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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@

## [Unreleased]

### 🐛 修复

- **Prompt cache 命中率回归防护** ([#120](https://github.com/TencentCloud/TencentDB-Agent-Memory/issues/120)):将 `<relevant-memories>` 注入块清理逻辑抽取为 `src/utils/injected-memory.ts`,并在 `before_message_write` 中复用,避免 `showInjected=true` 时动态 L1 召回内容被持久化进历史消息。新增单测覆盖字符串消息、多 part 消息和历史膨胀估算。

### ✨ 新功能

- **Cache-Aware Context Engine D 方案核心能力** ([#120](https://github.com/TencentCloud/TencentDB-Agent-Memory/issues/120)):新增 `recall.mode="tool-only"` 默认路径、确定性 Session Snapshot、前置工具结果卸载、`tdai_offload_read`、Cache Epoch 纯合约与 Task Snapshot/Delta 纯合约。默认不再每轮自动 prepend 动态 L1 记忆;大工具结果会先写入 `refs/*.md`,prompt 只保留摘要、`result_ref` 和恢复说明。
- **时区可配置** ([#75](https://github.com/Tencent/TencentDB-Agent-Memory/issues/75) / [#87](https://github.com/Tencent/TencentDB-Agent-Memory/issues/87)):新增顶层 `timezone` 配置项,支持 IANA 时区名(`Asia/Shanghai`、`Europe/Berlin`)和 UTC 偏移串(`+08:00`、`-05:30`)。默认 `"system"`(跟随进程系统时区),升级零感。
- **暴露给 LLM 的时间戳**统一为带显式 offset 的 ISO 8601(如 `2026-04-07T11:04:45+08:00`),修复 #87 报告的 UTC/本地时区混用导致 LLM 误算时间差的问题。
- **L1 / L2 prompt 顶部**自动插入时区声明,指引 LLM 按正确时区推算"昨天"、"上周"等相对时间。
Expand Down
42 changes: 15 additions & 27 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ import {
decideHookPolicy,
} from "./src/utils/ensure-hook-policy.js";
import { resolveOpenClawStateDir } from "./src/utils/openclaw-state-dir.js";
import {
measureMessageContentChars,
stripInjectedRelevantMemoriesFromContent,
} from "./src/utils/injected-memory.js";

const TAG = "[memory-tdai]";

Expand Down Expand Up @@ -613,41 +617,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>)`);
// the session JSONL unless the operator explicitly opts into persistence.
// The current-turn LLM already saw the full prompt (effectivePrompt lives in
// memory), but D-plan defaults keep dynamic recall out of replay history.
api.logger.debug?.(`${TAG} Registering before_message_write hook (persistInjected=${cfg.recall.persistInjected})`);
api.on("before_message_write", (event) => {
if (cfg.recall.persistInjected) return;
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 };
}
const cleanedContent = stripInjectedRelevantMemoriesFromContent(msg.content);
if (cleanedContent === msg.content) 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 beforeLen = measureMessageContentChars(msg.content);
const afterLen = measureMessageContentChars(cleanedContent);
api.logger.debug?.(`${TAG} [before_message_write] Stripped injected memories: ${beforeLen} → ${afterLen} chars`);
return { message: { ...event.message, content: cleanedContent } as typeof event.message };
});

// After agent end: auto-capture + L0 record + L1/L2/L3 schedule
Expand Down
20 changes: 18 additions & 2 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"onStartup": true
},
"contracts": {
"tools": ["tdai_memory_search", "tdai_conversation_search"]
"tools": ["tdai_memory_search", "tdai_conversation_search", "tdai_offload_read"]
},
"configSchema": {
"type": "object",
Expand Down Expand Up @@ -74,12 +74,20 @@
"description": "记忆召回设置",
"properties": {
"enabled": { "type": "boolean", "default": true, "description": "是否启用自动召回" },
"mode": { "type": "string", "enum": ["tool-only", "auto"], "default": "tool-only", "description": "召回模式:tool-only 默认不向每轮 prompt 注入动态 L1 记忆,由 Agent 按需调用工具;auto 可启用旧式自动召回" },
"showInjected": { "type": "boolean", "default": false, "description": "mode=auto 时是否把动态召回的 <relevant-memories> 注入当前 prompt" },
"persistInjected": { "type": "boolean", "default": false, "description": "是否允许动态召回内容写入历史消息。默认 false,避免污染后续重放和 prompt cache 前缀" },
"maxResults": { "type": "number", "default": 5, "description": "召回最大结果数" },
"maxCharsPerMemory": { "type": "number", "default": 0, "description": "单条 L1 记忆注入的最大字符数;填 0 表示不限制" },
"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": "召回整体超时(毫秒),超时后跳过记忆注入并打印警告日志" },
"sessionSnapshotMaxTokens": { "type": "number", "default": 1600, "description": "稳定 session snapshot 的 token 预算" },
"stableMemoryMaxItems": { "type": "number", "default": 8, "description": "稳定快照中最多纳入的稳定记忆条数" },
"sceneSummaryMaxItems": { "type": "number", "default": 6, "description": "稳定快照中最多纳入的场景摘要条数" },
"dynamicRecallMaxTokens": { "type": "number", "default": 900, "description": "mode=auto 时动态召回片段预算" },
"maxSearchCallsPerTurn": { "type": "number", "default": 3, "description": "tdai_memory_search 与 tdai_conversation_search 每轮合计调用预算" }
}
},
"embedding": {
Expand Down Expand Up @@ -161,6 +169,14 @@
"mildOffloadRatio": { "type": "number", "default": 0.5, "description": "温和压缩触发比例(占 context window)" },
"aggressiveCompressRatio": { "type": "number", "default": 0.85, "description": "激进压缩触发比例" },
"mmdMaxTokenRatio": { "type": "number", "default": 0.2, "description": "MMD 注入 token 预算比例" },
"inlineToolResultMaxTokens": { "type": "number", "default": 1200, "description": "单个工具结果超过该 token 估算值时,前置写入 refs 并在 prompt 中替换为 result_ref 摘要" },
"summaryMaxTokens": { "type": "number", "default": 350, "description": "工具结果前置卸载摘要 token 预算" },
"previewMaxChars": { "type": "number", "default": 1200, "description": "用于生成确定性摘要的预览字符数" },
"readChunkMaxTokens": { "type": "number", "default": 1600, "description": "tdai_offload_read 单次返回 token 预算" },
"epochTriggerRatio": { "type": "number", "default": 0.8, "description": "Cache Epoch 触发比例(占 context window)" },
"epochTargetRatio": { "type": "number", "default": 0.55, "description": "Cache Epoch 批量压缩后的目标比例" },
"epochMinimumTurns": { "type": "number", "default": 4, "description": "允许切换 Cache Epoch 前的最小轮数" },
"epochMinimumIntervalTurns": { "type": "number", "default": 2, "description": "可批量压缩的最小连续旧轮数" },
"backendUrl": { "type": "string", "description": "后端服务 URL(如 https://offload-api.example.com),配置后 L1/L1.5/L2/L4 走后端" },
"backendApiKey": { "type": "string", "description": "后端 API 认证 token" },
"backendTimeoutMs": { "type": "number", "default": 10000, "description": "后端调用超时(毫秒)" }
Expand Down
33 changes: 33 additions & 0 deletions src/config.cache-aware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { parseConfig } from "./config.js";

describe("cache-aware context defaults", () => {
it("defaults recall to tool-only with injected dynamic memories hidden and unpersisted", () => {
const cfg = parseConfig({});

expect(cfg.recall.mode).toBe("tool-only");
expect(cfg.recall.showInjected).toBe(false);
expect(cfg.recall.persistInjected).toBe(false);
expect(cfg.recall.maxSearchCallsPerTurn).toBe(3);
});

it("parses front-offload and cache-epoch controls", () => {
const cfg = parseConfig({
recall: { mode: "auto", showInjected: true, persistInjected: false },
offload: {
inlineToolResultMaxTokens: 32,
summaryMaxTokens: 12,
previewMaxChars: 80,
epochTriggerRatio: 0.7,
},
});

expect(cfg.recall.mode).toBe("auto");
expect(cfg.recall.showInjected).toBe(true);
expect(cfg.recall.persistInjected).toBe(false);
expect(cfg.offload.inlineToolResultMaxTokens).toBe(32);
expect(cfg.offload.summaryMaxTokens).toBe(12);
expect(cfg.offload.previewMaxChars).toBe(80);
expect(cfg.offload.epochTriggerRatio).toBe(0.7);
});
});
56 changes: 56 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ export interface PipelineTriggerConfig {
export interface RecallConfig {
/** Enable auto-recall (default: true) */
enabled: boolean;
/** Recall mode. "tool-only" keeps dynamic L1 recall out of the prompt by default. */
mode: "auto" | "tool-only";
/** Show dynamic injected memories in the prompt when mode="auto" (default: false) */
showInjected: boolean;
/** Persist dynamic injected memories into conversation history (default: false) */
persistInjected: boolean;
/** Max results to return (default: 5) */
maxResults: number;
/** Max characters injected for a single recalled L1 memory. 0 disables the per-memory limit. */
Expand All @@ -93,6 +99,16 @@ export interface RecallConfig {
strategy: "embedding" | "keyword" | "hybrid";
/** Overall recall timeout in milliseconds (default: 5000). When exceeded, recall is skipped with a warning. */
timeoutMs: number;
/** Stable session snapshot token budget (default: 1600) */
sessionSnapshotMaxTokens: number;
/** Max stable memories to include in the frozen snapshot (default: 8) */
stableMemoryMaxItems: number;
/** Max scene summary items to include in the frozen snapshot (default: 6) */
sceneSummaryMaxItems: number;
/** Dynamic recall token budget for mode="auto" (default: 900) */
dynamicRecallMaxTokens: number;
/** Combined active-search call budget per user turn (default: 3) */
maxSearchCallsPerTurn: number;
}

/** Embedding service configuration for vector search. */
Expand Down Expand Up @@ -260,6 +276,22 @@ export interface OffloadConfig {
aggressiveCompressRatio: number;
/** MMD injection token budget ratio (default: 0.2) */
mmdMaxTokenRatio: number;
/** Inline tool-result budget before front offload replaces the prompt payload (default: 1200) */
inlineToolResultMaxTokens: number;
/** Summary token budget for offloaded tool result stubs (default: 350) */
summaryMaxTokens: number;
/** Preview character budget used to build deterministic tool result summaries (default: 1200) */
previewMaxChars: number;
/** Max tokens returned by tdai_offload_read in one call (default: 1600) */
readChunkMaxTokens: number;
/** Cache epoch transition trigger ratio of context window (default: 0.8) */
epochTriggerRatio: number;
/** Cache epoch target ratio after batch compaction (default: 0.55) */
epochTargetRatio: number;
/** Minimum turns before an epoch can transition (default: 4) */
epochMinimumTurns: number;
/** Minimum continuous old interval turns to compact (default: 2) */
epochMinimumIntervalTurns: number;
/** Backend service URL. When set, L1/L1.5/L2/L4 LLM calls go through the backend. */
backendUrl?: string;
/** Backend API authentication token */
Expand Down Expand Up @@ -489,6 +521,14 @@ export function parseConfig(raw: Record<string, unknown> | undefined): MemoryTda
mildOffloadRatio: num(offloadGroup, "mildOffloadRatio") ?? 0.5,
aggressiveCompressRatio: num(offloadGroup, "aggressiveCompressRatio") ?? 0.85,
mmdMaxTokenRatio: num(offloadGroup, "mmdMaxTokenRatio") ?? 0.2,
inlineToolResultMaxTokens: num(offloadGroup, "inlineToolResultMaxTokens") ?? 1200,
summaryMaxTokens: num(offloadGroup, "summaryMaxTokens") ?? 350,
previewMaxChars: num(offloadGroup, "previewMaxChars") ?? 1200,
readChunkMaxTokens: num(offloadGroup, "readChunkMaxTokens") ?? 1600,
epochTriggerRatio: num(offloadGroup, "epochTriggerRatio") ?? 0.8,
epochTargetRatio: num(offloadGroup, "epochTargetRatio") ?? 0.55,
epochMinimumTurns: num(offloadGroup, "epochMinimumTurns") ?? 4,
epochMinimumIntervalTurns: num(offloadGroup, "epochMinimumIntervalTurns") ?? 2,
backendUrl: optStr(offloadGroup, "backendUrl"),
backendApiKey: optStr(offloadGroup, "backendApiKey"),
backendTimeoutMs: num(offloadGroup, "backendTimeoutMs") ?? 120000,
Expand Down Expand Up @@ -529,12 +569,20 @@ export function parseConfig(raw: Record<string, unknown> | undefined): MemoryTda
},
recall: {
enabled: bool(recallGroup, "enabled") ?? true,
mode: validateRecallMode(str(recallGroup, "mode")) ?? "tool-only",
showInjected: bool(recallGroup, "showInjected") ?? false,
persistInjected: bool(recallGroup, "persistInjected") ?? false,
maxResults: num(recallGroup, "maxResults") ?? 5,
maxCharsPerMemory: num(recallGroup, "maxCharsPerMemory") ?? 0,
maxTotalRecallChars: num(recallGroup, "maxTotalRecallChars") ?? 0,
scoreThreshold: num(recallGroup, "scoreThreshold") ?? 0.3,
strategy: validateStrategy(str(recallGroup, "strategy")) ?? "hybrid",
timeoutMs: num(recallGroup, "timeoutMs") ?? 5000,
sessionSnapshotMaxTokens: num(recallGroup, "sessionSnapshotMaxTokens") ?? 1600,
stableMemoryMaxItems: num(recallGroup, "stableMemoryMaxItems") ?? 8,
sceneSummaryMaxItems: num(recallGroup, "sceneSummaryMaxItems") ?? 6,
dynamicRecallMaxTokens: num(recallGroup, "dynamicRecallMaxTokens") ?? 900,
maxSearchCallsPerTurn: num(recallGroup, "maxSearchCallsPerTurn") ?? 3,
},
embedding: {
enabled: embeddingEnabled,
Expand Down Expand Up @@ -634,6 +682,7 @@ function strArray(src: Record<string, unknown>, key: string): string[] | undefin
}

const VALID_STRATEGIES: RecallConfig["strategy"][] = ["embedding", "keyword", "hybrid"];
const VALID_RECALL_MODES: RecallConfig["mode"][] = ["auto", "tool-only"];

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

function validateRecallMode(value: string | undefined): RecallConfig["mode"] | undefined {
if (!value) return undefined;
return VALID_RECALL_MODES.includes(value as RecallConfig["mode"])
? (value as RecallConfig["mode"])
: undefined;
}

/**
* Normalize a cleanup time string.
*
Expand Down
42 changes: 42 additions & 0 deletions src/core/hooks/auto-recall.tool-only.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseConfig } from "../../config.js";
import { performAutoRecall } from "./auto-recall.js";

const tempDirs: string[] = [];

afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true });
}
});

describe("performAutoRecall tool-only mode", () => {
it("does not search or prepend dynamic memories by default", async () => {
const pluginDataDir = await mkdtemp(join(tmpdir(), "tdai-recall-"));
tempDirs.push(pluginDataDir);
const cfg = parseConfig({});
const searchL1Fts = vi.fn(async () => []);
const vectorStore = {
isFtsAvailable: () => true,
searchL1Fts,
getCapabilities: () => ({ nativeHybridSearch: false }),
} as any;

const result = await performAutoRecall({
userText: "帮我回忆项目规划",
actorId: "user",
sessionKey: "agent:test:session",
cfg,
pluginDataDir,
vectorStore,
});

expect(searchL1Fts).not.toHaveBeenCalled();
expect(result?.prependContext).toBeUndefined();
expect(result?.appendSystemContext).toContain("<session-context");
expect(result?.appendSystemContext).toContain("tdai_memory_search");
});
});
Loading