Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions docs/RECALL-HISTORY-COMPACTION.md
Original file line number Diff line number Diff line change
@@ -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
<relevant-memories>
...
</relevant-memories>
```

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
<memory-omitted reason="prevent_context_bloat" />
```

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 `<relevant-memories>` 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
```
61 changes: 31 additions & 30 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -612,41 +629,25 @@ export default function register(api: OpenClawPluginApi) {
});
}

// Strip <relevant-memories> 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 <relevant-memories>)`);
// Compact <relevant-memories> 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 <relevant-memories>)`);
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 = /<relevant-memories>[\s\S]*?<\/relevant-memories>\s*/g;

if (typeof msg.content === "string") {
if (!msg.content.includes("<relevant-memories>")) 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<Record<string, unknown>>).map((part) => {
if (part.type !== "text" || typeof part.text !== "string") return part;
if (!(part.text as string).includes("<relevant-memories>")) 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 };
}
});

Expand Down
101 changes: 101 additions & 0 deletions src/utils/memory-injection-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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(
`<relevant-memories>\n- [episodic] noisy recalled fact\n</relevant-memories>\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<relevant-memories>\n- volatile memory\n</relevant-memories>\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: `<relevant-memories>\n- turn-specific memory\n</relevant-memories>\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: `<relevant-memories>\n- alpha-only memory\n</relevant-memories>\nContinue.`,
},
];
const sessionB = [
{ role: "system", content: "stable system instructions" },
{
role: "user",
content: `<relevant-memories>\n- beta-only memory\n</relevant-memories>\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);
});
});
138 changes: 138 additions & 0 deletions src/utils/memory-injection-cache.ts
Original file line number Diff line number Diff line change
@@ -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 = '<memory-omitted reason="prevent_context_bloat" />';

const RELEVANT_MEMORIES_BLOCK_RE = /<relevant-memories\b[^>]*>[\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<T extends PromptCacheMessage> {
message: T;
changed: boolean;
textPartsChanged: number;
removedChars: number;
}

export interface PromptCachePreparation<T extends PromptCacheMessage> {
messages: T[];
compacted: {
messagesChanged: number;
textPartsChanged: number;
removedChars: number;
};
}

export function compactRelevantMemoriesText(
text: string,
marker = MEMORY_OMITTED_MARKER,
): CompactTextResult {
if (!/<relevant-memories\b/i.test(text)) {
return { text, changed: false, removedChars: 0 };
}

const compacted = text
.replace(RELEVANT_MEMORIES_BLOCK_RE, `${marker}\n`)
.replace(/\n{3,}/g, "\n\n")
.trim();

return {
text: compacted,
changed: compacted !== text,
removedChars: Math.max(0, text.length - compacted.length),
};
}

export function compactRelevantMemoriesInMessage<T extends PromptCacheMessage>(
message: T,
marker = MEMORY_OMITTED_MARKER,
): CompactMessageResult<T> {
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<T extends PromptCacheMessage>(
messages: readonly T[],
): PromptCachePreparation<T> {
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",
);
}