更新memory读取机制,新增前需要先list阅读已有的内容

This commit is contained in:
2026-06-04 15:35:01 +08:00
parent 0188240d62
commit 8a1785c244
7 changed files with 67 additions and 129 deletions
+1
View File
@@ -88,3 +88,4 @@ CLI **不增加** `--input/--output`,数据转换由 `jq`/`xargs` 在 shell
- `memory_manager` = 用户偏好 / 项目事实(如"用户要简洁风格"、"当前项目管网规模 5000 管段") - `memory_manager` = 用户偏好 / 项目事实(如"用户要简洁风格"、"当前项目管网规模 5000 管段")
- `skill_manager` = 可复用操作流程 - `skill_manager` = 可复用操作流程
- `session_search` = 检索历史案例(只读) - `session_search` = 检索历史案例(只读)
- 修改 memory 前先 `list` 当前 scope 的已有内容,先通读,再决定 `add / replace / remove`
+22 -14
View File
@@ -1,9 +1,9 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { MemoryStore } from "../../src/memory/store.js"; import { MemoryStore } from "../../src/memory/store.js";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js"; import { SessionRuntimeContextStore } from "../../src/sessions/runtimeContextStore.js";
const memoryStore = new MemoryStore(); const memoryStore = new MemoryStore();
const toolContextStore = new ToolSessionContextStore(); const toolContextStore = new SessionRuntimeContextStore();
const initializePromise = Promise.all([ const initializePromise = Promise.all([
memoryStore.initialize(), memoryStore.initialize(),
toolContextStore.initialize(), toolContextStore.initialize(),
@@ -11,7 +11,7 @@ const initializePromise = Promise.all([
export default tool({ export default tool({
description: description:
"管理长期有效的用户偏好或项目事实。支持 add/list/replace/remove。新增记忆前必须先查看同 scope 的现有记忆,避免写入近似重复项;如果已有相近内容,应优先 replace/remove 而不是重复 add。禁止写入 token、password、secret、system prompt 或一次性上下文。scope 仅允许 'user' 或 'workspace'。", "管理长期有效的用户偏好或项目事实。支持 add/list/replace/remove。add 前必须先同 scope 执行 list 并阅读现有记忆,再决定 add、replaceremove;不要跳过读取直接新增。禁止写入 token、password、secret、system prompt 或一次性上下文。scope 仅允许 'user' 或 'workspace'。",
args: { args: {
action: tool.schema action: tool.schema
.enum(["add", "list", "replace", "remove"]) .enum(["add", "list", "replace", "remove"])
@@ -67,6 +67,14 @@ export default tool({
const scopeKey = const scopeKey =
scope === "user" ? sessionContext.actorKey : sessionContext.projectKey; scope === "user" ? sessionContext.actorKey : sessionContext.projectKey;
if (args.action === "list") { if (args.action === "list") {
const readScopes = {
...(sessionContext.memoryListReadScopes ?? {}),
[scope]: true,
};
await toolContextStore.write({
...sessionContext,
memoryListReadScopes: readScopes,
});
return JSON.stringify({ return JSON.stringify({
ok: true, ok: true,
kind: "memory", kind: "memory",
@@ -78,6 +86,15 @@ export default tool({
} }
if (args.action === "add") { if (args.action === "add") {
if (sessionContext.memoryListReadScopes?.[scope] !== true) {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: `must list ${scope} memory and review existing entries before add`,
target: scope,
});
}
const result = await memoryStore.upsert(scope, scopeKey, { const result = await memoryStore.upsert(scope, scopeKey, {
content: args.content ?? "", content: args.content ?? "",
sessionId: sessionContext.clientSessionId, sessionId: sessionContext.clientSessionId,
@@ -95,18 +112,9 @@ export default tool({
return JSON.stringify({ return JSON.stringify({
ok: true, ok: true,
kind: "memory", kind: "memory",
decision: decision: result.changed ? "accepted" : "deduped",
result.changed detail: result.detail,
? "accepted"
: result.detail === "memory already existed"
? "deduped"
: "rejected",
detail:
result.detail === "similar memory already exists"
? "similar memory already exists; review listed memories before storing a rewritten variant"
: result.detail,
entry: result.entry, entry: result.entry,
existing_entry: result.similar,
target: scope, target: scope,
}); });
} }
+2 -2
View File
@@ -1,9 +1,9 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { SkillStore } from "../../src/skills/store.js"; import { SkillStore } from "../../src/skills/store.js";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js"; import { SessionRuntimeContextStore } from "../../src/sessions/runtimeContextStore.js";
const toolContextStore = new ToolSessionContextStore(); const toolContextStore = new SessionRuntimeContextStore();
const initializePromise = toolContextStore.initialize(); const initializePromise = toolContextStore.initialize();
const skillStore = new SkillStore(); const skillStore = new SkillStore();
+34 -1
View File
@@ -240,9 +240,10 @@ export class LearningOrchestrator {
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
try { try {
const existingMemory = await this.loadMemoryContext(input.requestContext);
await this.runtime.prompt( await this.runtime.prompt(
reviewSession.id, reviewSession.id,
buildReviewPrompt({ focus, recentTurns }), buildReviewPrompt({ existingMemory, focus, recentTurns }),
toRuntimeModel(input.model), toRuntimeModel(input.model),
); );
const messages = await this.runtime.messages(reviewSession.id, 20); const messages = await this.runtime.messages(reviewSession.id, 20);
@@ -312,6 +313,14 @@ export class LearningOrchestrator {
}); });
} }
private async loadMemoryContext(context: ChatRequestContext) {
const [userMemory, workspaceMemory] = await Promise.all([
this.memoryStore.list("user", context.actorKey),
this.memoryStore.list("workspace", context.projectKey),
]);
return { userMemory, workspaceMemory };
}
private async applyMemoryProposal( private async applyMemoryProposal(
input: TurnReviewInput, input: TurnReviewInput,
proposal: ReviewResult["memories"][number], proposal: ReviewResult["memories"][number],
@@ -440,9 +449,14 @@ const buildGatePrompt = ({ recentTurns }: { recentTurns: SessionTurnRecord[] })
}; };
const buildReviewPrompt = ({ const buildReviewPrompt = ({
existingMemory,
focus, focus,
recentTurns, recentTurns,
}: { }: {
existingMemory: {
userMemory: Array<{ content: string; id: string }>;
workspaceMemory: Array<{ content: string; id: string }>;
};
focus: GateResult["focus"]; focus: GateResult["focus"];
recentTurns: SessionTurnRecord[]; recentTurns: SessionTurnRecord[];
}) => { }) => {
@@ -452,6 +466,16 @@ const buildReviewPrompt = ({
`Turn ${index + 1}\nUser: ${turn.userMessage}\nAssistant: ${turn.assistantMessage}\nTool calls: ${turn.toolCallCount}`, `Turn ${index + 1}\nUser: ${turn.userMessage}\nAssistant: ${turn.assistantMessage}\nTool calls: ${turn.toolCallCount}`,
) )
.join("\n\n"); .join("\n\n");
const userMemoryBlock =
existingMemory.userMemory.length > 0
? existingMemory.userMemory.map((entry) => `- [${entry.id}] ${entry.content}`).join("\n")
: "(empty)";
const workspaceMemoryBlock =
existingMemory.workspaceMemory.length > 0
? existingMemory.workspaceMemory
.map((entry) => `- [${entry.id}] ${entry.content}`)
.join("\n")
: "(empty)";
return [ return [
"You are doing an internal self-improvement review for TJWaterAgent.", "You are doing an internal self-improvement review for TJWaterAgent.",
"Do NOT call any tools. Return JSON only. Do NOT wrap in markdown fences.", "Do NOT call any tools. Return JSON only. Do NOT wrap in markdown fences.",
@@ -462,8 +486,17 @@ const buildReviewPrompt = ({
"- Keep only stable user preferences, durable constraints, or stable workspace facts.", "- Keep only stable user preferences, durable constraints, or stable workspace facts.",
"- Use scope='user' for user preferences and constraints.", "- Use scope='user' for user preferences and constraints.",
"- Use scope='workspace' for project or environment facts.", "- Use scope='workspace' for project or environment facts.",
"- Read the existing memories first before proposing changes.",
"- If a new lesson overlaps, refines, or supersedes an existing memory, prefer replace/remove using target_id instead of add.",
"- Use add only when the lesson is genuinely new after reviewing the existing memory list.",
"- Do not store one-off task outcomes, temporary facts, or speculative conclusions.", "- Do not store one-off task outcomes, temporary facts, or speculative conclusions.",
"", "",
"Current persisted memories:",
"[User memory]",
userMemoryBlock,
"[Workspace memory]",
workspaceMemoryBlock,
"",
"Skill rules:", "Skill rules:",
"- Save only reusable workflows, methods, or pitfalls that will help in future similar tasks.", "- Save only reusable workflows, methods, or pitfalls that will help in future similar tasks.",
"- Prefer append_pattern for concise reusable lessons.", "- Prefer append_pattern for concise reusable lessons.",
-105
View File
@@ -61,7 +61,6 @@ export class MemoryStore {
changed: false, changed: false,
detail: "content rejected by persistence policy", detail: "content rejected by persistence policy",
entry: null as MemoryEntry | null, entry: null as MemoryEntry | null,
similar: null as MemoryEntry | null,
}; };
} }
@@ -72,17 +71,6 @@ export class MemoryStore {
changed: false, changed: false,
detail: "memory already existed", detail: "memory already existed",
entry: existing, entry: existing,
similar: existing,
};
}
const similar = findSimilarMemory(entries, content);
if (similar) {
return {
changed: false,
detail: "similar memory already exists",
entry: similar,
similar,
}; };
} }
@@ -104,7 +92,6 @@ export class MemoryStore {
changed: true, changed: true,
detail: "memory stored", detail: "memory stored",
entry, entry,
similar: null as MemoryEntry | null,
}; };
}); });
} }
@@ -130,13 +117,6 @@ export class MemoryStore {
if (duplicate) { if (duplicate) {
return { changed: false, detail: "replacement would duplicate an existing memory" }; return { changed: false, detail: "replacement would duplicate an existing memory" };
} }
const similar = findSimilarMemory(entries, content, entries[index]?.id);
if (similar) {
return {
changed: false,
detail: "replacement would overlap with a similar existing memory",
};
}
entries[index] = { entries[index] = {
content, content,
id: entries[index]?.id ?? toStableId(scope, key, content.toLowerCase()), id: entries[index]?.id ?? toStableId(scope, key, content.toLowerCase()),
@@ -246,91 +226,6 @@ const normalizeMemoryContent = (content: string) => {
return normalized; return normalized;
}; };
const findSimilarMemory = (
entries: MemoryEntry[],
content: string,
excludeId?: string,
) =>
entries.find(
(entry) => entry.id !== excludeId && areSimilarMemoryContents(entry.content, content),
) ?? null;
const areSimilarMemoryContents = (left: string, right: string) => {
const normalizedLeft = normalizeComparableMemory(left);
const normalizedRight = normalizeComparableMemory(right);
if (!normalizedLeft || !normalizedRight) {
return false;
}
if (normalizedLeft === normalizedRight) {
return true;
}
const [shorter, longer] =
normalizedLeft.length <= normalizedRight.length
? [normalizedLeft, normalizedRight]
: [normalizedRight, normalizedLeft];
if (shorter.length >= 12 && longer.includes(shorter)) {
return true;
}
if (shorter.length < 8) {
return false;
}
if (
longestCommonSubsequenceLength(normalizedLeft, normalizedRight) / shorter.length >= 0.5
) {
return true;
}
return (
diceCoefficient(buildCharacterBigrams(normalizedLeft), buildCharacterBigrams(normalizedRight)) >=
0.72
);
};
const normalizeComparableMemory = (content: string) =>
normalizeMemoryContent(content)
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "");
const buildCharacterBigrams = (content: string) => {
const grams = new Set<string>();
for (let index = 0; index < content.length - 1; index += 1) {
grams.add(content.slice(index, index + 2));
}
return grams;
};
const diceCoefficient = (left: Set<string>, right: Set<string>) => {
if (left.size === 0 || right.size === 0) {
return 0;
}
let overlap = 0;
for (const item of left) {
if (right.has(item)) {
overlap += 1;
}
}
return (2 * overlap) / (left.size + right.size);
};
const longestCommonSubsequenceLength = (left: string, right: string) => {
const previous = new Array(right.length + 1).fill(0);
const current = new Array(right.length + 1).fill(0);
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
current[rightIndex] =
left[leftIndex - 1] === right[rightIndex - 1]
? previous[rightIndex - 1] + 1
: Math.max(previous[rightIndex], current[rightIndex - 1]);
}
for (let rightIndex = 0; rightIndex <= right.length; rightIndex += 1) {
previous[rightIndex] = current[rightIndex];
current[rightIndex] = 0;
}
}
return previous[right.length];
};
const parseMemoryMarkdown = (content: string): MemoryEntry[] => const parseMemoryMarkdown = (content: string): MemoryEntry[] =>
content content
.split("\n") .split("\n")
+1
View File
@@ -14,6 +14,7 @@ export type SessionRuntimeContext = {
allowLearningWrite?: boolean; allowLearningWrite?: boolean;
clientSessionId: string; clientSessionId: string;
learningMode?: "interactive" | "review"; learningMode?: "interactive" | "review";
memoryListReadScopes?: Partial<Record<"user" | "workspace", boolean>>;
projectId?: string; projectId?: string;
projectKey: string; projectKey: string;
sessionId: string; sessionId: string;
+7 -7
View File
@@ -37,7 +37,7 @@ describe("MemoryStore", () => {
expect(second.detail).toBe("memory already existed"); expect(second.detail).toBe("memory already existed");
}); });
it("rejects rewritten memories that are too similar to an existing one", async () => { it("allows rewritten memories when the content is not exactly the same", async () => {
await store.upsert("workspace", "project-1", { await store.upsert("workspace", "project-1", {
content: "保存记忆前先查看当前 workspace memory,避免重复写入相同约束。", content: "保存记忆前先查看当前 workspace memory,避免重复写入相同约束。",
source: "tool", source: "tool",
@@ -48,12 +48,12 @@ describe("MemoryStore", () => {
source: "tool", source: "tool",
}); });
expect(result.changed).toBe(false); expect(result.changed).toBe(true);
expect(result.detail).toBe("similar memory already exists"); expect(result.detail).toBe("memory stored");
expect(result.entry?.content).toBe("保存记忆前先查看当前 workspace memory,避免重复写入相同约束。"); expect(result.entry?.content).toBe("写入前先看一遍当前 workspace 记忆,避免把同样的约束重复保存进去。");
}); });
it("rejects replace when the new content overlaps a similar existing memory", async () => { it("rejects replace when the new content would become an exact duplicate", async () => {
const first = await store.upsert("user", "actor-1", { const first = await store.upsert("user", "actor-1", {
content: "回答时默认使用中文,并保持结论先行。", content: "回答时默认使用中文,并保持结论先行。",
source: "tool", source: "tool",
@@ -64,13 +64,13 @@ describe("MemoryStore", () => {
}); });
const result = await store.replace("user", "actor-1", second.entry?.id ?? "", { const result = await store.replace("user", "actor-1", second.entry?.id ?? "", {
content: "默认使用中文回答,结论放在最前面。", content: "回答时默认使用中文,并保持结论先行。",
source: "tool", source: "tool",
}); });
expect(first.changed).toBe(true); expect(first.changed).toBe(true);
expect(second.changed).toBe(true); expect(second.changed).toBe(true);
expect(result.changed).toBe(false); expect(result.changed).toBe(false);
expect(result.detail).toBe("replacement would overlap with a similar existing memory"); expect(result.detail).toBe("replacement would duplicate an existing memory");
}); });
}); });