From 8a1785c24423753b32d994e59966239c7a0091a6 Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 4 Jun 2026 15:35:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0memory=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=89=8D=E9=9C=80?= =?UTF-8?q?=E8=A6=81=E5=85=88list=E9=98=85=E8=AF=BB=E5=B7=B2=E6=9C=89?= =?UTF-8?q?=E7=9A=84=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .opencode/agents/instruction.md | 1 + .opencode/tools/memory_manager.ts | 36 ++++++---- .opencode/tools/skill_manager.ts | 4 +- src/learning/orchestrator.ts | 35 +++++++++- src/memory/store.ts | 105 ---------------------------- src/sessions/runtimeContextStore.ts | 1 + tests/memory/store.test.ts | 14 ++-- 7 files changed, 67 insertions(+), 129 deletions(-) diff --git a/.opencode/agents/instruction.md b/.opencode/agents/instruction.md index d41a9bb..0b689e0 100644 --- a/.opencode/agents/instruction.md +++ b/.opencode/agents/instruction.md @@ -88,3 +88,4 @@ CLI **不增加** `--input/--output`,数据转换由 `jq`/`xargs` 在 shell - `memory_manager` = 用户偏好 / 项目事实(如"用户要简洁风格"、"当前项目管网规模 5000 管段") - `skill_manager` = 可复用操作流程 - `session_search` = 检索历史案例(只读) +- 修改 memory 前先 `list` 当前 scope 的已有内容,先通读,再决定 `add / replace / remove` diff --git a/.opencode/tools/memory_manager.ts b/.opencode/tools/memory_manager.ts index 5512e09..db719e8 100644 --- a/.opencode/tools/memory_manager.ts +++ b/.opencode/tools/memory_manager.ts @@ -1,9 +1,9 @@ import { tool } from "@opencode-ai/plugin"; 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 toolContextStore = new ToolSessionContextStore(); +const toolContextStore = new SessionRuntimeContextStore(); const initializePromise = Promise.all([ memoryStore.initialize(), toolContextStore.initialize(), @@ -11,7 +11,7 @@ const initializePromise = Promise.all([ export default tool({ 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、replace 或 remove;不要跳过读取直接新增。禁止写入 token、password、secret、system prompt 或一次性上下文。scope 仅允许 'user' 或 'workspace'。", args: { action: tool.schema .enum(["add", "list", "replace", "remove"]) @@ -67,6 +67,14 @@ export default tool({ const scopeKey = scope === "user" ? sessionContext.actorKey : sessionContext.projectKey; if (args.action === "list") { + const readScopes = { + ...(sessionContext.memoryListReadScopes ?? {}), + [scope]: true, + }; + await toolContextStore.write({ + ...sessionContext, + memoryListReadScopes: readScopes, + }); return JSON.stringify({ ok: true, kind: "memory", @@ -78,6 +86,15 @@ export default tool({ } 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, { content: args.content ?? "", sessionId: sessionContext.clientSessionId, @@ -95,18 +112,9 @@ export default tool({ return JSON.stringify({ ok: true, kind: "memory", - decision: - result.changed - ? "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, + decision: result.changed ? "accepted" : "deduped", + detail: result.detail, entry: result.entry, - existing_entry: result.similar, target: scope, }); } diff --git a/.opencode/tools/skill_manager.ts b/.opencode/tools/skill_manager.ts index 2f22b57..6f3d200 100644 --- a/.opencode/tools/skill_manager.ts +++ b/.opencode/tools/skill_manager.ts @@ -1,9 +1,9 @@ import { tool } from "@opencode-ai/plugin"; 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 skillStore = new SkillStore(); diff --git a/src/learning/orchestrator.ts b/src/learning/orchestrator.ts index 49740fa..355f68c 100644 --- a/src/learning/orchestrator.ts +++ b/src/learning/orchestrator.ts @@ -240,9 +240,10 @@ export class LearningOrchestrator { traceId: input.requestContext.traceId, }); try { + const existingMemory = await this.loadMemoryContext(input.requestContext); await this.runtime.prompt( reviewSession.id, - buildReviewPrompt({ focus, recentTurns }), + buildReviewPrompt({ existingMemory, focus, recentTurns }), toRuntimeModel(input.model), ); 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( input: TurnReviewInput, proposal: ReviewResult["memories"][number], @@ -440,9 +449,14 @@ const buildGatePrompt = ({ recentTurns }: { recentTurns: SessionTurnRecord[] }) }; const buildReviewPrompt = ({ + existingMemory, focus, recentTurns, }: { + existingMemory: { + userMemory: Array<{ content: string; id: string }>; + workspaceMemory: Array<{ content: string; id: string }>; + }; focus: GateResult["focus"]; recentTurns: SessionTurnRecord[]; }) => { @@ -452,6 +466,16 @@ const buildReviewPrompt = ({ `Turn ${index + 1}\nUser: ${turn.userMessage}\nAssistant: ${turn.assistantMessage}\nTool calls: ${turn.toolCallCount}`, ) .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 [ "You are doing an internal self-improvement review for TJWaterAgent.", "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.", "- Use scope='user' for user preferences and constraints.", "- 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.", "", + "Current persisted memories:", + "[User memory]", + userMemoryBlock, + "[Workspace memory]", + workspaceMemoryBlock, + "", "Skill rules:", "- Save only reusable workflows, methods, or pitfalls that will help in future similar tasks.", "- Prefer append_pattern for concise reusable lessons.", diff --git a/src/memory/store.ts b/src/memory/store.ts index 0c2d6b7..46df2da 100644 --- a/src/memory/store.ts +++ b/src/memory/store.ts @@ -61,7 +61,6 @@ export class MemoryStore { changed: false, detail: "content rejected by persistence policy", entry: null as MemoryEntry | null, - similar: null as MemoryEntry | null, }; } @@ -72,17 +71,6 @@ export class MemoryStore { changed: false, detail: "memory already existed", 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, detail: "memory stored", entry, - similar: null as MemoryEntry | null, }; }); } @@ -130,13 +117,6 @@ export class MemoryStore { if (duplicate) { 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] = { content, id: entries[index]?.id ?? toStableId(scope, key, content.toLowerCase()), @@ -246,91 +226,6 @@ const normalizeMemoryContent = (content: string) => { 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(); - for (let index = 0; index < content.length - 1; index += 1) { - grams.add(content.slice(index, index + 2)); - } - return grams; -}; - -const diceCoefficient = (left: Set, right: Set) => { - 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[] => content .split("\n") diff --git a/src/sessions/runtimeContextStore.ts b/src/sessions/runtimeContextStore.ts index 2087c09..d10eb60 100644 --- a/src/sessions/runtimeContextStore.ts +++ b/src/sessions/runtimeContextStore.ts @@ -14,6 +14,7 @@ export type SessionRuntimeContext = { allowLearningWrite?: boolean; clientSessionId: string; learningMode?: "interactive" | "review"; + memoryListReadScopes?: Partial>; projectId?: string; projectKey: string; sessionId: string; diff --git a/tests/memory/store.test.ts b/tests/memory/store.test.ts index ff084c4..e3bae00 100644 --- a/tests/memory/store.test.ts +++ b/tests/memory/store.test.ts @@ -37,7 +37,7 @@ describe("MemoryStore", () => { 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", { content: "保存记忆前先查看当前 workspace memory,避免重复写入相同约束。", source: "tool", @@ -48,12 +48,12 @@ describe("MemoryStore", () => { source: "tool", }); - expect(result.changed).toBe(false); - expect(result.detail).toBe("similar memory already exists"); - expect(result.entry?.content).toBe("保存记忆前先查看当前 workspace memory,避免重复写入相同约束。"); + expect(result.changed).toBe(true); + expect(result.detail).toBe("memory stored"); + 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", { content: "回答时默认使用中文,并保持结论先行。", source: "tool", @@ -64,13 +64,13 @@ describe("MemoryStore", () => { }); const result = await store.replace("user", "actor-1", second.entry?.id ?? "", { - content: "默认使用中文回答,结论放在最前面。", + content: "回答时默认使用中文,并保持结论先行。", source: "tool", }); expect(first.changed).toBe(true); expect(second.changed).toBe(true); 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"); }); });