Skip to content
Closed
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
45 changes: 31 additions & 14 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,12 +584,14 @@ export default function register(api: OpenClawPluginApi) {
pendingRecallEndTimestamps.set(resolvedSessionKey, Date.now());
}

if (result?.appendSystemContext || result?.prependContext) {
if (result?.appendSystemContext || result?.prependContext || result?.prependSystemAddition) {
const appendLen = result.appendSystemContext?.length ?? 0;
const prependLen = result.prependContext?.length ?? 0;
const prependSysLen = result.prependSystemAddition?.length ?? 0;
api.logger.info(
`${TAG} [before_prompt_build] Recall complete (${elapsedMs}ms), ` +
`appendSystemContext=${appendLen} chars, prependContext=${prependLen} chars`,
`appendSystemContext=${appendLen} chars, prependContext=${prependLen} chars, ` +
`prependSystemAddition=${prependSysLen} chars`,
);
} else {
api.logger.info(`${TAG} [before_prompt_build] Recall complete (${elapsedMs}ms), no context to inject`);
Expand All @@ -612,24 +614,38 @@ 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>)`);
// Strip injected recall context (<relevant-memories> or <memory-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.
//
// When recall.showInjected=true, skip stripping so users can inspect what
// memory was used each turn (at the cost of context bloat).
api.logger.debug?.(
`${TAG} Registering before_message_write hook ` +
`(recall.showInjected=${cfg.recall.showInjected ? "true" : "false"}, ` +
`cacheOptimization=${cfg.recall.cacheOptimization})`,
);
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}`);

// When showInjected=true, preserve injected recall context for transparency
if (cfg.recall.showInjected) {
api.logger.debug?.(`${TAG} [before_message_write] recall.showInjected=true, preserving injected recall context`);
return;
}

if (msg.role !== "user") return;

// UserMessage.content: string | (TextContent | ImageContent)[]
const STRIP_RE = /<relevant-memories>[\s\S]*?<\/relevant-memories>\s*/g;
// Strip both legacy <relevant-memories> and new <memory-context> tags
const RECALL_STRIP_RE = /<(?:relevant-memories|memory-context\s+state="(?:active|empty)")>[\s\S]*?<\/(?:relevant-memories|memory-context)>\s*/g;

if (typeof msg.content === "string") {
if (!msg.content.includes("<relevant-memories>")) return;
const cleaned = msg.content.replace(STRIP_RE, "").trim();
if (!msg.content.includes("<relevant-memories>") && !msg.content.includes("<memory-context")) return;
const cleaned = msg.content.replace(RECALL_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 };
Expand All @@ -639,9 +655,10 @@ export default function register(api: OpenClawPluginApi) {
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;
const text = part.text as string;
if (!text.includes("<relevant-memories>") && !text.includes("<memory-context")) return part;
const cleaned = text.replace(RECALL_STRIP_RE, "").trim();
totalStripped += text.length - cleaned.length;
return { ...part, text: cleaned };
});
if (totalStripped === 0) return;
Expand Down
2 changes: 2 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
"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 注入的记忆上下文,便于用户查看召回内容。开启后会增加上下文膨胀,但用户可检查每轮使用了哪些记忆" },
"cacheOptimization": { "type": "string", "enum": ["none", "stable_wrapper", "split_system"], "default": "none", "description": "Prompt 缓存优化策略(针对 DeepSeek/MiMo 等前缀匹配缓存提供商):\"none\" 不优化;\"stable_wrapper\" 用稳定的 <memory-context> 外壳包裹动态记忆,保持前缀一致;\"split_system\" 进一步将 persona 分离到 CACHE_BOUNDARY 之前参与缓存" },
"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
147 changes: 147 additions & 0 deletions src/adapters/openclaw/cache-optimization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* Unit tests for the OpenClaw cache-optimization adapter.
*
* These tests pin the exact output of `buildCacheOptimizedContext` so the
* extraction from `auto-recall.ts` is provably faithful (identical strings),
* and they cover the new `dedupeRecallLines` helper.
*/

import { describe, expect, it } from "vitest";
import {
buildCacheOptimizedContext,
dedupeRecallLines,
MEMORY_TOOLS_GUIDE,
} from "./cache-optimization.js";

