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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 条新记忆触发用户画像生成 |
Expand Down
39 changes: 13 additions & 26 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]";

Expand Down Expand Up @@ -612,42 +613,28 @@ export default function register(api: OpenClawPluginApi) {
});
}

// Strip <relevant-memories> 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 <relevant-memories>)`);
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 = /<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 (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<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 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
Expand Down
1 change: 1 addition & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "召回整体超时(毫秒),超时后跳过记忆注入并打印警告日志" }
Expand Down
12 changes: 12 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") */
Expand Down Expand Up @@ -511,6 +513,7 @@ export function parseConfig(raw: Record<string, unknown> | 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,
Expand Down
45 changes: 45 additions & 0 deletions src/utils/recall-injection.test.ts
Original file line number Diff line number Diff line change
@@ -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 = [
"<relevant-memories>",
"记忆召回:",
"- [instruction|work] User prefers concise updates.",
"</relevant-memories>",
"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: "<relevant-memories>\nold memory\n</relevant-memories>\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: "<relevant-memories>\nold memory\n</relevant-memories>\nAnswer",
});

expect(result).toBeUndefined();
});
});
47 changes: 47 additions & 0 deletions src/utils/recall-injection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const RELEVANT_MEMORIES_RE = /<relevant-memories>[\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<T extends MessageWithContent>(
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<Record<string, unknown>>).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("<relevant-memories>")) return text;
return text.replace(RELEVANT_MEMORIES_RE, "").trim();
}