From 4d61383b6fc6589857eae5e8b1fd911e59eedf62 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 21 Apr 2026 13:45:07 +0800 Subject: [PATCH] feat(memory): preserve verbatim content the user explicitly asked to remember MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The memory compression path summarises conversation events and can therefore drop exact user text — names, numbers, sentences the user flagged with an explicit "remember this" imperative were getting paraphrased or summarised into generic bullets in `## Key Decisions`, then losing specificity further on each update cycle. Result: durable memory was missing content the user explicitly asked to preserve. This is handled entirely in prompts — no language-specific keyword table, no regex gate. The compressor LLM already reads the user's messages; we just tell it to recognise the INTENT (cross-language: 记住/记得/记下/牢记, remember/keep in mind/note this, 覚えて, 기억해줘, recuerda, запомни, etc.) and route that content into a dedicated section. - `summary-compressor.ts buildCompressionPrompt`: new `## User-Pinned Notes` section in the structured template. Both the fresh-compression branch and the update-existing-summary branch carry an explicit VERBATIM PRESERVATION RULE telling the LLM: copy pinned content word-for-word, never paraphrase, translate, or truncate; carry it forward unchanged across every update. - `materialization-coordinator.ts extractDurableSignalsFromSummary`: refactored to use a shared `extractSummarySection` helper (escaped-title regex) so any section can be pulled out. Now also scans `## User-Pinned Notes` and pushes each non-empty line into the `preferences` bucket of DurableSignals. Effect: pinned content is automatically promoted to `durable_memory_candidate` instead of living only in the short-lived recent_summary, where it could eventually age out on rotation. Existing summary-compressor + materialization tests remain green (25/25). The compressor tests don't assert on prompt text, so the change ships without touching test expectations. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/context/materialization-coordinator.ts | 78 +++++++++++++++------- src/context/summary-compressor.ts | 7 ++ 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/src/context/materialization-coordinator.ts b/src/context/materialization-coordinator.ts index 3cd029e49..924161980 100644 --- a/src/context/materialization-coordinator.ts +++ b/src/context/materialization-coordinator.ts @@ -381,36 +381,68 @@ type DurableSignals = { }; function extractDurableSignalsFromSummary(summary: string): DurableSignals { - const empty: DurableSignals = { decisions: [], constraints: [], preferences: [] }; - const match = summary.match(/##\s+Key Decisions\s*\n([\s\S]*?)(?:\n##\s+|$)/i); - const section = match?.[1]?.trim(); - if (!section) return empty; - const signals: DurableSignals = { decisions: [], constraints: [], preferences: [] }; - const lines = section - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); - for (const line of lines) { - const normalized = line.replace(/^[*-]\s*/, '').trim(); - if (!normalized) continue; - if (/^key decisions?:/i.test(normalized)) { - pushDurableItems(signals.decisions, normalized.replace(/^key decisions?:/i, '').trim()); - continue; - } - if (/^constraints?:/i.test(normalized)) { - pushDurableItems(signals.constraints, normalized.replace(/^constraints?:/i, '').trim()); - continue; + + const decisionsSection = extractSummarySection(summary, 'Key Decisions'); + if (decisionsSection) { + const lines = decisionsSection + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + for (const line of lines) { + const normalized = line.replace(/^[*-]\s*/, '').trim(); + if (!normalized) continue; + if (/^key decisions?:/i.test(normalized)) { + pushDurableItems(signals.decisions, normalized.replace(/^key decisions?:/i, '').trim()); + continue; + } + if (/^constraints?:/i.test(normalized)) { + pushDurableItems(signals.constraints, normalized.replace(/^constraints?:/i, '').trim()); + continue; + } + if (/^preferences?:/i.test(normalized)) { + pushDurableItems(signals.preferences, normalized.replace(/^preferences?:/i, '').trim()); + continue; + } + pushUnique(signals.decisions, normalized); } - if (/^preferences?:/i.test(normalized)) { - pushDurableItems(signals.preferences, normalized.replace(/^preferences?:/i, '').trim()); - continue; + } + + // User-Pinned Notes: content the user explicitly asked us to remember + // (in any language — the compressor prompt recognises the INTENT, not a + // keyword list). Each non-empty line is promoted to a durable preference + // verbatim so "记住 X" survives both compression AND the durable-memory + // promotion filter. Preferences is the right bucket because these are + // user-authored instructions that persist across sessions; decisions and + // constraints have implementation-specific semantics the user didn't + // necessarily intend. + const pinnedSection = extractSummarySection(summary, 'User-Pinned Notes'); + if (pinnedSection) { + const lines = pinnedSection + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + for (const line of lines) { + // Strip leading bullet markers but otherwise keep the line EXACTLY as + // the compressor produced it — the compressor is already instructed + // to preserve the user's original words. + const normalized = line.replace(/^[*-]\s*/, '').trim(); + if (!normalized) continue; + pushUnique(signals.preferences, normalized); } - pushUnique(signals.decisions, normalized); } + return signals; } +/** Extract the body of a `## ` section up to the next `## ` or EOF. */ +function extractSummarySection(summary: string, title: string): string | null { + const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?:\\n##\\s+|$)`, 'i'); + const match = summary.match(re); + return match?.[1]?.trim() ?? null; +} + function pushDurableItems(bucket: string[], value: string): void { if (!value) return; for (const part of value.split(/\s*;\s*/)) { diff --git a/src/context/summary-compressor.ts b/src/context/summary-compressor.ts index c0d8c8364..9e696f3a3 100644 --- a/src/context/summary-compressor.ts +++ b/src/context/summary-compressor.ts @@ -524,6 +524,9 @@ function buildCompressionPrompt( ## Key Decisions [Important technical decisions and why — include constraints and preferences] +## User-Pinned Notes +[If the user explicitly asked you to remember, memorize, take note of, or never forget any specific piece of information (in any language — e.g. English "remember/keep in mind/note this", Chinese "记住/记得/记下/牢记", Japanese "覚えて/覚えておいて", Korean "기억해줘", Spanish "recuerda", Russian "запомни", etc. — recognise the INTENT, not any fixed keyword list), copy the exact content here VERBATIM. Never paraphrase, summarise, translate, truncate, or reword pinned content. Preserve the user's original words exactly, including code, paths, numbers, names, and formatting. If there are no such requests in this batch, omit this section entirely.] + ## Active State [Files modified, test results, current branch — only if relevant]`; @@ -538,6 +541,8 @@ ${serializedEvents} Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new actions and outcomes. Move completed items from pending to resolved. Update active state. Remove information only if clearly obsolete. +CRITICAL — VERBATIM PRESERVATION RULE: If the previous summary contains a "User-Pinned Notes" section, every line in it MUST be carried forward UNCHANGED (word-for-word, character-for-character) into the updated summary. Also scan NEW EVENTS for any user message expressing an intent to be remembered (in any language — see the "User-Pinned Notes" description below). Append such content verbatim to that section. Never drop, paraphrase, translate, or compress pinned content, even if it looks redundant. + ${template} Target ~${targetTokens} tokens. Be CONCRETE — include file paths, error messages, and specific values. Write only the summary.`; @@ -552,6 +557,8 @@ Use this exact structure: ${template} +CRITICAL — VERBATIM PRESERVATION RULE: If any user message in the events above expresses an intent to be remembered (in any language — see the "User-Pinned Notes" description above), copy that exact content word-for-word into the "User-Pinned Notes" section. Never paraphrase, translate, summarise, or reorder pinned content. + Target ~${targetTokens} tokens. Be CONCRETE — include file paths, error messages, and specific values. Write only the summary.`; }