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
19 changes: 16 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,12 +584,13 @@ 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`,
`prependSystemAddition=${prependSysLen} chars, appendSystemContext=${appendLen} chars, prependContext=${prependLen} chars`,
);
} else {
api.logger.info(`${TAG} [before_prompt_build] Recall complete (${elapsedMs}ms), no context to inject`);
Expand All @@ -616,12 +617,24 @@ export default function register(api: OpenClawPluginApi) {
// 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>)`);
//
// 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 ` +
`(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}`);

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

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

// UserMessage.content: string | (TextContent | ImageContent)[]
Expand Down
4 changes: 3 additions & 1 deletion openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@
"maxTotalRecallChars": { "type": "number", "default": 0, "description": "本轮 auto-recall 注入的 L1 记忆总字符预算;填 0 表示不限制" },
"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": "召回整体超时(毫秒),超时后跳过记忆注入并打印警告日志" }
"timeoutMs": { "type": "number", "default": 5000, "description": "召回整体超时(毫秒),超时后跳过记忆注入并打印警告日志" },
"cacheOptimization": { "type": "string", "enum": ["none", "split_system"], "default": "none", "description": "缓存优化策略(针对 DeepSeek/MiMo 等前缀匹配 provider):none=默认行为不变;split_system=将稳定 persona 移至 CACHE_BOUNDARY 前参与缓存,与 #375 的 injectionMode 正交可叠加" },
"showInjected": { "type": "boolean", "default": false, "description": "为 true 时保留注入的召回上下文到持久化消息中(调试用,默认 false 自动剥离)" }
}
},
"embedding": {
Expand Down
95 changes: 95 additions & 0 deletions src/adapters/openclaw/cache-optimization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* 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.).
*
* Host-specific *shaping* logic lives here following the adapter-layer
* convention, keeping TDAI Core's recall path free of presentation concerns.
*/

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

export interface CacheOptimizationInput {
cacheOptimization: CacheOptimizationMode;
personaContent?: string;
sceneNavigation?: string;
memoryLines: string[];
separator: string;
dedup?: boolean;
}

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

/**
* Remove exact-duplicate memory lines while preserving first-seen order.
*/
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.
*
* Strategy:
* - "none" (default): persona + scene nav + tools → appendSystemContext.
* L1 memories → prependContext (legacy <relevant-memories> wrapper).
* Zero behavior change from baseline.
* - "split_system": persona → prependSystemAddition (BEFORE CACHE_BOUNDARY,
* making it part of the cached prefix). Scene nav + tools stay in
* appendSystemContext. L1 memories unchanged in prependContext.
*
* Key insight: prefix-matching providers hash the full token sequence before
* CACHE_BOUNDARY. Only truly stable content (persona) should be moved there.
* Dynamic L1 memories change every turn and must NOT be placed before the
* boundary — doing so would bust the cache for everything that follows.
*/
export function buildCacheOptimizedContext(input: CacheOptimizationInput): CacheOptimizationResult {
const { cacheOptimization, personaContent, sceneNavigation, separator } = input;
const memoryLines = input.dedup ? dedupeRecallLines(input.memoryLines) : input.memoryLines;

const useSplitSystem = cacheOptimization === "split_system";

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

if (useSplitSystem && personaContent) {
// Persona goes BEFORE CACHE_BOUNDARY — the unique value of split_system.
prependSystemAddition = `<user-persona>\n${personaContent}\n</user-persona>`;
} else if (personaContent) {
// Legacy: persona stays in appendSystemContext.
stableParts.push(`<user-persona>\n${personaContent}\n</user-persona>`);
}

if (sceneNavigation) {
stableParts.push(`<scene-navigation>\n${sceneNavigation}\n</scene-navigation>`);
}

// Dynamic L1 memories — always in prependContext, always after CACHE_BOUNDARY.
let prependContext: string | undefined;
if (memoryLines.length > 0) {
prependContext =
`<relevant-memories>\n以下是当前对话召回的相关记忆,不代表当前任务进程,仅作为参考:\n\n${memoryLines.join(separator)}\n</relevant-memories>`;
}

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

return { prependSystemAddition, appendSystemContext, prependContext };
}
25 changes: 25 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ export interface RecallConfig {
strategy: "embedding" | "keyword" | "hybrid";
/** Overall recall timeout in milliseconds (default: 5000). When exceeded, recall is skipped with a warning. */
timeoutMs: number;
/**
* Cache optimization strategy for prefix-matching providers (DeepSeek, MiMo, etc.).
* - `"none"` (default): no change — persona stays in appendSystemContext (after CACHE_BOUNDARY).
* - `"split_system"`: move stable persona BEFORE CACHE_BOUNDARY so the provider can
* cache it across turns. L1 dynamic memories are NOT affected (they stay in
* prependContext, after the boundary). This is orthogonal to and composable
* with PR #375's `injectionMode` (which controls L1 placement).
*/
cacheOptimization: "none" | "split_system";
/** When true, preserve injected recall context in persisted messages (default: false). */
showInjected: boolean;
}

