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",
+ );
+}