Skip to content

Commit 7b0dfea

Browse files
authored
Merge pull request #392 from Opencode-DCP/feat/compress-block-placeholders
Feat/compress block placeholders
2 parents 80c8fd0 + eddf269 commit 7b0dfea

20 files changed

+1680
-1118
lines changed

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ Thumbs.db
2828
# OpenCode
2929
.opencode/
3030

31+
# Python cache
32+
__pycache__/
33+
*.py[cod]
34+
*$py.class
35+
3136
# Generated prompt files (from scripts/generate-prompts.ts)
3237
lib/prompts/**/*.generated.ts
3338

@@ -40,4 +45,4 @@ test-update.ts
4045
docs/
4146
SCHEMA_NOTES.md
4247

43-
repomix-output.xml
48+
repomix-output.xml

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: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,29 @@ USER INTENT FIDELITY
3131
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.
3232
Directly quote user messages when they are short enough to include safely. Direct quotes are preferred when they best preserve exact meaning.
3333

34+
COMPRESSED BLOCK PLACEHOLDERS
35+
When the selected range includes previously compressed blocks, use this exact placeholder format when referencing one:
36+
37+
- `{block_N}`
38+
39+
Rules:
40+
41+
- Include every required block placeholder exactly once.
42+
- Do not invent placeholders for blocks outside the selected range.
43+
- 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 bN` (without curly braces).
45+
- Preflight check before finalizing: the set of `{block_N}` placeholders in your summary must exactly match the required set, with no duplicates.
46+
47+
These placeholders are semantic references. They will be replaced with the full stored compressed block content when the tool processes your output.
48+
49+
FLOW PRESERVATION WITH PLACEHOLDERS
50+
When you use compressed block placeholders, write the surrounding summary text so it still reads correctly AFTER placeholder expansion.
51+
52+
- Treat each placeholder as a stand-in for a full conversation segment, not as a short label.
53+
- Ensure transitions before and after each placeholder preserve chronology and causality.
54+
- Do not write text that depends on the placeholder staying literal (for example, "as noted in {block_2}").
55+
- Your final meaning must be coherent once each placeholder is replaced with its full compressed block content.
56+
3457
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.
3558

3659
THE WAYS OF COMPRESS
@@ -43,7 +66,7 @@ Exploration exhausted and patterns understood
4366
Compress smaller ranges when:
4467
You need to discard dead-end noise without waiting for a whole chapter to close
4568
You need to preserve key findings from a narrow slice while freeing context quickly
46-
You can bound a stale range cleanly with unique boundaries
69+
You can bound a stale range cleanly with injected IDs
4770

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

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

55-
BOUNDARY MATCHING
56-
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.
57-
58-
WHERE TO PICK STRINGS FROM (important for reliable matching):
59-
60-
- Your own assistant text responses (MOST RELIABLE - always stored verbatim)
61-
- The user's own words in their messages
62-
- Tool result output text (distinctive substrings within the output)
63-
- Previous compress summaries
64-
- Tool input string values (LEAST RELIABLE - only single concrete field values, not keys or schema fields, may be transformed by AI SDK)
65-
66-
NEVER USE GENERIC OR REPEATING STRINGS:
78+
BOUNDARY IDS
79+
You specify boundaries by ID
6780

68-
Tool status messages repeat identically across every invocation. These are ALWAYS ambiguous:
81+
Use the injected IDs visible in the conversation:
6982

70-
- "Edit applied successfully." (appears in EVERY successful edit)
71-
- "File written successfully" or any tool success/error boilerplate
72-
- Common tool output patterns that are identical across calls
83+
- `mNNNN` IDs identify raw messages
84+
- `bN` IDs identify previously compressed blocks
7385

74-
Instead, combine the generic output with surrounding unique context (a file path, a specific code snippet, or your own unique assistant text).
86+
Rules:
7587

76-
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.
7792

78-
WHERE TO NEVER PICK STRINGS FROM:
93+
ID SOURCES
7994

80-
- `<system-reminder>` tags or any XML wrapper/meta-commentary around messages
81-
- Injected system instructions (plan mode text, max-steps warnings, mode-switch text, environment info)
82-
- Reasoning parts or chain-of-thought text
83-
- File/directory listing framing text (e.g. "Called the Read tool with the following input...")
84-
- Strings that span across message or part boundaries
85-
- 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.
8698

87-
CRITICAL: AVOID USING TOOL INPUT VALUES
88-
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.
89100

90101
PARALLEL COMPRESS EXECUTION
91102
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.
@@ -96,8 +107,8 @@ THE FORMAT OF COMPRESS
96107
{
97108
topic: string, // Short label (3-5 words) - e.g., "Auth System Exploration"
98109
content: {
99-
startString: string, // Unique text string marking the beginning of the range
100-
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
101112
summary: string // Complete technical summary replacing all content in the range
102113
}
103114
}

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/persistence.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export async function loadSessionState(
116116
(s): s is CompressSummary =>
117117
s !== null &&
118118
typeof s === "object" &&
119+
typeof s.blockId === "number" &&
119120
typeof s.anchorMessageId === "string" &&
120121
typeof s.summary === "string",
121122
)

0 commit comments

Comments
 (0)