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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 条新记忆触发用户画像生成 |
Expand Down
2 changes: 2 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "召回整体超时(毫秒),超时后跳过记忆注入并打印警告日志" }
Expand Down
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down Expand Up @@ -102,7 +100,7 @@
},
"openclaw": {
"extensions": [
"./index.ts"
"./dist/index.mjs"
],
"compat": {
"pluginApi": ">=2026.3.13",
Expand Down
23 changes: 23 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") */
Expand Down Expand Up @@ -486,6 +490,8 @@ export function parseConfig(raw: Record<string, unknown> | 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,
Expand Down Expand Up @@ -566,6 +572,12 @@ function num(src: Record<string, unknown>, 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<string, unknown>, key: string): boolean | undefined {
const v = src[key];
return typeof v === "boolean" ? v : undefined;
Expand Down
81 changes: 81 additions & 0 deletions src/core/hooks/auto-recall.test.ts
Original file line number Diff line number Diff line change
@@ -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<L1FtsResult[]> => [
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(
/<relevant-memories>[\s\S]*?\n\n([\s\S]*?)\n<\/relevant-memories>/,
);
return match?.[1] ?? "";
}
80 changes: 80 additions & 0 deletions src/core/hooks/auto-recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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")
Expand Down
Loading