mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
d952566385
* fix(memory-core): stop MEMORY.md compaction from deleting marker-free user sections Budget compaction decided that a MEMORY.md section belonged to OpenClaw purely from its heading text, so a user-authored section under a heading that looks generated, such as "## Promoted From Short-Term Memory (2026-04-10)", was eligible for deletion once the file plus the new promotion exceeded memoryFileMaxChars. The rewrite succeeded silently and the atomic replace made the loss durable. A matching heading is now provisional: the block is classified as a promotion only when it structurally contains at least one "<!-- openclaw-memory-promotion:... -->" marker, which buildPromotionSection emits for every promoted candidate. Marker-free sections are preserved. Generated sections, including the multi-project shape whose markers sit under "### Global" and "### Project:" subsections, are still dropped oldest-first, so the size bound from #73691 is unchanged. Extends #116057, which made only subsection handling marker-aware and left the top-level heading classifier trusting presentation text. * fix(memory-core): preserve mixed promotion blocks Validate the complete marker-and-entry grammar before treating a promotion-shaped block as writer-owned. Preserve marker-only and mixed blocks so unheaded operator notes cannot be deleted during budget compaction. Co-authored-by: yetval <yetvald@gmail.com> * test(memory-core): allow cold citation startup --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
247 lines
8.3 KiB
TypeScript
247 lines
8.3 KiB
TypeScript
/**
|
|
* Bounded MEMORY.md compaction for dreaming/promotion writes.
|
|
*
|
|
* Background: the dreaming pipeline appends promoted entries to MEMORY.md
|
|
* via short-term-promotion.applyShortTermPromotions. Without a size budget,
|
|
* MEMORY.md grows unboundedly across deep-phase sweeps and eventually
|
|
* exceeds bootstrap's per-file injection cap, breaking session bootstrap.
|
|
* See issue #73691.
|
|
*
|
|
* Strategy: drop the OLDEST auto-promoted sections (date-ordered) until
|
|
* the file plus the new section fit within the budget. A section counts as
|
|
* dreaming-owned only when its complete body matches the marker + entry
|
|
* structure emitted by `buildPromotionSection`. Ambiguous or mixed content
|
|
* is preserved unconditionally.
|
|
*/
|
|
|
|
const PROMOTION_SECTION_HEADING_RE = /^## Promoted From Short-Term Memory \(([^)]+)\)\s*$/;
|
|
|
|
const PROMOTION_SUBSECTION_HEADING_RE = /^### (?:Global|Project: .+?)\s*$/;
|
|
|
|
const PROMOTION_ENTRY_MARKER_RE = /^<!--\s*openclaw-memory-promotion:.*-->\s*$/i;
|
|
|
|
const ATX_HEADING_RE = /^ {0,3}#{1,6}(?:[ \t]|$)/;
|
|
|
|
const SETEXT_HEADING_UNDERLINE_RE = /^ {0,3}(?:=+|-+)[ \t]*$/;
|
|
|
|
/**
|
|
* Default budget for MEMORY.md content on disk, in characters. Chosen to
|
|
* stay safely below the bootstrap injection cap (~12KB per file at the
|
|
* time of writing) so promoted memory keeps reaching new sessions instead
|
|
* of being silently dropped by bootstrap truncation.
|
|
*/
|
|
export const DEFAULT_MEMORY_FILE_MAX_CHARS = 10_000;
|
|
|
|
/**
|
|
* Reserve for writer-side overhead that the helper does not see directly:
|
|
* the `# Long-Term Memory\n\n` header re-emitted when compaction empties
|
|
* out (20 chars) and `withTrailingNewline`'s trailing `\n` (1 char). See
|
|
* the actual write expression in `applyShortTermPromotions`. Subtracting
|
|
* this from `budgetChars` keeps the on-disk file inside the caller's
|
|
* stated budget instead of exceeding it by up to ~21 chars in edge cases.
|
|
*/
|
|
const WRITE_OVERHEAD_RESERVE = 21;
|
|
|
|
type MemoryBlock =
|
|
| { kind: "preserved"; text: string }
|
|
| { kind: "promotion"; date: string; text: string };
|
|
|
|
function isGeneratedPromotionBlock(lines: string[]): boolean {
|
|
let sawEntry = false;
|
|
let index = 1;
|
|
|
|
while (index < lines.length) {
|
|
const line = lines[index] ?? "";
|
|
if (line.trim().length === 0) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (PROMOTION_SUBSECTION_HEADING_RE.test(line)) {
|
|
index += 1;
|
|
while (index < lines.length && (lines[index] ?? "").trim().length === 0) {
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
if (!PROMOTION_ENTRY_MARKER_RE.test(lines[index] ?? "")) {
|
|
return false;
|
|
}
|
|
// A marker owns only the single bullet emitted with it. Treat any other
|
|
// body shape as mixed user content so compaction cannot delete it.
|
|
if (!(lines[index + 1] ?? "").startsWith("- ")) {
|
|
return false;
|
|
}
|
|
sawEntry = true;
|
|
index += 2;
|
|
}
|
|
|
|
return sawEntry;
|
|
}
|
|
|
|
function startsGeneratedPromotionSubsection(lines: string[], index: number): boolean {
|
|
if (!PROMOTION_SUBSECTION_HEADING_RE.test(lines[index] ?? "")) {
|
|
return false;
|
|
}
|
|
for (let next = index + 1; next < lines.length; next += 1) {
|
|
const line = lines[next] ?? "";
|
|
if (line.trim().length === 0) {
|
|
continue;
|
|
}
|
|
return PROMOTION_ENTRY_MARKER_RE.test(line);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function takeSetextHeadingLines(lines: string[]): string[] | undefined {
|
|
let start = lines.length;
|
|
while (start > 1 && lines[start - 1]?.trim().length !== 0) {
|
|
start -= 1;
|
|
}
|
|
if (start === lines.length) {
|
|
return undefined;
|
|
}
|
|
const headingLines = lines.slice(start);
|
|
if (headingLines.some((line) => PROMOTION_ENTRY_MARKER_RE.test(line))) {
|
|
return undefined;
|
|
}
|
|
lines.splice(start);
|
|
return headingLines;
|
|
}
|
|
|
|
function parseMemoryBlocks(content: string): MemoryBlock[] {
|
|
if (content.length === 0) {
|
|
return [];
|
|
}
|
|
const lines = content.split(/\r?\n/);
|
|
const blocks: MemoryBlock[] = [];
|
|
let currentLines: string[] = [];
|
|
let currentKind: "preserved" | "promotion" = "preserved";
|
|
let currentDate: string | undefined;
|
|
|
|
const flush = () => {
|
|
if (currentLines.length === 0) {
|
|
return;
|
|
}
|
|
const text = currentLines.join("\n");
|
|
if (currentKind === "promotion" && currentDate && isGeneratedPromotionBlock(currentLines)) {
|
|
blocks.push({ kind: "promotion", date: currentDate, text });
|
|
} else {
|
|
blocks.push({ kind: "preserved", text });
|
|
}
|
|
currentLines = [];
|
|
currentKind = "preserved";
|
|
currentDate = undefined;
|
|
};
|
|
|
|
for (const [index, line] of lines.entries()) {
|
|
if (currentKind === "promotion" && SETEXT_HEADING_UNDERLINE_RE.test(line)) {
|
|
const headingLines = takeSetextHeadingLines(currentLines);
|
|
if (headingLines) {
|
|
flush();
|
|
currentLines = [...headingLines, line];
|
|
continue;
|
|
}
|
|
}
|
|
const continuesPromotionBody =
|
|
currentKind === "promotion" && startsGeneratedPromotionSubsection(lines, index);
|
|
if (ATX_HEADING_RE.test(line) && !continuesPromotionBody) {
|
|
flush();
|
|
const match = PROMOTION_SECTION_HEADING_RE.exec(line);
|
|
if (match) {
|
|
currentKind = "promotion";
|
|
currentDate = match[1];
|
|
} else {
|
|
currentKind = "preserved";
|
|
}
|
|
currentLines = [line];
|
|
} else {
|
|
currentLines.push(line);
|
|
}
|
|
}
|
|
flush();
|
|
return blocks;
|
|
}
|
|
|
|
function joinBlocks(blocks: MemoryBlock[]): string {
|
|
return blocks.map((block) => block.text).join("\n");
|
|
}
|
|
|
|
type CompactMemoryParams = {
|
|
existingMemory: string;
|
|
newSection: string;
|
|
budgetChars: number;
|
|
};
|
|
|
|
type CompactMemoryResult = {
|
|
compacted: string;
|
|
droppedDates: string[];
|
|
};
|
|
|
|
/**
|
|
* Drop oldest auto-promotion sections from `existingMemory` until
|
|
* `existingMemory + newSection` fits within `budgetChars`. Returns the
|
|
* (possibly trimmed) existing memory and the dates of dropped sections.
|
|
*
|
|
* Guarantees:
|
|
* - Non-promotion content (user-authored markdown, the file header, any
|
|
* heading of any level not matching the promotion pattern, and any mixed
|
|
* or malformed promotion-shaped block) is preserved.
|
|
* - Promotion sections are dropped in ascending date order (oldest first).
|
|
* - If `existingMemory + newSection` already fits the budget, the existing
|
|
* memory is returned unchanged.
|
|
* - If the budget cannot be satisfied even by dropping every promotion
|
|
* section, the function drops them all and returns; the caller writes
|
|
* the new section anyway. This is the "log and continue" failure mode —
|
|
* refusing the new write would silently swallow the freshest material.
|
|
*/
|
|
export function compactMemoryForBudget(params: CompactMemoryParams): CompactMemoryResult {
|
|
const { existingMemory, newSection, budgetChars } = params;
|
|
if (budgetChars <= 0) {
|
|
return { compacted: existingMemory, droppedDates: [] };
|
|
}
|
|
|
|
// Reserve writer-side header + trailing-newline overhead so the on-disk
|
|
// file actually fits the caller's stated budget.
|
|
const effectiveBudget = Math.max(0, budgetChars - WRITE_OVERHEAD_RESERVE);
|
|
|
|
if (existingMemory.length + newSection.length <= effectiveBudget) {
|
|
return { compacted: existingMemory, droppedDates: [] };
|
|
}
|
|
|
|
const blocks = parseMemoryBlocks(existingMemory);
|
|
const promotionEntries = blocks
|
|
.map((block, index) =>
|
|
block.kind === "promotion" ? { index, date: block.date, length: block.text.length } : null,
|
|
)
|
|
.filter((entry): entry is { index: number; date: string; length: number } => entry !== null)
|
|
.toSorted((a, b) => a.date.localeCompare(b.date));
|
|
|
|
if (promotionEntries.length === 0) {
|
|
return { compacted: existingMemory, droppedDates: [] };
|
|
}
|
|
|
|
const droppedIndices = new Set<number>();
|
|
const droppedDates: string[] = [];
|
|
let projectedExistingSize = existingMemory.length;
|
|
// Block boundaries cost one newline each in joinBlocks; subtract a
|
|
// newline along with the block text so the projection stays honest.
|
|
const blockSeparatorCost = blocks.length > 1 ? 1 : 0;
|
|
|
|
for (const entry of promotionEntries) {
|
|
if (projectedExistingSize + newSection.length <= effectiveBudget) {
|
|
break;
|
|
}
|
|
droppedIndices.add(entry.index);
|
|
droppedDates.push(entry.date);
|
|
projectedExistingSize = Math.max(0, projectedExistingSize - entry.length - blockSeparatorCost);
|
|
}
|
|
|
|
if (droppedIndices.size === 0) {
|
|
return { compacted: existingMemory, droppedDates: [] };
|
|
}
|
|
|
|
const remaining = blocks.filter((_, index) => !droppedIndices.has(index));
|
|
return { compacted: joinBlocks(remaining), droppedDates };
|
|
}
|