const PERSONA = "用户叫王小明,软件工程师";
const SCENE = "Scene1: 项目初始化 (2026-01-15)";
const MEM = ["- [episodic] Memory A"];

describe("buildCacheOptimizedContext — mode: none (legacy)", () => {
it("no persona/scene/memories → all undefined (caller returns undefined)", () => {
const r = buildCacheOptimizedContext({ cacheOptimization: "none", memoryLines: [], separator: "\n" });
expect(r.prependSystemAddition).toBeUndefined();
expect(r.appendSystemContext).toBeUndefined();
expect(r.prependContext).toBeUndefined();
});

it("persona+scene go to appendSystemContext (after boundary); prependSystemAddition undefined", () => {
const r = buildCacheOptimizedContext({
cacheOptimization: "none",
personaContent: PERSONA,
sceneNavigation: SCENE,
memoryLines: [],
separator: "\n",
});
expect(r.prependSystemAddition).toBeUndefined();
expect(r.appendSystemContext).toContain("<user-persona>");
expect(r.appendSystemContext).toContain(PERSONA);
expect(r.appendSystemContext).toContain("<scene-navigation>");
expect(r.appendSystemContext).toContain(SCENE);
expect(r.appendSystemContext).toContain("<memory-tools-guide>");
});

it("memories → prependContext is <relevant-memories> (no wrapper, no empty placeholder)", () => {
const r = buildCacheOptimizedContext({ cacheOptimization: "none", memoryLines: MEM, separator: "\n" });
const expected = `<relevant-memories>\n以下是当前对话召回的相关记忆,不代表当前任务进程,仅作为参考:\n\n${MEM.join("\n")}\n</relevant-memories>`;
expect(r.prependContext).toBe(expected);
});

it("empty memories → prependContext undefined (no placeholder in legacy)", () => {
const r = buildCacheOptimizedContext({ cacheOptimization: "none", memoryLines: [], separator: "\n" });
expect(r.prependContext).toBeUndefined();
});
});

describe("buildCacheOptimizedContext — mode: stable_wrapper", () => {
it("memories → prependContext wrapped in <memory-context state=\"active\">", () => {
const r = buildCacheOptimizedContext({ cacheOptimization: "stable_wrapper", memoryLines: MEM, separator: "\n" });
expect(r.prependContext).toContain('<memory-context state="active">');
expect(r.prependContext).toContain("</memory-context>");
expect(r.prependContext).toContain(MEM[0]);
});

it("empty memories → prependContext is empty placeholder (keeps prefix stable)", () => {
const r = buildCacheOptimizedContext({ cacheOptimization: "stable_wrapper", memoryLines: [], separator: "\n" });
expect(r.prependContext).toBe(`<memory-context state="empty"></memory-context>`);
});

it("persona still in appendSystemContext (not before boundary in stable_wrapper)", () => {
const r = buildCacheOptimizedContext({
cacheOptimization: "stable_wrapper",
personaContent: PERSONA,
sceneNavigation: SCENE,
memoryLines: [],
separator: "\n",
});
expect(r.prependSystemAddition).toBeUndefined();
expect(r.appendSystemContext).toContain("<user-persona>");
});
});

describe("buildCacheOptimizedContext — mode: split_system", () => {
it("persona moves to prependSystemAddition (before boundary); scene+tools stay after", () => {
const r = buildCacheOptimizedContext({
cacheOptimization: "split_system",
personaContent: PERSONA,
sceneNavigation: SCENE,
memoryLines: MEM,
separator: "\n",
});
expect(r.prependSystemAddition).toContain("<user-persona>");
expect(r.prependSystemAddition).toContain(PERSONA);
expect(r.appendSystemContext).toContain("<scene-navigation>");
expect(r.appendSystemContext).toContain("<memory-tools-guide>");
// persona must NOT also appear in appendSystemContext
expect(r.appendSystemContext).not.toContain("<user-persona>");
});

it("memories wrapped with stable_wrapper semantics in split_system too", () => {
const r = buildCacheOptimizedContext({ cacheOptimization: "split_system", memoryLines: MEM, separator: "\n" });
expect(r.prependContext).toContain('<memory-context state="active">');
});
});

