diff --git a/docs/RECALL-HISTORY-COMPACTION.md b/docs/RECALL-HISTORY-COMPACTION.md new file mode 100644 index 00000000..c6f3c6b9 --- /dev/null +++ b/docs/RECALL-HISTORY-COMPACTION.md @@ -0,0 +1,60 @@ +# Recall History Compaction + +Related issue: https://github.com/TencentCloud/TencentDB-Agent-Memory/issues/120 + +This is a narrow follow-up for injected memory history that survives into +session messages. It is not the primary runtime prompt-cache fix for #120. +Runtime injection-mode behavior and provider-facing cache boundaries should be +handled by the OpenClaw/runtime-side follow-up work. + +## Scope + +Dynamic L1 recall is injected as: + +```xml + +... + +``` + +That block is useful for the active turn. If it is later persisted or replayed +from history, the full recalled payload becomes stale context. This change +compacts only that historical payload into a stable marker: + +```xml + +``` + +The marker keeps an audit trail that memory injection happened, while removing +the turn-specific recalled content from future history. + +## Non-goals + +- Do not change the default recall injection mode. +- Do not dedupe or reorder system messages. +- Do not make provider-specific cache-hit claims. +- Do not replace runtime-side prompt-cache fixes. + +## Where It Runs + +1. `before_message_write` + + User messages containing `` are compacted before they are + written to session history. + +2. `before_prompt_build` + + Existing history is compacted again as a migration/safety pass. This catches + older sessions or host paths where injected memory already reached + `event.messages`. + +## Validation + +Run: + +```bash +npm test -- src/utils/memory-injection-cache.test.ts +npm test +npm run build +git diff --check +``` diff --git a/index.ts b/index.ts index 868a7701..e42dd405 100644 --- a/index.ts +++ b/index.ts @@ -35,6 +35,11 @@ import { registerMemoryTdaiCli } from "./src/cli/index.js"; import { initDataDirectories, resetStores } from "./src/utils/pipeline-factory.js"; import { getOrCreateInstanceId, initReporter, report, resetReporter } from "./src/core/report/reporter.js"; import { ensureL2L3Local } from "./src/core/profile/profile-sync.js"; +import { + compactRelevantMemoriesInMessage, + prepareMessagesForPromptCache, + type PromptCacheMessage, +} from "./src/utils/memory-injection-cache.js"; // Core abstractions (host-neutral) import { OpenClawHostAdapter } from "./src/adapters/openclaw/host-adapter.js"; @@ -541,12 +546,24 @@ export default function register(api: OpenClawPluginApi) { // Cache original user prompt for agent_end const rawPrompt = event.prompt; - const messages = Array.isArray(event.messages) ? event.messages : undefined; + const messages = Array.isArray(event.messages) ? (event.messages as PromptCacheMessage[]) : undefined; if (sessionKey && rawPrompt) { const messageCount = messages?.length ?? 0; pendingOriginalPrompts.set(sessionKey, { text: rawPrompt, ts: Date.now(), messageCount }); api.logger.debug?.(`${TAG} [before_prompt_build] Cached original prompt (${rawPrompt.length} chars, msgCount=${messageCount})`); } + if (messages) { + const prepared = prepareMessagesForPromptCache(messages); + if (prepared.compacted.messagesChanged > 0) { + (event as { messages?: unknown }).messages = prepared.messages; + api.logger.debug?.( + `${TAG} [before_prompt_build] Compacted injected memory history: ` + + `compactedMessages=${prepared.compacted.messagesChanged}, ` + + `textParts=${prepared.compacted.textPartsChanged}, ` + + `removedChars=${prepared.compacted.removedChars}`, + ); + } + } sweepStaleCaches(); const userText = rawPrompt; @@ -612,41 +629,25 @@ export default function register(api: OpenClawPluginApi) { }); } - // Strip 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 )`); + // Compact before user messages are persisted to the + // session JSONL. The current-turn LLM already saw the full prompt + // (effectivePrompt lives in memory), but future turns should only replay a + // stable marker instead of stale recall details. + api.logger.debug?.(`${TAG} Registering before_message_write hook (compact )`); api.on("before_message_write", (event) => { - const msg = event.message as { role?: string; content?: unknown }; + const msg = event.message as PromptCacheMessage; 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 (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 compacted = compactRelevantMemoriesInMessage(msg); + if (compacted.changed) { + api.logger.debug?.( + `${TAG} [before_message_write] Compacted memory injection: ` + + `removedChars=${compacted.removedChars}, textParts=${compacted.textPartsChanged}`, + ); + return { message: compacted.message as typeof event.message }; } }); diff --git a/src/utils/memory-injection-cache.test.ts b/src/utils/memory-injection-cache.test.ts new file mode 100644 index 00000000..0b6033ed --- /dev/null +++ b/src/utils/memory-injection-cache.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { + MEMORY_OMITTED_MARKER, + compactRelevantMemoriesInMessage, + compactRelevantMemoriesText, + prepareMessagesForPromptCache, +} from "./memory-injection-cache.js"; + +describe("memory injection history compaction", () => { + it("compacts relevant memory blocks in string content", () => { + const result = compactRelevantMemoriesText( + `\n- [episodic] noisy recalled fact\n\nPlease continue the task.`, + ); + + expect(result.changed).toBe(true); + expect(result.text).toBe(`${MEMORY_OMITTED_MARKER}\nPlease continue the task.`); + expect(result.text).not.toContain("noisy recalled fact"); + expect(result.removedChars).toBeGreaterThan(0); + }); + + it("compacts only text parts in multimodal message content", () => { + const imagePart = { type: "image", image: "data:image/png;base64,abc" }; + const message = { + role: "user", + content: [ + { + type: "text", + text: `hello\n\n- volatile memory\n\nworld`, + }, + imagePart, + ], + }; + + const result = compactRelevantMemoriesInMessage(message); + const parts = result.message.content as Array<{ type: string; text?: string; image?: string }>; + + expect(result.changed).toBe(true); + expect(result.textPartsChanged).toBe(1); + expect(parts[0].text).toBe(`hello\n${MEMORY_OMITTED_MARKER}\nworld`); + expect(parts[0].text).not.toContain("volatile memory"); + expect(parts[1]).toBe(imagePart); + }); + + it("compacts historical memory without changing unrelated messages", () => { + const messages = [ + { role: "system", content: "stable system instructions" }, + { + role: "user", + content: `\n- turn-specific memory\n\nWhat changed?`, + }, + { role: "assistant", content: "A short answer." }, + { role: "system", content: "stable system instructions" }, + { role: "system", content: "different system instructions" }, + ]; + + const prepared = prepareMessagesForPromptCache(messages); + + expect(prepared.compacted.messagesChanged).toBe(1); + expect(prepared.messages).toHaveLength(5); + expect(prepared.messages.map((message) => message.content)).toEqual([ + "stable system instructions", + `${MEMORY_OMITTED_MARKER}\nWhat changed?`, + "A short answer.", + "stable system instructions", + "different system instructions", + ]); + }); + + it("normalizes different recalled-memory histories to the same cache prefix", () => { + const sessionA = [ + { role: "system", content: "stable system instructions" }, + { + role: "user", + content: `\n- alpha-only memory\n\nContinue.`, + }, + ]; + const sessionB = [ + { role: "system", content: "stable system instructions" }, + { + role: "user", + content: `\n- beta-only memory\n\nContinue.`, + }, + ]; + + expect(JSON.stringify(sessionA)).not.toBe(JSON.stringify(sessionB)); + + const preparedA = prepareMessagesForPromptCache(sessionA).messages; + const preparedB = prepareMessagesForPromptCache(sessionB).messages; + + expect(JSON.stringify(preparedA)).toBe(JSON.stringify(preparedB)); + }); + + it("leaves messages unchanged when there is no injected memory", () => { + const message = { role: "user", content: "plain prompt" }; + const result = compactRelevantMemoriesInMessage(message); + + expect(result.changed).toBe(false); + expect(result.message).toBe(message); + }); +}); diff --git a/src/utils/memory-injection-cache.ts b/src/utils/memory-injection-cache.ts new file mode 100644 index 00000000..94088a05 --- /dev/null +++ b/src/utils/memory-injection-cache.ts @@ -0,0 +1,138 @@ +/** + * Utilities for compacting injected L1 recall blocks that leaked into session + * history. Dynamic recall belongs in the current turn; historical copies + * collapse to a stable marker. + */ + +export const MEMORY_OMITTED_MARKER = ''; + +const RELEVANT_MEMORIES_BLOCK_RE = /]*>[\s\S]*?<\/relevant-memories>\s*/gi; + +export interface PromptCacheMessage { + role?: unknown; + content?: unknown; + [key: string]: unknown; +} + +export interface CompactTextResult { + text: string; + changed: boolean; + removedChars: number; +} + +export interface CompactMessageResult { + message: T; + changed: boolean; + textPartsChanged: number; + removedChars: number; +} + +export interface PromptCachePreparation { + messages: T[]; + compacted: { + messagesChanged: number; + textPartsChanged: number; + removedChars: number; + }; +} + +export function compactRelevantMemoriesText( + text: string, + marker = MEMORY_OMITTED_MARKER, +): CompactTextResult { + if (!/( + message: T, + marker = MEMORY_OMITTED_MARKER, +): CompactMessageResult { + const content = message.content; + + if (typeof content === "string") { + const result = compactRelevantMemoriesText(content, marker); + if (!result.changed) { + return { message, changed: false, textPartsChanged: 0, removedChars: 0 }; + } + return { + message: { ...message, content: result.text } as T, + changed: true, + textPartsChanged: 1, + removedChars: result.removedChars, + }; + } + + if (!Array.isArray(content)) { + return { message, changed: false, textPartsChanged: 0, removedChars: 0 }; + } + + let changed = false; + let textPartsChanged = 0; + let removedChars = 0; + const compactedParts = content.map((part) => { + if (!isTextPart(part)) return part; + const result = compactRelevantMemoriesText(part.text, marker); + if (!result.changed) return part; + + changed = true; + textPartsChanged += 1; + removedChars += result.removedChars; + return { ...part, text: result.text }; + }); + + if (!changed) { + return { message, changed: false, textPartsChanged: 0, removedChars: 0 }; + } + + return { + message: { ...message, content: compactedParts } as T, + changed: true, + textPartsChanged, + removedChars, + }; +} + +export function prepareMessagesForPromptCache( + messages: readonly T[], +): PromptCachePreparation { + let messagesChanged = 0; + let textPartsChanged = 0; + let removedChars = 0; + + const compactedMessages = messages.map((message) => { + const result = compactRelevantMemoriesInMessage(message); + if (result.changed) { + messagesChanged += 1; + textPartsChanged += result.textPartsChanged; + removedChars += result.removedChars; + } + return result.message; + }); + + return { + messages: compactedMessages, + compacted: { messagesChanged, textPartsChanged, removedChars }, + }; +} + +function isTextPart(part: unknown): part is { type: "text"; text: string; [key: string]: unknown } { + return Boolean( + part && + typeof part === "object" && + (part as { type?: unknown }).type === "text" && + typeof (part as { text?: unknown }).text === "string", + ); +}