Skip to content
Draft
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
22 changes: 22 additions & 0 deletions lib/prompts/compress.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<compress_result name="summary_N">` 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
Expand Down
4 changes: 4 additions & 0 deletions lib/prompts/nudge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<compress_result name="summary_N">` 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.
Expand Down Expand Up @@ -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).
</instruction>
11 changes: 10 additions & 1 deletion lib/prompts/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<compress_result name="summary_N">` 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
Expand All @@ -52,7 +60,8 @@ It is of your responsibility to keep a sharp, high-quality context window for op
</instruction>

<manual><instruction name=manual_mode policy_level=critical>
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 `<compress triggered manually>` in the current user instruction context.

Expand Down
161 changes: 157 additions & 4 deletions lib/tools/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,148 @@ 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 = /^<compress_result\s+name="([^"]+)"[^>]*>/i
const SUMMARY_NAME_REGEX = /^summary_(\d+)$/i

function stripSummaryPrefix(summary: string): string {
if (summary.startsWith(COMPRESS_SUMMARY_PREFIX)) {
return summary.slice(COMPRESS_SUMMARY_PREFIX.length).trim()
}
return summary.trim()
}

function wrapSummaryBlock(name: string, content: string): string {
return `<compress_result name="${name}">\n${content.trim()}\n</compress_result>`
}

function getNextSummaryNumericId(summaries: CompressSummary[]): number {
let maxId = -1

for (const summary of summaries) {
const content = stripSummaryPrefix(summary.summary)
const matches = content.matchAll(/<compress_result\s+name="summary_(\d+)"[^>]*>/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<string>()
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 namedSummaryBlocks = buildNamedSummaryBlocks(removedSummaries)

if (namedSummaryBlocks.length === 0) {
return newSummary
}

const summaryByName = new Map(namedSummaryBlocks.map((entry) => [entry.name, entry.block]))

const usedSummaryNames = new Set<string>()

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<typeof tool> {
return tool({
Expand Down Expand Up @@ -288,6 +430,9 @@ export function createCompressTool(ctx: ToolContext): ReturnType<typeof tool> {
},
})

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) =>
Expand All @@ -296,7 +441,7 @@ export function createCompressTool(ctx: ToolContext): ReturnType<typeof tool> {
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,
Expand All @@ -309,15 +454,23 @@ export function createCompressTool(ctx: ToolContext): ReturnType<typeof tool> {
)
}

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 + summary,
summary: COMPRESS_SUMMARY_PREFIX + wrappedMergedSummary,
}
state.compressSummaries.push(compressSummary)

clog.info(C.COMPRESS, `Summary Creation`, {
anchor: anchorMessageId,
summaryName: nextSummaryName,
totalSummaries: state.compressSummaries.length,
})

Expand Down Expand Up @@ -403,7 +556,7 @@ export function createCompressTool(ctx: ToolContext): ReturnType<typeof tool> {
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,
Expand All @@ -427,7 +580,7 @@ export function createCompressTool(ctx: ToolContext): ReturnType<typeof tool> {
compressedToolIds,
compressedMessageIds,
topic,
summary,
wrappedMergedSummary,
summaryTokens,
totalSessionTokens,
estimatedCompressedTokens,
Expand Down
Loading