Skip to content

Commit bb1d33d

Browse files
committed
Merge branch 'dev' into refactor/one-tool-to-rule-them-all
2 parents c75edaa + b9f0018 commit bb1d33d

10 files changed

Lines changed: 87 additions & 56 deletions

File tree

lib/hooks.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,7 @@ export function createChatMessageTransformHandler(
115115
purgeErrors(state, logger, config, output.messages)
116116

117117
prune(state, logger, config, output.messages)
118-
119118
insertCompressToolContext(state, config, logger, output.messages)
120-
121119
insertMessageIdContext(state, config, output.messages)
122120

123121
applyPendingManualTriggerPrompt(state, output.messages, logger)

lib/prompts/system.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@ OPERATING STANCE
88
Prefer short, closed, summary-safe ranges.
99
When multiple independent stale ranges exist, prefer several short compressions (in parallel when possible) over one large-range compression.
1010

11-
Use `compress` as steady housekeeping while you work.
11+
<compress>THE COMPRESS TOOL
12+
`compress` is a sledgehammer and should be used accordingly. It's purpose is to reduce whole part of the conversation to its essence and technical details in order to leave room for newer context. Your summary MUST be technical and specific enough to preserve FULL understanding of WHAT TRANSPIRED, such that NO AMBIGUITY remains about what was done, found, or decided. Your compress summary must be thorough and precise. `compress` will replace everything in the range you match, user and assistant messages, tool inputs and outputs. It is preferred to not compress preemptively, but rather wait for natural breakpoints in the conversation. Those breakpoints are to be infered from user messages. You WILL NOT compress based on thinking that you are done with the task, wait for conversation queues that the user has moved on from current phase. Use injected boundary IDs (`startId`/`endId`) to select ranges.
13+
14+
Injected boundary IDs are surfaced as XML tags in conversation context, e.g. `<dcp-message-id>m0001</dcp-message-id>` for message IDs and `<dcp-message-id>b3</dcp-message-id>` for compressed blocks. These IDs are internal boundary markers for `compress` only. Do not reference, explain, or surface these IDs in normal user-facing responses unless you are actively constructing a `compress` tool call.
1215

1316
CADENCE, SIGNALS, AND LATENCY
1417

1518
- No fixed threshold mandates compression
1619
- Prioritize closedness and independence over raw range size
1720
- Prefer smaller, regular compressions over infrequent massive compressions for better latency and summary quality
1821
- When multiple independent stale ranges are ready, batch compressions in parallel
22+
</compress>
1923

2024
BOUNDARY MATCHING
2125
`compress` uses inclusive ID boundaries via `content.startId` and `content.endId`. IDs are injected in context as message refs (`mNNNN`) and compressed block refs (`bN`).

lib/state/persistence.ts

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -114,22 +114,57 @@ export async function loadSessionState(
114114
}
115115

116116
if (Array.isArray(state.compressSummaries)) {
117-
const validSummaries = state.compressSummaries.filter(
118-
(s): s is CompressSummary =>
119-
s !== null &&
120-
typeof s === "object" &&
121-
typeof s.blockId === "number" &&
122-
typeof s.anchorMessageId === "string" &&
123-
typeof s.summary === "string",
124-
)
125-
if (validSummaries.length !== state.compressSummaries.length) {
117+
const migratedSummaries: CompressSummary[] = []
118+
let nextBlockId = 1
119+
120+
for (const entry of state.compressSummaries) {
121+
if (
122+
entry === null ||
123+
typeof entry !== "object" ||
124+
typeof entry.anchorMessageId !== "string" ||
125+
typeof entry.summary !== "string"
126+
) {
127+
continue
128+
}
129+
130+
const blockId =
131+
typeof entry.blockId === "number" && Number.isInteger(entry.blockId)
132+
? entry.blockId
133+
: nextBlockId
134+
migratedSummaries.push({
135+
blockId,
136+
anchorMessageId: entry.anchorMessageId,
137+
summary: entry.summary,
138+
})
139+
nextBlockId = Math.max(nextBlockId, blockId + 1)
140+
}
141+
142+
if (migratedSummaries.length !== state.compressSummaries.length) {
126143
logger.warn("Filtered out malformed compressSummaries entries", {
127144
sessionId: sessionId,
128145
original: state.compressSummaries.length,
129-
valid: validSummaries.length,
146+
valid: migratedSummaries.length,
147+
})
148+
}
149+
150+
const seenBlockIds = new Set<number>()
151+
const dedupedSummaries = migratedSummaries.filter((summary) => {
152+
if (seenBlockIds.has(summary.blockId)) {
153+
return false
154+
}
155+
seenBlockIds.add(summary.blockId)
156+
return true
157+
})
158+
159+
if (dedupedSummaries.length !== migratedSummaries.length) {
160+
logger.warn("Removed duplicate compress block IDs", {
161+
sessionId: sessionId,
162+
original: migratedSummaries.length,
163+
valid: dedupedSummaries.length,
130164
})
131165
}
132-
state.compressSummaries = validSummaries
166+
167+
state.compressSummaries = dedupedSummaries
133168
} else {
134169
state.compressSummaries = []
135170
}

lib/state/state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export function createSessionState(): SessionState {
8383
byRef: new Map<string, string>(),
8484
nextRef: 0,
8585
},
86+
8687
lastCompaction: 0,
8788
currentTurn: 0,
8889
variant: undefined,
@@ -114,6 +115,7 @@ export function resetSessionState(state: SessionState): void {
114115
byRef: new Map<string, string>(),
115116
nextRef: 0,
116117
}
118+
117119
state.lastCompaction = 0
118120
state.currentTurn = 0
119121
state.variant = undefined

lib/state/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface SessionState {
5656
toolParameters: Map<string, ToolParameterEntry>
5757
toolIdList: string[]
5858
messageIds: MessageIdState
59+
5960
lastCompaction: number
6061
currentTurn: number
6162
variant: string | undefined

lib/state/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ export function resetOnCompaction(state: SessionState): void {
9090
state.prune.tools = new Map<string, number>()
9191
state.prune.messages = new Map<string, number>()
9292
state.compressSummaries = []
93+
state.messageIds = {
94+
byRawId: new Map<string, string>(),
95+
byRef: new Map<string, string>(),
96+
nextRef: 0,
97+
}
9398
state.contextLimitAnchors = new Set<string>()
9499
state.softNudgeAnchors = new Set<string>()
95100
}

lib/tools/compress-utils.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,22 +144,21 @@ export function resolveBoundaryIds(
144144
throwCombinedIssues(issues)
145145
}
146146

147-
if (!parsedStartId || !parsedEndId) {
148-
throw new Error("Invalid boundary ID(s)")
149-
}
147+
const validStartId = parsedStartId as NonNullable<typeof parsedStartId>
148+
const validEndId = parsedEndId as NonNullable<typeof parsedEndId>
150149

151-
const startReference = lookup.get(parsedStartId.ref)
152-
const endReference = lookup.get(parsedEndId.ref)
150+
const startReference = lookup.get(validStartId.ref)
151+
const endReference = lookup.get(validEndId.ref)
153152

154153
if (!startReference) {
155154
issues.push(
156-
`startId ${parsedStartId.ref} is not available in the current conversation context. Choose an injected ID visible in context.`,
155+
`startId ${validStartId.ref} is not available in the current conversation context. Choose an injected ID visible in context.`,
157156
)
158157
}
159158

160159
if (!endReference) {
161160
issues.push(
162-
`endId ${parsedEndId.ref} is not available in the current conversation context. Choose an injected ID visible in context.`,
161+
`endId ${validEndId.ref} is not available in the current conversation context. Choose an injected ID visible in context.`,
163162
)
164163
}
165164

@@ -173,7 +172,7 @@ export function resolveBoundaryIds(
173172

174173
if (startReference.rawIndex > endReference.rawIndex) {
175174
throw new Error(
176-
`startId ${parsedStartId.ref} appears after endId ${parsedEndId.ref} in the conversation. Start must come before end.`,
175+
`startId ${validStartId.ref} appears after endId ${validEndId.ref} in the conversation. Start must come before end.`,
177176
)
178177
}
179178

lib/tools/compress.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { tool } from "@opencode-ai/plugin"
2+
import type { BoundaryReference } from "./compress-utils"
23
import type { ToolContext } from "./types"
34
import { COMPRESS_TOOL_SPEC } from "../prompts"
45
import { ensureSessionInitialized } from "../state"
@@ -22,6 +23,7 @@ import {
2223
import { getCurrentParams, getCurrentTokenUsage } from "../strategies/utils"
2324
import { saveSessionState } from "../state/persistence"
2425
import { sendCompressNotification } from "../ui/notification"
26+
import { assignMessageRefs } from "../message-ids"
2527

2628
export function createCompressTool(ctx: ToolContext): ReturnType<typeof tool> {
2729
return tool({
@@ -72,6 +74,8 @@ export function createCompressTool(ctx: ToolContext): ReturnType<typeof tool> {
7274
ctx.config.manualMode.enabled,
7375
)
7476

77+
assignMessageRefs(ctx.state, rawMessages)
78+
7579
const searchContext = buildSearchContext(ctx.state, rawMessages)
7680

7781
const { startReference, endReference } = resolveBoundaryIds(
@@ -142,3 +146,17 @@ export function createCompressTool(ctx: ToolContext): ReturnType<typeof tool> {
142146
},
143147
})
144148
}
149+
150+
function getBoundaryMessageId(reference: BoundaryReference): string {
151+
if (reference.kind === "message") {
152+
if (!reference.messageId) {
153+
throw new Error("Failed to map boundary matches back to raw messages")
154+
}
155+
return reference.messageId
156+
}
157+
158+
if (!reference.anchorMessageId) {
159+
throw new Error("Failed to map boundary matches back to raw messages")
160+
}
161+
return reference.anchorMessageId
162+
}

package-lock.json

Lines changed: 2 additions & 32 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://json.schemastore.org/package.json",
33
"name": "@tarquinen/opencode-dcp",
4-
"version": "2.1.3",
4+
"version": "2.1.6",
55
"type": "module",
66
"description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context",
77
"main": "./dist/index.js",
@@ -44,7 +44,6 @@
4444
"dependencies": {
4545
"@anthropic-ai/tokenizer": "^0.0.4",
4646
"@opencode-ai/sdk": "^1.1.48",
47-
"fuzzball": "^2.2.3",
4847
"jsonc-parser": "^3.3.1",
4948
"zod": "^4.3.6"
5049
},

0 commit comments

Comments
 (0)