更新memory读取机制,新增前需要先list阅读已有的内容
This commit is contained in:
@@ -88,3 +88,4 @@ CLI **不增加** `--input/--output`,数据转换由 `jq`/`xargs` 在 shell
|
||||
- `memory_manager` = 用户偏好 / 项目事实(如"用户要简洁风格"、"当前项目管网规模 5000 管段")
|
||||
- `skill_manager` = 可复用操作流程
|
||||
- `session_search` = 检索历史案例(只读)
|
||||
- 修改 memory 前先 `list` 当前 scope 的已有内容,先通读,再决定 `add / replace / remove`
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<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[] =>
|
||||
content
|
||||
.split("\n")
|
||||
|
||||
@@ -14,6 +14,7 @@ export type SessionRuntimeContext = {
|
||||
allowLearningWrite?: boolean;
|
||||
clientSessionId: string;
|
||||
learningMode?: "interactive" | "review";
|
||||
memoryListReadScopes?: Partial<Record<"user" | "workspace", boolean>>;
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
sessionId: string;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user