Skip to content

Commit eddf269

Browse files
committed
feat: migrate compress to injected ID boundaries
1 parent fd233eb commit eddf269

11 files changed

Lines changed: 340 additions & 192 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ For model-facing behavior (prompts and tool calls), this capability is always ad
3030

3131
### Tool
3232

33-
**Compress** — Exposes a single `compress` tool with one method: match a conversation range using `startString` and `endString`, then replace it with a technical summary.
33+
**Compress** — Exposes a single `compress` tool with one method: select a conversation range using injected `startId` and `endId` (`mNNNN` or `bN`), then replace it with a technical summary.
3434

3535
The model can use that same method at different scales: tiny ranges for noise cleanup, focused ranges for preserving key findings, and full chapters for completed work.
3636

lib/hooks.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { SessionState, WithParts } from "./state"
22
import type { Logger } from "./logger"
33
import type { PluginConfig } from "./config"
4+
import { assignMessageRefs } from "./message-ids"
45
import { syncToolCache } from "./state/tool-cache"
56
import { deduplicate, supersedeWrites, purgeErrors } from "./strategies"
6-
import { prune, insertCompressToolContext } from "./messages"
7+
import { prune, insertCompressToolContext, insertMessageIdContext } from "./messages"
78
import { buildToolIdList, isIgnoredUserMessage } from "./messages/utils"
89
import { checkSession } from "./state"
910
import { renderSystemPrompt } from "./prompts"
@@ -104,6 +105,8 @@ export function createChatMessageTransformHandler(
104105

105106
cacheSystemPromptTokens(state, output.messages)
106107

108+
assignMessageRefs(state, output.messages)
109+
107110
syncToolCache(state, config, logger, output.messages)
108111
buildToolIdList(state, output.messages)
109112

@@ -113,6 +116,8 @@ export function createChatMessageTransformHandler(
113116

114117
prune(state, logger, config, output.messages)
115118

119+
insertMessageIdContext(state, output.messages)
120+
116121
insertCompressToolContext(state, config, logger, output.messages)
117122

118123
applyPendingManualTriggerPrompt(state, output.messages, logger)

lib/message-ids.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import type { SessionState, WithParts } from "./state"
2+
3+
const MESSAGE_REF_REGEX = /^m(\d{4})$/
4+
const BLOCK_REF_REGEX = /^b([1-9]\d*)$/
5+
6+
const MESSAGE_REF_WIDTH = 4
7+
const MESSAGE_REF_MIN_INDEX = 0
8+
export const MESSAGE_REF_MAX_INDEX = 9999
9+
10+
export type ParsedBoundaryId =
11+
| {
12+
kind: "message"
13+
ref: string
14+
index: number
15+
}
16+
| {
17+
kind: "compressed-block"
18+
ref: string
19+
blockId: number
20+
}
21+
22+
export function formatMessageRef(index: number): string {
23+
if (
24+
!Number.isInteger(index) ||
25+
index < MESSAGE_REF_MIN_INDEX ||
26+
index > MESSAGE_REF_MAX_INDEX
27+
) {
28+
throw new Error(
29+
`Message ID index out of bounds: ${index}. Supported range is 0-${MESSAGE_REF_MAX_INDEX}.`,
30+
)
31+
}
32+
return `m${index.toString().padStart(MESSAGE_REF_WIDTH, "0")}`
33+
}
34+
35+
export function formatBlockRef(blockId: number): string {
36+
if (!Number.isInteger(blockId) || blockId < 1) {
37+
throw new Error(`Invalid block ID: ${blockId}`)
38+
}
39+
return `b${blockId}`
40+
}
41+
42+
export function parseMessageRef(ref: string): number | null {
43+
const normalized = ref.trim().toLowerCase()
44+
const match = normalized.match(MESSAGE_REF_REGEX)
45+
if (!match) {
46+
return null
47+
}
48+
const index = Number.parseInt(match[1], 10)
49+
return Number.isInteger(index) ? index : null
50+
}
51+
52+
export function parseBlockRef(ref: string): number | null {
53+
const normalized = ref.trim().toLowerCase()
54+
const match = normalized.match(BLOCK_REF_REGEX)
55+
if (!match) {
56+
return null
57+
}
58+
const id = Number.parseInt(match[1], 10)
59+
return Number.isInteger(id) ? id : null
60+
}
61+
62+
export function parseBoundaryId(id: string): ParsedBoundaryId | null {
63+
const normalized = id.trim().toLowerCase()
64+
const messageIndex = parseMessageRef(normalized)
65+
if (messageIndex !== null) {
66+
return {
67+
kind: "message",
68+
ref: formatMessageRef(messageIndex),
69+
index: messageIndex,
70+
}
71+
}
72+
73+
const blockId = parseBlockRef(normalized)
74+
if (blockId !== null) {
75+
return {
76+
kind: "compressed-block",
77+
ref: formatBlockRef(blockId),
78+
blockId,
79+
}
80+
}
81+
82+
return null
83+
}
84+
85+
export function formatMessageIdMarker(ref: string): string {
86+
return `Message ID: ${ref}`
87+
}
88+
89+
export function assignMessageRefs(state: SessionState, messages: WithParts[]): number {
90+
let assigned = 0
91+
92+
for (const message of messages) {
93+
const rawMessageId = message.info.id
94+
if (typeof rawMessageId !== "string" || rawMessageId.length === 0) {
95+
continue
96+
}
97+
98+
const existingRef = state.messageIds.byRawId.get(rawMessageId)
99+
if (existingRef) {
100+
if (state.messageIds.byRef.get(existingRef) !== rawMessageId) {
101+
state.messageIds.byRef.set(existingRef, rawMessageId)
102+
}
103+
continue
104+
}
105+
106+
const ref = allocateNextMessageRef(state)
107+
state.messageIds.byRawId.set(rawMessageId, ref)
108+
state.messageIds.byRef.set(ref, rawMessageId)
109+
assigned++
110+
}
111+
112+
return assigned
113+
}
114+
115+
function allocateNextMessageRef(state: SessionState): string {
116+
let candidate = Number.isInteger(state.messageIds.nextRef)
117+
? Math.max(MESSAGE_REF_MIN_INDEX, state.messageIds.nextRef)
118+
: MESSAGE_REF_MIN_INDEX
119+
120+
while (candidate <= MESSAGE_REF_MAX_INDEX) {
121+
const ref = formatMessageRef(candidate)
122+
if (!state.messageIds.byRef.has(ref)) {
123+
state.messageIds.nextRef = candidate + 1
124+
return ref
125+
}
126+
candidate++
127+
}
128+
129+
throw new Error(
130+
`Message ID alias capacity exceeded. Cannot allocate more than ${formatMessageRef(MESSAGE_REF_MAX_INDEX)} aliases in this session.`,
131+
)
132+
}

lib/messages/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { prune } from "./prune"
22
export { insertCompressToolContext } from "./inject/inject"
3+
export { insertMessageIdContext } from "./inject/inject"

lib/messages/inject/inject.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { SessionState, WithParts } from "../../state"
22
import type { Logger } from "../../logger"
33
import type { PluginConfig } from "../../config"
4+
import { formatMessageIdMarker } from "../../message-ids"
5+
import { createSyntheticTextPart, createSyntheticToolPart, isIgnoredUserMessage } from "../utils"
46
import {
57
addAnchor,
68
applyAnchoredNudge,
@@ -56,3 +58,52 @@ export const insertCompressToolContext = (
5658
persistAnchors(state, logger)
5759
}
5860
}
61+
62+
export const insertMessageIdContext = (state: SessionState, messages: WithParts[]): void => {
63+
const { modelId } = getModelInfo(messages)
64+
const toolModelId = modelId || ""
65+
66+
for (const message of messages) {
67+
if (message.info.role === "user" && isIgnoredUserMessage(message)) {
68+
continue
69+
}
70+
71+
const messageRef = state.messageIds.byRawId.get(message.info.id)
72+
if (!messageRef) {
73+
continue
74+
}
75+
76+
const marker = formatMessageIdMarker(messageRef)
77+
78+
if (message.info.role === "user") {
79+
const hasMarker = message.parts.some(
80+
(part) => part.type === "text" && part.text.trim() === marker,
81+
)
82+
if (!hasMarker) {
83+
message.parts.push(createSyntheticTextPart(message, marker))
84+
}
85+
continue
86+
}
87+
88+
if (message.info.role !== "assistant") {
89+
continue
90+
}
91+
92+
const hasMarker = message.parts.some((part) => {
93+
if (part.type !== "tool") {
94+
return false
95+
}
96+
if (part.tool !== "context_info") {
97+
return false
98+
}
99+
return (
100+
part.state?.status === "completed" &&
101+
typeof part.state.output === "string" &&
102+
part.state.output.trim() === marker
103+
)
104+
})
105+
if (!hasMarker) {
106+
message.parts.push(createSyntheticToolPart(message, marker, toolModelId))
107+
}
108+
}
109+
}

lib/prompts/compress.md

Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Rules:
4141
- Include every required block placeholder exactly once.
4242
- Do not invent placeholders for blocks outside the selected range.
4343
- Treat `{block_N}` placeholders as RESERVED TOKENS. Do not emit `{block_N}` text anywhere except intentional placeholders.
44-
- If you need to mention a block in prose, use plain text like `compressed block #N` (without curly braces).
44+
- If you need to mention a block in prose, use plain text like `compressed bN` (without curly braces).
4545
- Preflight check before finalizing: the set of `{block_N}` placeholders in your summary must exactly match the required set, with no duplicates.
4646

4747
These placeholders are semantic references. They will be replaced with the full stored compressed block content when the tool processes your output.
@@ -66,7 +66,7 @@ Exploration exhausted and patterns understood
6666
Compress smaller ranges when:
6767
You need to discard dead-end noise without waiting for a whole chapter to close
6868
You need to preserve key findings from a narrow slice while freeing context quickly
69-
You can bound a stale range cleanly with unique boundaries
69+
You can bound a stale range cleanly with injected IDs
7070

7171
Do NOT compress when:
7272
You may need exact code, error messages, or file contents from the range in the immediate next steps
@@ -75,40 +75,28 @@ You cannot identify reliable boundaries yet
7575

7676
Before compressing, ask: _"Is this range closed enough to become summary-only right now?"_ Compression is irreversible. The summary replaces everything in the range.
7777

78-
BOUNDARY MATCHING
79-
You specify boundaries by matching unique text strings in the conversation. CRITICAL: In code-centric conversations, strings repeat often. Provide sufficiently unique text to match exactly once. Be conservative and choose longer, highly specific boundaries when in doubt. If a match fails (not found or found multiple times), the tool will error - extend your boundary string with more surrounding context in order to make SURE the tool does NOT error.
78+
BOUNDARY IDS
79+
You specify boundaries by ID
8080

81-
WHERE TO PICK STRINGS FROM (important for reliable matching):
81+
Use the injected IDs visible in the conversation:
8282

83-
- Your own assistant text responses (MOST RELIABLE - always stored verbatim)
84-
- The user's own words in their messages
85-
- Tool result output text (distinctive substrings within the output)
86-
- Previous compress summaries
87-
- Tool input string values (LEAST RELIABLE - only single concrete field values, not keys or schema fields, may be transformed by AI SDK)
83+
- `mNNNN` IDs identify raw messages
84+
- `bN` IDs identify previously compressed blocks
8885

89-
NEVER USE GENERIC OR REPEATING STRINGS:
90-
91-
Tool status messages repeat identically across every invocation. These are ALWAYS ambiguous:
92-
93-
- "Edit applied successfully." (appears in EVERY successful edit)
94-
- "File written successfully" or any tool success/error boilerplate
95-
- Common tool output patterns that are identical across calls
96-
97-
Instead, combine the generic output with surrounding unique context (a file path, a specific code snippet, or your own unique assistant text).
86+
Rules:
9887

99-
Each boundary string you choose MUST be unique to ONE specific message. Before using a string, ask: "Could this exact text appear in any other place in this conversation?" If yes, extend it or pick a different string.
88+
- Pick `startId` and `endId` directly from injected IDs in context.
89+
- IDs must exist in the current visible context.
90+
- `startId` must appear before `endId`.
91+
- Prefer boundaries that produce short, closed ranges.
10092

101-
WHERE TO NEVER PICK STRINGS FROM:
93+
ID SOURCES
10294

103-
- `<system-reminder>` tags or any XML wrapper/meta-commentary around messages
104-
- Injected system instructions (plan mode text, max-steps warnings, mode-switch text, environment info)
105-
- Reasoning parts or chain-of-thought text
106-
- File/directory listing framing text (e.g. "Called the Read tool with the following input...")
107-
- Strings that span across message or part boundaries
108-
- Entire serialized JSON objects (key ordering may differ - pick a distinctive substring within instead)
95+
- User messages include a text marker with their `mNNNN` ID.
96+
- Assistant messages include a `context_info` tool marker with their `mNNNN` ID.
97+
- Compressed blocks are addressable by `bN` IDs.
10998

110-
CRITICAL: AVOID USING TOOL INPUT VALUES
111-
NEVER use tool input schema keys or field names as boundary strings (e.g., "startString", "endString", "filePath", "content"). These may be transformed by the AI SDK and are not reliable. The ONLY acceptable use of tool input strings is a SINGLE concrete field VALUE (not the key), and even then, prefer using assistant text, user messages, or tool result outputs instead. When in doubt, choose boundaries from your own assistant responses or distinctive user message content.
99+
Do not invent IDs. Use only IDs that are present in context.
112100

113101
PARALLEL COMPRESS EXECUTION
114102
When multiple independent ranges are ready and their boundaries do not overlap, launch MULTIPLE `compress` calls in parallel in a single response. This is the PREFERRED pattern over a single large-range compression when the work can be safely split. Run compression sequentially only when ranges overlap or when a later range depends on the result of an earlier compression.
@@ -119,8 +107,8 @@ THE FORMAT OF COMPRESS
119107
{
120108
topic: string, // Short label (3-5 words) - e.g., "Auth System Exploration"
121109
content: {
122-
startString: string, // Unique text string marking the beginning of the range
123-
endString: string, // Unique text string marking the end of the range
110+
startId: string, // Boundary ID at range start: mNNNN or bN
111+
endId: string, // Boundary ID at range end: mNNNN or bN
124112
summary: string // Complete technical summary replacing all content in the range
125113
}
126114
}

lib/prompts/nudge.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,7 @@ Do not jump to a single broad range when the same cleanup can be done safely wit
3131

3232
If you are performing a critical atomic operation, do not interrupt it, but make sure to perform context management rapidly
3333

34-
BE VERY MINDFUL of the startString and endString you use for compression for RELIABLE boundary matching. NEVER use generic tool outputs like "Edit applied successfully." or generic status message as boundaries. Use unique assistant text or distinctive content instead with enough surrounding context to ensure uniqueness.
35-
36-
CRITICAL: AVOID USING TOOL INPUT VALUES AS BOUNDARIES
37-
NEVER use tool input schema keys or field names. The ONLY acceptable use of tool input strings is a SINGLE concrete field VALUE (not the key), and even then, prefer assistant text, user messages, or tool result outputs instead.
34+
Use injected boundary IDs for compression (`mNNNN` for messages, `bN` for compressed blocks). Pick IDs that are visible in context and ensure `startId` appears before `endId`.
3835

3936
Ensure your summaries are inclusive of all parts of the range.
4037
If the compressed range includes user messages, preserve user intent exactly. Prefer direct quotes for short user messages to avoid semantic drift.

lib/state/state.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ export function createSessionState(): SessionState {
7676
},
7777
toolParameters: new Map<string, ToolParameterEntry>(),
7878
toolIdList: [],
79+
messageIds: {
80+
byRawId: new Map<string, string>(),
81+
byRef: new Map<string, string>(),
82+
nextRef: 0,
83+
},
7984
lastCompaction: 0,
8085
currentTurn: 0,
8186
variant: undefined,
@@ -101,6 +106,11 @@ export function resetSessionState(state: SessionState): void {
101106
}
102107
state.toolParameters.clear()
103108
state.toolIdList = []
109+
state.messageIds = {
110+
byRawId: new Map<string, string>(),
111+
byRef: new Map<string, string>(),
112+
nextRef: 0,
113+
}
104114
state.lastCompaction = 0
105115
state.currentTurn = 0
106116
state.variant = undefined

lib/state/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export interface PendingManualTrigger {
3737
prompt: string
3838
}
3939

40+
export interface MessageIdState {
41+
byRawId: Map<string, string>
42+
byRef: Map<string, string>
43+
nextRef: number
44+
}
45+
4046
export interface SessionState {
4147
sessionId: string | null
4248
isSubAgent: boolean
@@ -48,6 +54,7 @@ export interface SessionState {
4854
stats: SessionStats
4955
toolParameters: Map<string, ToolParameterEntry>
5056
toolIdList: string[]
57+
messageIds: MessageIdState
5158
lastCompaction: number
5259
currentTurn: number
5360
variant: string | undefined

0 commit comments

Comments
 (0)