From 40eafcdb27db119726b4cf624df5a20423b2f5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Thu, 21 May 2026 22:35:58 +0800 Subject: [PATCH 1/2] feat(recall): cap injected memory context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 李冠辰 --- README.md | 2 + README_CN.md | 2 + openclaw.plugin.json | 2 + src/config.test.ts | 23 +++++++++ src/config.ts | 12 +++++ src/core/hooks/auto-recall.test.ts | 81 ++++++++++++++++++++++++++++++ src/core/hooks/auto-recall.ts | 80 +++++++++++++++++++++++++++++ 7 files changed, 202 insertions(+) create mode 100644 src/config.test.ts create mode 100644 src/core/hooks/auto-recall.test.ts diff --git a/README.md b/README.md index 58aa74f..1ee7956 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,8 @@ docker exec -it hermes-memory hermes | `storeBackend` | `"sqlite"` | Storage backend: `sqlite` | | `recall.strategy` | `"hybrid"` | Recall strategy: `keyword` / `embedding` / `hybrid` (RRF fusion, recommended) | | `recall.maxResults` | `5` | Number of items returned per recall | +| `recall.maxCharsPerMemory` | `800` | Max characters injected for one recalled L1 memory; `0` disables this guard | +| `recall.maxTotalRecallChars` | `3000` | Total character budget for auto-recalled L1 memories; `0` disables this guard | | `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 | diff --git a/README_CN.md b/README_CN.md index c1180c5..7e4b391 100644 --- a/README_CN.md +++ b/README_CN.md @@ -262,6 +262,8 @@ docker exec -it hermes-memory hermes | `storeBackend` | `"sqlite"` | 存储后端:`sqlite` | | `recall.strategy` | `"hybrid"` | 召回策略:`keyword` / `embedding` / `hybrid`(RRF 融合,推荐) | | `recall.maxResults` | `5` | 每次召回条数 | +| `recall.maxCharsPerMemory` | `800` | 单条 L1 记忆注入的最大字符数;`0` 表示不限制 | +| `recall.maxTotalRecallChars` | `3000` | 每轮 auto-recall 注入的 L1 记忆总字符预算;`0` 表示不限制 | | `pipeline.everyNConversations` | `5` | 每 N 轮对话触发一次 L1 记忆提取 | | `extraction.maxMemoriesPerSession` | `20` | 单次 L1 最多提取多少条 | | `persona.triggerEveryN` | `50` | 每 N 条新记忆触发用户画像生成 | diff --git a/openclaw.plugin.json b/openclaw.plugin.json index f6ea5fd..9d6d6e3 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -69,6 +69,8 @@ "properties": { "enabled": { "type": "boolean", "default": true, "description": "是否启用自动召回" }, "maxResults": { "type": "number", "default": 5, "description": "召回最大结果数" }, + "maxCharsPerMemory": { "type": "number", "default": 800, "description": "单条 L1 记忆注入的最大字符数;填 0 表示不限制" }, + "maxTotalRecallChars": { "type": "number", "default": 3000, "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": "召回整体超时(毫秒),超时后跳过记忆注入并打印警告日志" } diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..ac89269 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { parseConfig } from "./config.js"; + +describe("parseConfig", () => { + it("defaults recall injection budgets to bounded values", () => { + const cfg = parseConfig({}); + + expect(cfg.recall.maxCharsPerMemory).toBe(800); + expect(cfg.recall.maxTotalRecallChars).toBe(3000); + }); + + it("parses recall injection character budgets", () => { + const cfg = parseConfig({ + recall: { + maxCharsPerMemory: 800, + maxTotalRecallChars: 3000, + }, + }); + + expect(cfg.recall.maxCharsPerMemory).toBe(800); + expect(cfg.recall.maxTotalRecallChars).toBe(3000); + }); +}); diff --git a/src/config.ts b/src/config.ts index e09cff5..2fea748 100644 --- a/src/config.ts +++ b/src/config.ts @@ -80,6 +80,10 @@ export interface RecallConfig { enabled: boolean; /** Max results to return (default: 5) */ maxResults: number; + /** Max characters injected for a single recalled L1 memory. 0 disables the per-memory limit. */ + maxCharsPerMemory: number; + /** Max total characters injected for all recalled L1 memories. 0 disables the total limit. */ + maxTotalRecallChars: number; /** Minimum score threshold (default: 0.3) */ scoreThreshold: number; /** Search strategy (default: "hybrid") */ @@ -486,6 +490,8 @@ export function parseConfig(raw: Record | undefined): MemoryTda recall: { enabled: bool(recallGroup, "enabled") ?? true, maxResults: num(recallGroup, "maxResults") ?? 5, + maxCharsPerMemory: normalizeNonNegativeInt(num(recallGroup, "maxCharsPerMemory"), 800), + maxTotalRecallChars: normalizeNonNegativeInt(num(recallGroup, "maxTotalRecallChars"), 3000), scoreThreshold: num(recallGroup, "scoreThreshold") ?? 0.3, strategy: validateStrategy(str(recallGroup, "strategy")) ?? "hybrid", timeoutMs: num(recallGroup, "timeoutMs") ?? 5000, @@ -566,6 +572,12 @@ function num(src: Record, key: string): number | undefined { return typeof v === "number" && Number.isFinite(v) ? v : undefined; } +function normalizeNonNegativeInt(value: number | undefined, fallback: number): number { + if (value == null) return fallback; + if (value < 0) return fallback; + return Math.floor(value); +} + function bool(src: Record, key: string): boolean | undefined { const v = src[key]; return typeof v === "boolean" ? v : undefined; diff --git a/src/core/hooks/auto-recall.test.ts b/src/core/hooks/auto-recall.test.ts new file mode 100644 index 0000000..2d4ed01 --- /dev/null +++ b/src/core/hooks/auto-recall.test.ts @@ -0,0 +1,81 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { parseConfig } from "../../config.js"; +import type { IMemoryStore, L1FtsResult } from "../store/types.js"; +import { performAutoRecall } from "./auto-recall.js"; + +describe("performAutoRecall recall budget", () => { + let dataDir: string | undefined; + + afterEach(() => { + if (dataDir) { + rmSync(dataDir, { recursive: true, force: true }); + dataDir = undefined; + } + }); + + it("truncates individual L1 memories and caps total injected recall text", async () => { + dataDir = mkdtempSync(path.join(tmpdir(), "memory-tdai-recall-")); + const store = { + isFtsAvailable: () => true, + getCapabilities: () => ({ nativeHybridSearch: false }), + searchL1Fts: vi.fn(async (): Promise => [ + makeFtsResult("a", "A".repeat(180), 0.92), + makeFtsResult("b", "B".repeat(180), 0.91), + makeFtsResult("c", "C".repeat(180), 0.9), + ]), + } as unknown as IMemoryStore; + + const cfg = parseConfig({ + recall: { + strategy: "keyword", + maxResults: 3, + scoreThreshold: 0, + maxCharsPerMemory: 90, + maxTotalRecallChars: 150, + }, + }); + + const result = await performAutoRecall({ + userText: "alpha", + actorId: "user", + sessionKey: "session", + cfg, + pluginDataDir: dataDir, + vectorStore: store, + }); + + const injected = extractRelevantMemoryLines(result?.prependContext); + expect(injected.length).toBeLessThanOrEqual(150); + expect(injected).toContain("已截断"); + expect(injected).not.toContain("A".repeat(120)); + expect(injected).not.toContain("C".repeat(120)); + }); +}); + +function makeFtsResult(id: string, content: string, score: number): L1FtsResult { + return { + record_id: id, + content, + type: "episodic", + priority: 80, + scene_name: "test", + score, + timestamp_str: "", + timestamp_start: "", + timestamp_end: "", + session_key: "session", + session_id: "session-1", + metadata_json: "{}", + }; +} + +function extractRelevantMemoryLines(prependContext: string | undefined): string { + const match = prependContext?.match( + /[\s\S]*?\n\n([\s\S]*?)\n<\/relevant-memories>/, + ); + return match?.[1] ?? ""; +} diff --git a/src/core/hooks/auto-recall.ts b/src/core/hooks/auto-recall.ts index cccb864..b17561c 100644 --- a/src/core/hooks/auto-recall.ts +++ b/src/core/hooks/auto-recall.ts @@ -22,6 +22,8 @@ import type { EmbeddingService, EmbeddingCallOptions } from "../store/embedding. import { sanitizeText } from "../../utils/sanitize.js"; const TAG = "[memory-tdai] [recall]"; +const RECALL_TRUNCATION_SUFFIX = "…(已截断;可用 tdai_memory_search 或 tdai_conversation_search 查看详情)"; +const MIN_TRUNCATED_RECALL_LINE_CHARS = 40; /** * Memory tools usage guide — injected at the end of memory context so the @@ -127,6 +129,7 @@ async function performAutoRecallInner(params: { const searchResult = await searchMemories(userText, pluginDataDir, cfg, logger, effectiveStrategy as "keyword" | "embedding" | "hybrid", vectorStore, embeddingService); memoryLines = searchResult.lines; searchTiming = searchResult.timing; + memoryLines = applyRecallBudget(memoryLines, cfg.recall, logger); // Extract structured RecalledMemory from formatted lines for metric reporting recalledL1Memories = memoryLines.map((line) => { @@ -706,6 +709,83 @@ function formatMemoryLine(m: FormatableMemory): string { return line; } +function applyRecallBudget( + lines: string[], + recall: MemoryTdaiConfig["recall"], + logger?: Logger, +): string[] { + const maxCharsPerMemory = normalizeBudgetLimit(recall.maxCharsPerMemory); + const maxTotalRecallChars = normalizeBudgetLimit(recall.maxTotalRecallChars); + + if (!maxCharsPerMemory && !maxTotalRecallChars) { + return lines; + } + + const budgeted: string[] = []; + let usedChars = 0; + let truncatedCount = 0; + let droppedCount = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const perMemoryBounded = maxCharsPerMemory + ? truncateRecallLine(line, maxCharsPerMemory) + : line; + if (perMemoryBounded !== line) truncatedCount++; + + if (!maxTotalRecallChars) { + budgeted.push(perMemoryBounded); + continue; + } + + const separatorChars = budgeted.length > 0 ? 1 : 0; + const remainingChars = maxTotalRecallChars - usedChars - separatorChars; + if (remainingChars <= 0) { + droppedCount += lines.length - i; + break; + } + + if (perMemoryBounded.length > remainingChars) { + if (remainingChars >= MIN_TRUNCATED_RECALL_LINE_CHARS) { + const totalBounded = truncateRecallLine(perMemoryBounded, remainingChars); + budgeted.push(totalBounded); + usedChars += separatorChars + totalBounded.length; + if (totalBounded !== perMemoryBounded) truncatedCount++; + } else { + droppedCount++; + } + droppedCount += lines.length - i - 1; + break; + } + + budgeted.push(perMemoryBounded); + usedChars += separatorChars + perMemoryBounded.length; + } + + if (truncatedCount > 0 || droppedCount > 0) { + logger?.debug?.( + `${TAG} Recall budget applied: input=${lines.length}, output=${budgeted.length}, ` + + `truncated=${truncatedCount}, dropped=${droppedCount}, ` + + `maxCharsPerMemory=${recall.maxCharsPerMemory}, maxTotalRecallChars=${recall.maxTotalRecallChars}`, + ); + } + + return budgeted; +} + +function normalizeBudgetLimit(value: number | undefined): number | undefined { + if (value == null || !Number.isFinite(value) || value <= 0) return undefined; + return Math.floor(value); +} + +function truncateRecallLine(line: string, maxChars: number): string { + if (line.length <= maxChars) return line; + if (maxChars <= RECALL_TRUNCATION_SUFFIX.length) { + return line.slice(0, maxChars); + } + return `${line.slice(0, maxChars - RECALL_TRUNCATION_SUFFIX.length).trimEnd()}${RECALL_TRUNCATION_SUFFIX}`; +} + /** * Format an ISO 8601 timestamp to a concise date or datetime string. * - If the time part is 00:00:00 → show date only (e.g. "2025-03-01") From f7ee6f172ccdf95bdb173a58daf037cdbc082266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Thu, 21 May 2026 22:39:45 +0800 Subject: [PATCH 2/2] fix(package): ship built runtime only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 李冠辰 --- package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/package.json b/package.json index 0609611..7884d0a 100644 --- a/package.json +++ b/package.json @@ -34,14 +34,12 @@ "files": [ "dist/", "bin/", - "index.ts", "scripts/migrate-sqlite-to-tcvdb/dist/", "scripts/export-tencent-vdb/dist/", "scripts/read-local-memory/dist/", "scripts/memory-tencentdb-ctl.sh", "scripts/install_hermes_memory_tencentdb.sh", "scripts/README.memory-tencentdb-ctl.md", - "src/", "scripts/openclaw-after-tool-call-messages.patch.sh", "scripts/setup-offload.sh", "hermes-plugin/", @@ -102,7 +100,7 @@ }, "openclaw": { "extensions": [ - "./index.ts" + "./dist/index.mjs" ], "compat": { "pluginApi": ">=2026.3.13",