feat(recall): cap injected memory context (#71)

- Add `recall.maxCharsPerMemory` and `recall.maxTotalRecallChars` with defaults of `0`, which do not alter existing behavior. Users can opt in by setting positive values to cap injected memory context.
- Apply the budget after L1 search and before `<relevant-memories>` injection, preserving score order while truncating oversized entries and dropping overflow.
- Document the new guards in README, README_CN, and `openclaw.plugin.json`.
This commit is contained in:
YOMXXX
2026-05-26 21:19:38 +08:00
committed by GitHub
parent d92cbd3840
commit 1bdcf28c5e
5 changed files with 96 additions and 1 deletions
+6
View File
@@ -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<string, unknown> | undefined): MemoryTda
recall: {
enabled: bool(recallGroup, "enabled") ?? true,
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,
+84 -1
View File
@@ -22,6 +22,9 @@ 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;
const RECALL_LINE_SEPARATOR = "\n";
/**
* Memory tools usage guide — injected at the end of memory context so the
@@ -127,6 +130,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) => {
@@ -206,7 +210,7 @@ async function performAutoRecallInner(params: {
let prependContext: string | undefined;
if (memoryLines.length > 0) {
prependContext =
`<relevant-memories>\n以下是当前对话召回的相关记忆,不代表当前任务进程,仅作为参考:\n\n${memoryLines.join("\n")}\n</relevant-memories>`;
`<relevant-memories>\n以下是当前对话召回的相关记忆,不代表当前任务进程,仅作为参考:\n\n${memoryLines.join(RECALL_LINE_SEPARATOR)}\n</relevant-memories>`;
}
// Append memory tools usage guide to the stable part so the agent knows
@@ -706,6 +710,85 @@ 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;
let wasTruncated = perMemoryBounded !== line;
if (!maxTotalRecallChars) {
budgeted.push(perMemoryBounded);
if (wasTruncated) truncatedCount++;
continue;
}
const separatorChars = budgeted.length > 0 ? RECALL_LINE_SEPARATOR.length : 0;
const remainingChars = maxTotalRecallChars - usedChars - separatorChars;
if (remainingChars <= 0) {
droppedCount += lines.length - i;
break;
}
if (perMemoryBounded.length > remainingChars) {
const canFit = remainingChars >= MIN_TRUNCATED_RECALL_LINE_CHARS;
if (canFit) {
const totalBounded = truncateRecallLine(perMemoryBounded, remainingChars);
budgeted.push(totalBounded);
usedChars += separatorChars + totalBounded.length;
wasTruncated ||= totalBounded !== perMemoryBounded;
if (wasTruncated) truncatedCount++;
}
droppedCount += lines.length - i - (canFit ? 1 : 0);
break;
}
budgeted.push(perMemoryBounded);
usedChars += separatorChars + perMemoryBounded.length;
if (wasTruncated) truncatedCount++;
}
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")