From ef83066ac82ee6ff5b795cb4852709cec229f7ff Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:48:49 +0100 Subject: [PATCH 1/2] fix: preserve subsumed compress summaries When a new compress range includes prior summary anchors, carry their summary text forward instead of discarding it. This prevents recompression loops from losing previously captured context and adds regression coverage for summary merging. --- lib/tools/compress.ts | 30 +++++++++++++++++-- tests/compress-summary-merge.test.ts | 45 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 tests/compress-summary-merge.test.ts diff --git a/lib/tools/compress.ts b/lib/tools/compress.ts index 71fa6243..15ba63bf 100644 --- a/lib/tools/compress.ts +++ b/lib/tools/compress.ts @@ -19,6 +19,28 @@ import { clog, C } from "../compress-logger" const COMPRESS_TOOL_DESCRIPTION = COMPRESS_TOOL_SPEC const COMPRESS_SUMMARY_PREFIX = "[Compressed conversation block]\n\n" +function stripSummaryPrefix(summary: string): string { + if (summary.startsWith(COMPRESS_SUMMARY_PREFIX)) { + return summary.slice(COMPRESS_SUMMARY_PREFIX.length).trim() + } + return summary.trim() +} + +export function mergeSubsumedSummaries( + removedSummaries: CompressSummary[], + newSummary: string, +): string { + const inheritedSummaryBlocks = removedSummaries + .map((s) => stripSummaryPrefix(s.summary)) + .filter((s) => s.length > 0) + + if (inheritedSummaryBlocks.length === 0) { + return newSummary + } + + return `${inheritedSummaryBlocks.join("\n\n")}\n\n${newSummary}` +} + export function createCompressTool(ctx: ToolContext): ReturnType { return tool({ description: COMPRESS_TOOL_DESCRIPTION, @@ -296,7 +318,7 @@ export function createCompressTool(ctx: ToolContext): ReturnType { if (removedSummaries.length > 0) { clog.info( C.COMPRESS, - `Removing Subsumed Summaries\ncount: ${removedSummaries.length}`, + `Merging Subsumed Summaries\ncount: ${removedSummaries.length}`, { removed: removedSummaries.map((s) => ({ anchor: s.anchorMessageId, @@ -309,10 +331,12 @@ export function createCompressTool(ctx: ToolContext): ReturnType { ) } + const mergedSummary = mergeSubsumedSummaries(removedSummaries, summary) + const anchorMessageId = messages[rawStartIndex]?.info.id || startResult.messageId const compressSummary: CompressSummary = { anchorMessageId, - summary: COMPRESS_SUMMARY_PREFIX + summary, + summary: COMPRESS_SUMMARY_PREFIX + mergedSummary, } state.compressSummaries.push(compressSummary) @@ -403,7 +427,7 @@ export function createCompressTool(ctx: ToolContext): ReturnType { state.stats.pruneTokenCounter += estimatedCompressedTokens const currentParams = getCurrentParams(state, messages, logger) - const summaryTokens = countTokens(args.content.summary) + const summaryTokens = countTokens(compressSummary.summary) clog.info(C.COMPRESS, `Notification Values`, { totalSessionTokens, diff --git a/tests/compress-summary-merge.test.ts b/tests/compress-summary-merge.test.ts new file mode 100644 index 00000000..385e2e01 --- /dev/null +++ b/tests/compress-summary-merge.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mergeSubsumedSummaries } from "../lib/tools/compress" +import type { CompressSummary } from "../lib/state" + +const PREFIX = "[Compressed conversation block]\n\n" + +test("mergeSubsumedSummaries keeps new summary when there are no prior summaries", () => { + const merged = mergeSubsumedSummaries([], "Newest summary") + assert.equal(merged, "Newest summary") +}) + +test("mergeSubsumedSummaries prepends subsumed summaries in order", () => { + const removed: CompressSummary[] = [ + { + anchorMessageId: "a1", + summary: `${PREFIX}Older summary A`, + }, + { + anchorMessageId: "a2", + summary: `${PREFIX}Older summary B`, + }, + ] + + const merged = mergeSubsumedSummaries(removed, "Newest summary") + + assert.equal(merged, "Older summary A\n\nOlder summary B\n\nNewest summary") +}) + +test("mergeSubsumedSummaries ignores empty subsumed summaries", () => { + const removed: CompressSummary[] = [ + { + anchorMessageId: "a1", + summary: `${PREFIX} `, + }, + { + anchorMessageId: "a2", + summary: "Useful prior summary", + }, + ] + + const merged = mergeSubsumedSummaries(removed, "Newest summary") + + assert.equal(merged, "Useful prior summary\n\nNewest summary") +}) From b5a4416c81da9d5f15266dcf0f70e38e9a0d2556 Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:32:00 +0100 Subject: [PATCH 2/2] interleaved summary injection --- lib/prompts/compress.md | 22 ++++ lib/prompts/nudge.md | 4 + lib/prompts/system.md | 11 +- lib/tools/compress.ts | 145 +++++++++++++++++++++++++-- tests/compress-summary-merge.test.ts | 109 ++++++++++++++++++-- 5 files changed, 275 insertions(+), 16 deletions(-) diff --git a/lib/prompts/compress.md b/lib/prompts/compress.md index dab33ca6..789ea719 100644 --- a/lib/prompts/compress.md +++ b/lib/prompts/compress.md @@ -31,6 +31,28 @@ USER INTENT FIDELITY When the compressed range includes user messages, preserve the user's intent with extra care. Do not change scope, constraints, priorities, acceptance criteria, or requested outcomes. Directly quote user messages when they are short enough to include safely. Direct quotes are preferred when they best preserve exact meaning. +SUMMARY TAG MERGE PROTOCOL +When the range you are compressing includes previously-compressed summaries, you MUST preserve them by using summary tags in your new `summary` text. + +- Use placeholders like `{summary_0}`, `{summary_1}`, `{summary_2}` where prior summaries should be injected. +- Placeholder matching is case-insensitive (`{sumMary_0}` is treated the same as `{summary_0}`). +- Tags map to the existing `` labels present in inherited summaries. +- If there are multiple prior summaries, reference each one exactly once unless duplication is truly necessary. +- Integrate tags naturally into your narrative so inherited context lands in the right section, not just as a generic preface. + +Example pattern inside your `summary`: + +``` +## Prior context +{summary_0} + +## Recent work +... + +## Additional inherited details +{summary_1} +``` + Yet be LEAN. Strip away the noise: failed attempts that led nowhere, verbose tool outputs, back-and-forth exploration. What remains should be pure signal - golden nuggets of detail that preserve full understanding with zero ambiguity. THE WAYS OF COMPRESS diff --git a/lib/prompts/nudge.md b/lib/prompts/nudge.md index bffe7789..e0af14e1 100644 --- a/lib/prompts/nudge.md +++ b/lib/prompts/nudge.md @@ -8,6 +8,8 @@ You should prioritize context management, but do not interrupt a critical atomic IMMEDIATE ACTION REQUIRED KNOWLEDGE PRESERVATION: If holding valuable raw data you POTENTIALLY will need in your task, compress the smallest closed range that preserves those findings. Write a high-fidelity summary so no key technical insight is lost. +If the selected range includes previously-compressed summaries, place `{summary_0}`, `{summary_1}`, ... in your new summary where inherited context should be injected. Matching is case-insensitive and maps to inherited `` labels. + NOISE REMOVAL: If you read files or ran commands that yielded no value, compress those dead-end ranges into explicit noise summaries and move on. PHASE COMPLETION: If a chapter is complete, compress the entire sequence into a detailed technical summary with unambiguous outcomes. @@ -40,4 +42,6 @@ NEVER use tool input schema keys or field names. The ONLY acceptable use of tool Ensure your summaries are inclusive of all parts of the range. If the compressed range includes user messages, preserve user intent exactly. Prefer direct quotes for short user messages to avoid semantic drift. + +When the range includes prior compressed summaries, reference them with `{summary_0}`, `{summary_1}`, ... in the new summary so inherited details are injected at the right location (case-insensitive matching). diff --git a/lib/prompts/system.md b/lib/prompts/system.md index 622f12cf..485fce9f 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -36,6 +36,14 @@ DO NOT USE A TOOL SCHEMA FIELD FOR START OR END STRING. THE SUMMARY STANDARD Your summary MUST be technical and specific enough to preserve FULL understanding of what transpired, such that NO ambiguity remains about what asked, found, planned, done, or decided - yet noise free +SUMMARY TAG MERGE PROTOCOL +When compressing a range that includes previously-compressed summaries, preserve them by placing summary tags in your new summary text: + +- Use `{summary_0}`, `{summary_1}`, `{summary_2}`, ... at the exact points where inherited summaries should appear. +- Matching is case-insensitive (`{sumMary_0}` and `{summary_0}` are equivalent). +- Tags map to inherited `` labels already present in context. +- Prefer integrating each tag once into the right section rather than dumping inherited summaries as an undifferentiated block. + When compressing ranges that include user messages, preserve user intent faithfully. Do not reinterpret or redirect the request. Directly quote short user messages when that is the most reliable way to preserve exact meaning. Preserve key details: file paths, symbols, signatures, constraints, decisions, outcomes, commands, etc.. in order to produce a high fidelity, authoritative technical record @@ -52,7 +60,8 @@ It is of your responsibility to keep a sharp, high-quality context window for op -Manual mode is enabled. Do NOT use compress unless the user has explicitly triggered it through a manual marker. +MANUAL MODE IS CURRENTLY ENABLED +Do NOT use compress unless the user has explicitly triggered it through a manual marker. Only use the compress tool after seeing `` in the current user instruction context. diff --git a/lib/tools/compress.ts b/lib/tools/compress.ts index 15ba63bf..5a6959bb 100644 --- a/lib/tools/compress.ts +++ b/lib/tools/compress.ts @@ -18,6 +18,9 @@ import { clog, C } from "../compress-logger" const COMPRESS_TOOL_DESCRIPTION = COMPRESS_TOOL_SPEC const COMPRESS_SUMMARY_PREFIX = "[Compressed conversation block]\n\n" +const SUMMARY_PLACEHOLDER_REGEX = /\{summary_(\d+)\}/gi +const COMPRESS_RESULT_NAME_REGEX = /^]*>/i +const SUMMARY_NAME_REGEX = /^summary_(\d+)$/i function stripSummaryPrefix(summary: string): string { if (summary.startsWith(COMPRESS_SUMMARY_PREFIX)) { @@ -26,19 +29,136 @@ function stripSummaryPrefix(summary: string): string { return summary.trim() } +function wrapSummaryBlock(name: string, content: string): string { + return `\n${content.trim()}\n` +} + +function getNextSummaryNumericId(summaries: CompressSummary[]): number { + let maxId = -1 + + for (const summary of summaries) { + const content = stripSummaryPrefix(summary.summary) + const matches = content.matchAll(/]*>/gi) + for (const match of matches) { + const value = Number.parseInt(match[1], 10) + if (!Number.isNaN(value) && value > maxId) { + maxId = value + } + } + } + + return maxId + 1 +} + +interface NamedSummaryBlock { + name: string + block: string +} + +function buildNamedSummaryBlocks(removedSummaries: CompressSummary[]): NamedSummaryBlock[] { + const stripped = removedSummaries + .map((s) => stripSummaryPrefix(s.summary)) + .filter((s) => s.length > 0) + + if (stripped.length === 0) { + return [] + } + + const namesInUse = new Set() + for (const block of stripped) { + const match = block.match(COMPRESS_RESULT_NAME_REGEX) + const existingName = match?.[1]?.trim() || "" + + if (SUMMARY_NAME_REGEX.test(existingName)) { + namesInUse.add(existingName.toLowerCase()) + } + } + + const result: NamedSummaryBlock[] = [] + let fallbackId = 0 + const nextFallbackName = (): string => { + while (namesInUse.has(`summary_${fallbackId}`)) { + fallbackId++ + } + const name = `summary_${fallbackId}` + namesInUse.add(name) + fallbackId++ + return name + } + + for (const block of stripped) { + const match = block.match(COMPRESS_RESULT_NAME_REGEX) + const existingName = match?.[1]?.trim() || "" + if (SUMMARY_NAME_REGEX.test(existingName)) { + const normalizedName = existingName.toLowerCase() + result.push({ + name: normalizedName, + block, + }) + continue + } + + const fallbackName = nextFallbackName() + result.push({ + name: fallbackName, + block: wrapSummaryBlock(fallbackName, block), + }) + } + + return result +} + export function mergeSubsumedSummaries( removedSummaries: CompressSummary[], newSummary: string, + currentSummaryName: string, ): string { - const inheritedSummaryBlocks = removedSummaries - .map((s) => stripSummaryPrefix(s.summary)) - .filter((s) => s.length > 0) + const namedSummaryBlocks = buildNamedSummaryBlocks(removedSummaries) - if (inheritedSummaryBlocks.length === 0) { + if (namedSummaryBlocks.length === 0) { return newSummary } - return `${inheritedSummaryBlocks.join("\n\n")}\n\n${newSummary}` + const summaryByName = new Map(namedSummaryBlocks.map((entry) => [entry.name, entry.block])) + + const usedSummaryNames = new Set() + + const mergedByTag = newSummary.replace( + SUMMARY_PLACEHOLDER_REGEX, + (_fullTag, numericSuffix: string) => { + const name = `summary_${numericSuffix}`.toLowerCase() + const block = summaryByName.get(name) + if (!block) { + return "" + } + + usedSummaryNames.add(name) + return block + }, + ) + + const unresolvedSummaries = namedSummaryBlocks + .filter((entry) => !usedSummaryNames.has(entry.name)) + .map((entry) => entry.block) + + const trimmedMerged = mergedByTag.trim() + + if (unresolvedSummaries.length === 0) { + return trimmedMerged + } + + const unresolvedCount = unresolvedSummaries.length + const unresolvedHeader = + unresolvedCount === 1 + ? `${currentSummaryName} overlapped 1 summary that was not included in the output. It has been appended below:` + : `${currentSummaryName} overlapped ${unresolvedCount} summaries that were not included in the output. They have been appended below:` + const unresolvedAppendix = `${unresolvedHeader}\n\n${unresolvedSummaries.join("\n\n")}` + + if (trimmedMerged.length === 0) { + return unresolvedAppendix + } + + return `${trimmedMerged}\n\n${unresolvedAppendix}` } export function createCompressTool(ctx: ToolContext): ReturnType { @@ -310,6 +430,9 @@ export function createCompressTool(ctx: ToolContext): ReturnType { }, }) + const nextSummaryNumericId = getNextSummaryNumericId(state.compressSummaries) + const nextSummaryName = `summary_${nextSummaryNumericId}` + // Remove any existing summaries whose anchors are now inside this range // This prevents duplicate injections when a larger compress subsumes a smaller one const removedSummaries = state.compressSummaries.filter((s) => @@ -331,17 +454,23 @@ export function createCompressTool(ctx: ToolContext): ReturnType { ) } - const mergedSummary = mergeSubsumedSummaries(removedSummaries, summary) + const mergedSummary = mergeSubsumedSummaries( + removedSummaries, + summary, + nextSummaryName, + ) + const wrappedMergedSummary = wrapSummaryBlock(nextSummaryName, mergedSummary) const anchorMessageId = messages[rawStartIndex]?.info.id || startResult.messageId const compressSummary: CompressSummary = { anchorMessageId, - summary: COMPRESS_SUMMARY_PREFIX + mergedSummary, + summary: COMPRESS_SUMMARY_PREFIX + wrappedMergedSummary, } state.compressSummaries.push(compressSummary) clog.info(C.COMPRESS, `Summary Creation`, { anchor: anchorMessageId, + summaryName: nextSummaryName, totalSummaries: state.compressSummaries.length, }) @@ -451,7 +580,7 @@ export function createCompressTool(ctx: ToolContext): ReturnType { compressedToolIds, compressedMessageIds, topic, - summary, + wrappedMergedSummary, summaryTokens, totalSessionTokens, estimatedCompressedTokens, diff --git a/tests/compress-summary-merge.test.ts b/tests/compress-summary-merge.test.ts index 385e2e01..0aa1e559 100644 --- a/tests/compress-summary-merge.test.ts +++ b/tests/compress-summary-merge.test.ts @@ -4,13 +4,21 @@ import { mergeSubsumedSummaries } from "../lib/tools/compress" import type { CompressSummary } from "../lib/state" const PREFIX = "[Compressed conversation block]\n\n" +const WRAP = (name: string, content: string) => + `\n${content}\n` +const APPEND_NOTICE = (currentSummaryName: string, count: number) => { + if (count === 1) { + return `${currentSummaryName} overlapped 1 summary that was not included in the output. It has been appended below:` + } + return `${currentSummaryName} overlapped ${count} summaries that were not included in the output. They have been appended below:` +} test("mergeSubsumedSummaries keeps new summary when there are no prior summaries", () => { - const merged = mergeSubsumedSummaries([], "Newest summary") + const merged = mergeSubsumedSummaries([], "Newest summary", "summary_10") assert.equal(merged, "Newest summary") }) -test("mergeSubsumedSummaries prepends subsumed summaries in order", () => { +test("mergeSubsumedSummaries injects summaries by numeric tag", () => { const removed: CompressSummary[] = [ { anchorMessageId: "a1", @@ -22,12 +30,96 @@ test("mergeSubsumedSummaries prepends subsumed summaries in order", () => { }, ] - const merged = mergeSubsumedSummaries(removed, "Newest summary") + const merged = mergeSubsumedSummaries( + removed, + "Top\n{summary_0}\nMiddle\n{summary_1}\nBottom", + "summary_11", + ) - assert.equal(merged, "Older summary A\n\nOlder summary B\n\nNewest summary") + assert.equal( + merged, + `Top\n${WRAP("summary_0", "Older summary A")}\nMiddle\n${WRAP("summary_1", "Older summary B")}\nBottom`, + ) }) -test("mergeSubsumedSummaries ignores empty subsumed summaries", () => { +test("mergeSubsumedSummaries matches tags case-insensitively", () => { + const removed: CompressSummary[] = [ + { + anchorMessageId: "a1", + summary: `${PREFIX}Older summary A`, + }, + ] + + const merged = mergeSubsumedSummaries(removed, "Header\n{sumMary_0}\nFooter", "summary_11") + + assert.equal(merged, `Header\n${WRAP("summary_0", "Older summary A")}\nFooter`) +}) + +test("mergeSubsumedSummaries uses existing summary names when already wrapped", () => { + const removed: CompressSummary[] = [ + { + anchorMessageId: "a1", + summary: `${PREFIX}${WRAP("summary_4", "Prior block four")}`, + }, + { + anchorMessageId: "a2", + summary: `${PREFIX}${WRAP("summary_9", "Prior block nine")}`, + }, + ] + + const merged = mergeSubsumedSummaries( + removed, + "Use this one: {Summary_9}\nThen later: {SUMMARY_4}", + "summary_11", + ) + + assert.equal( + merged, + `Use this one: ${WRAP("summary_9", "Prior block nine")}\nThen later: ${WRAP("summary_4", "Prior block four")}`, + ) +}) + +test("mergeSubsumedSummaries appends overlap notice and summaries when no tags are used", () => { + const removed: CompressSummary[] = [ + { + anchorMessageId: "a1", + summary: `${PREFIX}Older summary A`, + }, + { + anchorMessageId: "a2", + summary: `${PREFIX}Older summary B`, + }, + ] + + const merged = mergeSubsumedSummaries(removed, "Newest summary", "summary_5") + + assert.equal( + merged, + `Newest summary\n\n${APPEND_NOTICE("summary_5", 2)}\n\n${WRAP("summary_0", "Older summary A")}\n\n${WRAP("summary_1", "Older summary B")}`, + ) +}) + +test("mergeSubsumedSummaries appends unresolved summaries when only some tags are used", () => { + const removed: CompressSummary[] = [ + { + anchorMessageId: "a1", + summary: `${PREFIX}Older summary A`, + }, + { + anchorMessageId: "a2", + summary: `${PREFIX}Older summary B`, + }, + ] + + const merged = mergeSubsumedSummaries(removed, "Intro\n{summary_0}\nOutro", "summary_6") + + assert.equal( + merged, + `Intro\n${WRAP("summary_0", "Older summary A")}\nOutro\n\n${APPEND_NOTICE("summary_6", 1)}\n\n${WRAP("summary_1", "Older summary B")}`, + ) +}) + +test("mergeSubsumedSummaries ignores unknown tags and empty summaries", () => { const removed: CompressSummary[] = [ { anchorMessageId: "a1", @@ -39,7 +131,10 @@ test("mergeSubsumedSummaries ignores empty subsumed summaries", () => { }, ] - const merged = mergeSubsumedSummaries(removed, "Newest summary") + const merged = mergeSubsumedSummaries(removed, "Start {sumMary_7} End", "summary_3") - assert.equal(merged, "Useful prior summary\n\nNewest summary") + assert.equal( + merged, + `Start End\n\n${APPEND_NOTICE("summary_3", 1)}\n\n${WRAP("summary_0", "Useful prior summary")}`, + ) })