diff --git a/README.md b/README.md index 5b412cd..2348114 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,7 @@ If `MEMORY_TENCENTDB_GATEWAY_API_KEY` is unset, the plugin also looks at `TDAI_G | `recall.maxResults` | `5` | Number of items returned per recall | | `recall.maxCharsPerMemory` | `0` | Max characters injected for one recalled L1 memory; `0` disables this guard | | `recall.maxTotalRecallChars` | `0` | Total character budget for auto-recalled L1 memories; `0` disables this guard | +| `recall.showInjected` | `false` | Preserve injected recall context in conversation history so users can inspect what memory was used | | `pipeline.everyNConversations` | `5` | Trigger an L1 memory extraction every N turns | | `extraction.maxMemoriesPerSession` | `20` | Max memories extracted per L1 pass | | `persona.triggerEveryN` | `50` | Generate the user persona every N new memories | diff --git a/README_CN.md b/README_CN.md index 46cf341..8c2efe7 100644 --- a/README_CN.md +++ b/README_CN.md @@ -305,6 +305,7 @@ export MEMORY_TENCENTDB_GATEWAY_API_KEY="<与 Gateway 同一份密钥>" | `recall.maxResults` | `5` | 每次召回条数 | | `recall.maxCharsPerMemory` | `0` | 单条 L1 记忆注入的最大字符数;`0` 表示不限制 | | `recall.maxTotalRecallChars` | `0` | 每轮 auto-recall 注入的 L1 记忆总字符预算;`0` 表示不限制 | +| `recall.showInjected` | `false` | 在会话历史中保留 auto-recall 注入的记忆上下文,方便用户检查本轮使用了哪些记忆 | | `pipeline.everyNConversations` | `5` | 每 N 轮对话触发一次 L1 记忆提取 | | `extraction.maxMemoriesPerSession` | `20` | 单次 L1 最多提取多少条 | | `persona.triggerEveryN` | `50` | 每 N 条新记忆触发用户画像生成 | diff --git a/index.ts b/index.ts index 868a770..0c39a87 100644 --- a/index.ts +++ b/index.ts @@ -44,6 +44,7 @@ import { decideHookPolicy, } from "./src/utils/ensure-hook-policy.js"; import { resolveOpenClawStateDir } from "./src/utils/openclaw-state-dir.js"; +import { stripInjectedRecallFromMessage } from "./src/utils/recall-injection.js"; const TAG = "[memory-tdai]"; @@ -612,42 +613,28 @@ export default function register(api: OpenClawPluginApi) { }); } - // Strip from user messages before they are persisted to + // Strip injected recall context from user messages before they are persisted to // the session JSONL. The current-turn LLM already saw the full prompt // (effectivePrompt lives in memory), but we don't want recall artifacts // polluting the historical transcript for future replays. - api.logger.debug?.(`${TAG} Registering before_message_write hook (strip )`); + api.logger.debug?.( + `${TAG} Registering before_message_write hook ` + + `(recall.showInjected=${cfg.recall.showInjected ? "true" : "false"})`, + ); api.on("before_message_write", (event) => { const msg = event.message as { role?: string; content?: unknown }; const contentType = typeof msg.content === "string" ? "string" : Array.isArray(msg.content) ? "parts" : typeof msg.content; api.logger.debug?.(`${TAG} [before_message_write] role=${msg.role}, contentType=${contentType}`); - if (msg.role !== "user") return; - - // UserMessage.content: string | (TextContent | ImageContent)[] - const STRIP_RE = /[\s\S]*?<\/relevant-memories>\s*/g; - - if (typeof msg.content === "string") { - if (!msg.content.includes("")) return; - const cleaned = msg.content.replace(STRIP_RE, "").trim(); - if (cleaned === msg.content) return; - api.logger.debug?.(`${TAG} [before_message_write] Stripped: ${msg.content.length} → ${cleaned.length} chars`); - return { message: { ...event.message, content: cleaned } as typeof event.message }; + if (cfg.recall.showInjected) { + api.logger.debug?.(`${TAG} [before_message_write] recall.showInjected=true, preserving injected recall context`); + return; } - if (Array.isArray(msg.content)) { - let totalStripped = 0; - const cleanedParts = (msg.content as Array>).map((part) => { - if (part.type !== "text" || typeof part.text !== "string") return part; - if (!(part.text as string).includes("")) return part; - const cleaned = (part.text as string).replace(STRIP_RE, "").trim(); - totalStripped += (part.text as string).length - cleaned.length; - return { ...part, text: cleaned }; - }); - if (totalStripped === 0) return; - api.logger.debug?.(`${TAG} [before_message_write] Stripped from parts: removed ${totalStripped} chars`); - return { message: { ...event.message, content: cleanedParts } as unknown as typeof event.message }; - } + const stripped = stripInjectedRecallFromMessage(event.message as { role?: string; content?: unknown }); + if (!stripped) return; + api.logger.debug?.(`${TAG} [before_message_write] Stripped injected recall context: removed ${stripped.strippedChars} chars`); + return { message: stripped.message as typeof event.message }; }); // After agent end: auto-capture + L0 record + L1/L2/L3 schedule diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 8723713..478e729 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -77,6 +77,7 @@ "maxResults": { "type": "number", "default": 5, "description": "召回最大结果数" }, "maxCharsPerMemory": { "type": "number", "default": 0, "description": "单条 L1 记忆注入的最大字符数;填 0 表示不限制" }, "maxTotalRecallChars": { "type": "number", "default": 0, "description": "本轮 auto-recall 注入的 L1 记忆总字符预算;填 0 表示不限制" }, + "showInjected": { "type": "boolean", "default": false, "description": "是否在会话历史中保留 auto-recall 注入的记忆上下文,便于用户查看召回内容" }, "scoreThreshold": { "type": "number", "default": 0.3, "description": "最低分数阈值" }, "strategy": { "type": "string", "enum": ["embedding", "keyword", "hybrid"], "default": "hybrid", "description": "搜索策略:keyword(关键词)、embedding(向量)、hybrid(混合RRF融合,推荐)" }, "timeoutMs": { "type": "number", "default": 5000, "description": "召回整体超时(毫秒),超时后跳过记忆注入并打印警告日志" } diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..61664a1 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { parseConfig } from "./config.js"; + +describe("parseConfig recall.showInjected", () => { + it("defaults to false", () => { + expect(parseConfig({}).recall.showInjected).toBe(false); + }); + + it("accepts explicit true", () => { + expect(parseConfig({ recall: { showInjected: true } }).recall.showInjected).toBe(true); + }); +}); diff --git a/src/config.ts b/src/config.ts index 7587a5a..ba0bbe9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -84,6 +84,8 @@ export interface RecallConfig { maxCharsPerMemory: number; /** Max total characters injected for all recalled L1 memories. 0 disables the total limit. */ maxTotalRecallChars: number; + /** Preserve injected recall context in persisted user messages for transparency (default: false). */ + showInjected: boolean; /** Minimum score threshold (default: 0.3) */ scoreThreshold: number; /** Search strategy (default: "hybrid") */ @@ -511,6 +513,7 @@ export function parseConfig(raw: Record | undefined): MemoryTda maxResults: num(recallGroup, "maxResults") ?? 5, maxCharsPerMemory: num(recallGroup, "maxCharsPerMemory") ?? 0, maxTotalRecallChars: num(recallGroup, "maxTotalRecallChars") ?? 0, + showInjected: bool(recallGroup, "showInjected") ?? false, scoreThreshold: num(recallGroup, "scoreThreshold") ?? 0.3, strategy: validateStrategy(str(recallGroup, "strategy")) ?? "hybrid", timeoutMs: num(recallGroup, "timeoutMs") ?? 5000, diff --git a/src/utils/recall-injection.test.ts b/src/utils/recall-injection.test.ts new file mode 100644 index 0000000..f68b87b --- /dev/null +++ b/src/utils/recall-injection.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { stripInjectedRecallFromMessage, stripInjectedRecallText } from "./recall-injection.js"; + +describe("recall injection stripping", () => { + it("removes relevant memories from user string content", () => { + const text = [ + "", + "记忆召回:", + "- [instruction|work] User prefers concise updates.", + "", + "Please summarize the task.", + ].join("\n"); + + expect(stripInjectedRecallText(text)).toBe("Please summarize the task."); + }); + + it("leaves ordinary text unchanged", () => { + expect(stripInjectedRecallText("Please summarize the task.")).toBe("Please summarize the task."); + }); + + it("removes relevant memories from text parts only", () => { + const result = stripInjectedRecallFromMessage({ + role: "user", + content: [ + { type: "text", text: "\nold memory\n\nActual prompt" }, + { type: "image", data: "abc" }, + ], + }); + + expect(result?.message.content).toEqual([ + { type: "text", text: "Actual prompt" }, + { type: "image", data: "abc" }, + ]); + expect(result?.strippedChars).toBeGreaterThan(0); + }); + + it("does not strip assistant messages", () => { + const result = stripInjectedRecallFromMessage({ + role: "assistant", + content: "\nold memory\n\nAnswer", + }); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/utils/recall-injection.ts b/src/utils/recall-injection.ts new file mode 100644 index 0000000..9b6bf02 --- /dev/null +++ b/src/utils/recall-injection.ts @@ -0,0 +1,47 @@ +const RELEVANT_MEMORIES_RE = /[\s\S]*?<\/relevant-memories>\s*/g; + +export type MessageWithContent = { + role?: string; + content?: unknown; +}; + +/** + * Remove auto-recall prompt artifacts before user messages are persisted. + * The current LLM turn has already seen the injected context; this keeps + * historical transcripts clean unless recall.showInjected is enabled. + */ +export function stripInjectedRecallFromMessage( + message: T, +): { message: T; strippedChars: number } | undefined { + if (message.role !== "user") return undefined; + + if (typeof message.content === "string") { + const cleaned = stripInjectedRecallText(message.content); + if (cleaned === message.content) return undefined; + return { + message: { ...message, content: cleaned }, + strippedChars: message.content.length - cleaned.length, + }; + } + + if (!Array.isArray(message.content)) return undefined; + + let strippedChars = 0; + const cleanedParts = (message.content as Array>).map((part) => { + if (part.type !== "text" || typeof part.text !== "string") return part; + const cleaned = stripInjectedRecallText(part.text); + strippedChars += part.text.length - cleaned.length; + return cleaned === part.text ? part : { ...part, text: cleaned }; + }); + + if (strippedChars === 0) return undefined; + return { + message: { ...message, content: cleanedParts }, + strippedChars, + }; +} + +export function stripInjectedRecallText(text: string): string { + if (!text.includes("")) return text; + return text.replace(RELEVANT_MEMORIES_RE, "").trim(); +}