describe("buildCacheOptimizedContext — dedup option (absorbs #402 dedup idea)", () => {
it("dedup=true removes exact-duplicate memory lines, preserving order", () => {
const lines = ["- [episodic] A", "- [episodic] A", "- [instruction] B", "- [episodic] A"];
const r = buildCacheOptimizedContext({
cacheOptimization: "stable_wrapper",
memoryLines: lines,
separator: "\n",
dedup: true,
});
expect(r.prependContext).toBe(
`<memory-context state="active">\n以下是当前对话召回的相关记忆,不代表当前任务进程,仅作为参考:\n\n- [episodic] A\n- [instruction] B\n</memory-context>`,
);
});

it("dedup=false (default) preserves duplicates", () => {
const lines = ["- [episodic] A", "- [episodic] A"];
const r = buildCacheOptimizedContext({ cacheOptimization: "stable_wrapper", memoryLines: lines, separator: "\n" });
expect(r.prependContext).toContain("- [episodic] A\n- [episodic] A");
});
});

describe("dedupeRecallLines — pure helper", () => {
it("removes exact duplicates, keeps first-seen order", () => {
expect(dedupeRecallLines(["a", "b", "a", "c", "b"])).toEqual(["a", "b", "c"]);
});
it("returns a fresh copy (no mutation of input) when no duplicates", () => {
const input = ["a", "b", "c"];
const out = dedupeRecallLines(input);
expect(out).toEqual(["a", "b", "c"]);
expect(out).not.toBe(input);
});
it("handles empty input", () => {
expect(dedupeRecallLines([])).toEqual([]);
});
});

describe("MEMORY_TOOLS_GUIDE constant", () => {
it("is a non-empty guide string", () => {
expect(typeof MEMORY_TOOLS_GUIDE).toBe("string");
expect(MEMORY_TOOLS_GUIDE.length).toBeGreaterThan(50);
expect(MEMORY_TOOLS_GUIDE).toContain("记忆工具调用指南");
});
});
150 changes: 150 additions & 0 deletions src/adapters/openclaw/cache-optimization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* OpenClaw cache-optimization adapter.
*
* Pure, host-neutral helpers that shape recalled memory content for
* prompt-cache friendliness with prefix-matching providers
* (OpenAI-compatible: DeepSeek, MiMo, etc.).
*
* This module follows the adapter-layer convention established for recall
* injection: host-specific *shaping* lives under `src/adapters/openclaw/`,
* keeping TDAI Core's recall path free of presentation concerns while the
* logic itself remains a pure, independently-testable function.
*
* The functions here have no OpenClaw runtime dependencies, so they can be
* reused by the core recall path and unit-tested in isolation.
*/

/**
* Memory tools usage guide — injected at the end of stable context so the
* main agent knows how to actively retrieve deeper information.
*/
export const MEMORY_TOOLS_GUIDE = `<memory-tools-guide>
## 记忆工具调用指南

当上方注入的记忆片段不足以回答用户问题时,可主动调用以下工具获取更多信息:

- **tdai_memory_search**:搜索结构化记忆(L1),适用于回忆用户偏好、历史事件节点、规则等关键信息。
- **tdai_conversation_search**:搜索原始对话(L0),适用于查找具体消息原文、时间线、上下文细节;也可用于补充或校验 memory_search 的结果。
- **read_file**(Scene Navigation 中的路径):当已定位到相关情境,且需要该场景的完整画像、事件经过或阶段结论时使用。

### ⚠️ 调用次数限制
每轮对话中,tdai_memory_search 和 tdai_conversation_search **合计最多调用 3 次**。
- 首次搜索无结果时,可换关键词或换工具重试,但总调用次数不要超过 3 次。
- 若 3 次搜索后仍无结果,说明该信息不在记忆中,请直接根据已有信息回复用户,不要继续搜索。
</memory-tools-guide>`;

export type CacheOptimizationMode = "none" | "stable_wrapper" | "split_system";

export interface CacheOptimizationInput {
/** Cache optimization strategy (from recall.cacheOptimization). */
cacheOptimization: CacheOptimizationMode;
/** Stable persona content (L3). Placed before CACHE_BOUNDARY in split_system mode. */
personaContent?: string;
/** Stable scene navigation (L2). */
sceneNavigation?: string;
/** Dynamic L1 memory lines (changes every turn). */
memoryLines: string[];
/** Separator used to join memory lines. */
separator: string;
/** When true, deduplicate identical memory lines before shaping. */
dedup?: boolean;
}