/** Embedding service configuration for vector search. */
Expand Down Expand Up @@ -535,6 +546,8 @@ export function parseConfig(raw: Record<string, unknown> | undefined): MemoryTda
scoreThreshold: num(recallGroup, "scoreThreshold") ?? 0.3,
strategy: validateStrategy(str(recallGroup, "strategy")) ?? "hybrid",
timeoutMs: num(recallGroup, "timeoutMs") ?? 5000,
cacheOptimization: validateCacheOptimization(str(recallGroup, "cacheOptimization")) ?? "none",
showInjected: bool(recallGroup, "showInjected") ?? false,
},
embedding: {
enabled: embeddingEnabled,
Expand Down Expand Up @@ -634,6 +647,7 @@ function strArray(src: Record<string, unknown>, key: string): string[] | undefin
}

const VALID_STRATEGIES: RecallConfig["strategy"][] = ["embedding", "keyword", "hybrid"];
const VALID_CACHE_OPTIMIZATIONS: RecallConfig["cacheOptimization"][] = ["none", "split_system"];

/**
* Validate recall strategy against whitelist.
Expand All @@ -646,6 +660,17 @@ function validateStrategy(value: string | undefined): RecallConfig["strategy"] |
: undefined;
}

/**
* Validate cache optimization mode against whitelist.
* Returns the mode if valid, undefined otherwise (caller falls back to default).
*/
function validateCacheOptimization(value: string | undefined): RecallConfig["cacheOptimization"] | undefined {
if (!value) return undefined;
return VALID_CACHE_OPTIMIZATIONS.includes(value as RecallConfig["cacheOptimization"])
? (value as RecallConfig["cacheOptimization"])
: undefined;
}

/**
* Normalize a cleanup time string.
*
Expand Down
145 changes: 145 additions & 0 deletions src/core/hooks/auto-recall-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Tests for split_system cache optimization.
*
* Verifies that when cacheOptimization === "split_system":
* - persona is moved to prependSystemAddition (BEFORE CACHE_BOUNDARY)
* - scene nav + tools guide stay in appendSystemContext (after boundary)
* - L1 memories stay in prependContext (dynamic, per-turn)
*
* And when cacheOptimization === "none" (default):
* - zero behavior change — persona stays in appendSystemContext
*/

import { describe, expect, it } from "vitest";
import { buildCacheOptimizedContext, dedupeRecallLines, type CacheOptimizationMode } from "../../adapters/openclaw/cache-optimization.js";

const PERSONA = "用户叫小明,擅长 TypeScript";
const SCENE_NAV = "Scene1: 项目初始化\nScene2: API 开发";
const MEMORY_A = "- [episodic] User worked on API caching";
const MEMORY_B = "- [instruction] User prefers TypeScript";