export interface CacheOptimizationResult {
/** Persona placed BEFORE CACHE_BOUNDARY (split_system only). */
prependSystemAddition?: string;
/** Stable content after CACHE_BOUNDARY (persona/scene/tools). */
appendSystemContext?: string;
/** Dynamic L1 memories for the user-prompt prefix (wrapped for stability). */
prependContext?: string;
}

/**
* Remove exact-duplicate memory lines while preserving first-seen order.
*
* Defensive against double-injection (e.g. when a record is returned by both
* the keyword and embedding paths and survives RRF merge with differing
* formatting). Deterministic and side-effect free.
*/
export function dedupeRecallLines(lines: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const line of lines) {
if (seen.has(line)) continue;
seen.add(line);
out.push(line);
}
return out;
}

/**
* Build cache-optimized prompt context from recalled pieces.
*
* Pure function — identical output for identical input. This is the single
* source of truth for how `none` / `stable_wrapper` / `split_system` shape the
* prompt; the core recall path and any adapter both delegate here.
*
* Strategy matrix:
* - "none" (legacy): prependContext = <relevant-memories> (no stable wrapper,
* no empty placeholder). Persona + scene + tools live in appendSystemContext.
* - "stable_wrapper": same stable parts, but prependContext is wrapped in
* <memory-context state="active|empty"> so the outer prefix is consistent
* across turns (empty placeholder keeps structure when no memory recalled).
* - "split_system": additionally moves persona into prependSystemAddition
* (placed BEFORE CACHE_BOUNDARY for caching); scene + tools stay after.
*/
export function buildCacheOptimizedContext(input: CacheOptimizationInput): CacheOptimizationResult {
const { cacheOptimization, personaContent, sceneNavigation, separator } = input;
const memoryLines = input.dedup ? dedupeRecallLines(input.memoryLines) : input.memoryLines;

const useStableWrapper = cacheOptimization === "stable_wrapper" || cacheOptimization === "split_system";
const useSplitSystem = cacheOptimization === "split_system";

const stableParts: string[] = [];
let prependSystemAddition: string | undefined;

if (useSplitSystem) {
// Split mode: persona goes BEFORE CACHE_BOUNDARY (prependSystemAddition).
// Scene nav + tools guide stay in appendSystemContext (after boundary).
if (personaContent) {
prependSystemAddition = `<user-persona>\n${personaContent}\n</user-persona>`;
}
if (sceneNavigation) {
stableParts.push(`<scene-navigation>\n${sceneNavigation}\n</scene-navigation>`);
}
} else {
// Legacy / stable_wrapper: all stable content in appendSystemContext.
if (personaContent) {
stableParts.push(`<user-persona>\n${personaContent}\n</user-persona>`);
}
if (sceneNavigation) {
stableParts.push(`<scene-navigation>\n${sceneNavigation}\n</scene-navigation>`);
}
}

// Dynamic part: L1 relevant memories (changes every turn) → prependContext.
let prependContext: string | undefined;
if (useStableWrapper) {
if (memoryLines.length > 0) {
prependContext =
`<memory-context state="active">\n以下是当前对话召回的相关记忆,不代表当前任务进程,仅作为参考:\n\n${memoryLines.join(separator)}\n</memory-context>`;
} else {
// Empty placeholder keeps the prefix stable even when no memories recalled.
prependContext = `<memory-context state="empty"></memory-context>`;
}
} else {
// Legacy mode: <relevant-memories> (no stable wrapper, no empty placeholder).
if (memoryLines.length > 0) {
prependContext =
`<relevant-memories>\n以下是当前对话召回的相关记忆,不代表当前任务进程,仅作为参考:\n\n${memoryLines.join(separator)}\n</relevant-memories>`;
}
}

if (stableParts.length > 0 || prependContext || prependSystemAddition) {
stableParts.push(MEMORY_TOOLS_GUIDE);
}

const appendSystemContext = stableParts.length > 0 ? stableParts.join("\n\n") : undefined;

return { prependSystemAddition, appendSystemContext, prependContext };
}
Loading