describe("buildCacheOptimizedContext — split_system mode", () => {
it("persona goes to prependSystemAddition (before CACHE_BOUNDARY)", () => {
const result = buildCacheOptimizedContext({
cacheOptimization: "split_system",
personaContent: PERSONA,
memoryLines: [MEMORY_A],
separator: "\n",
});
expect(result.prependSystemAddition).toContain("<user-persona>");
expect(result.prependSystemAddition).toContain(PERSONA);
expect(result.prependSystemAddition).toContain("</user-persona>");
});

it("scene nav stays in appendSystemContext (after boundary)", () => {
const result = buildCacheOptimizedContext({
cacheOptimization: "split_system",
personaContent: PERSONA,
sceneNavigation: SCENE_NAV,
memoryLines: [MEMORY_A],
separator: "\n",
});
expect(result.appendSystemContext).toContain("<scene-navigation>");
expect(result.appendSystemContext).toContain(SCENE_NAV);
// persona should NOT be in appendSystemContext in split_system mode
expect(result.appendSystemContext).not.toContain("<user-persona>");
});

it("L1 memories stay in prependContext (dynamic, per-turn)", () => {
const result = buildCacheOptimizedContext({
cacheOptimization: "split_system",
personaContent: PERSONA,
memoryLines: [MEMORY_A, MEMORY_B],
separator: "\n",
});
expect(result.prependContext).toContain(MEMORY_A);
expect(result.prependContext).toContain(MEMORY_B);
});

it("no memory lines → prependContext is undefined (no wrapper bloat)", () => {
const result = buildCacheOptimizedContext({
cacheOptimization: "split_system",
personaContent: PERSONA,
memoryLines: [],
separator: "\n",
});
expect(result.prependContext).toBeUndefined();
});

it("persona still cached even when no memories recalled", () => {
const result = buildCacheOptimizedContext({
cacheOptimization: "split_system",
personaContent: PERSONA,
memoryLines: [],
separator: "\n",
});
// The whole point: persona is in prependSystemAddition regardless of recall
expect(result.prependSystemAddition).toBeDefined();
expect(result.prependSystemAddition).toContain(PERSONA);
});
});

describe("buildCacheOptimizedContext — none mode (default, zero behavior change)", () => {
it("persona stays in appendSystemContext", () => {
const result = buildCacheOptimizedContext({
cacheOptimization: "none",
personaContent: PERSONA,
sceneNavigation: SCENE_NAV,
memoryLines: [MEMORY_A],
separator: "\n",
});
expect(result.appendSystemContext).toContain("<user-persona>");
expect(result.appendSystemContext).toContain(PERSONA);
expect(result.prependSystemAddition).toBeUndefined();
});

it("L1 memories use legacy <relevant-memories> wrapper", () => {
const result = buildCacheOptimizedContext({
cacheOptimization: "none",
memoryLines: [MEMORY_A],
separator: "\n",
});
expect(result.prependContext).toContain("<relevant-memories>");
expect(result.prependContext).toContain(MEMORY_A);
});
});

describe("dedupeRecallLines", () => {
it("removes exact duplicates preserving order", () => {
const lines = [MEMORY_A, MEMORY_B, MEMORY_A, MEMORY_B, MEMORY_A];
const result = dedupeRecallLines(lines);
expect(result).toEqual([MEMORY_A, MEMORY_B]);
});

it("preserves unique lines", () => {
const lines = [MEMORY_A, MEMORY_B];
expect(dedupeRecallLines(lines)).toEqual(lines);
});

it("empty input → empty output", () => {
expect(dedupeRecallLines([])).toEqual([]);
});
});

describe("orthogonality with #375 injectionMode", () => {
it("split_system persona placement is independent of L1 position", () => {
// Whether L1 goes to prependContext (current) or appendContext (#375 style),
// the persona placement in prependSystemAddition is identical.
const withPrepend = buildCacheOptimizedContext({
cacheOptimization: "split_system",
personaContent: PERSONA,
memoryLines: [MEMORY_A],
separator: "\n",
});

// In a hypothetical append mode, prependSystemAddition would be the same.
// This test documents that split_system's system-level benefit (persona caching)
// is orthogonal to where L1 content is placed.
expect(withPrepend.prependSystemAddition).toBeDefined();

// The cached prefix (prependSystemAddition) does not contain any dynamic content.
// It ONLY contains the stable persona — this is what makes it safe to cache.
expect(withPrepend.prependSystemAddition).not.toContain(MEMORY_A);
expect(withPrepend.prependSystemAddition).not.toContain("relevant-memories");
});
});
Loading