@serkanalgur/opencodev2-slim 2.0.12 → 2.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -141,6 +141,13 @@ Note: Compression is performed by the AI assistant using the `compress` tool. Th
141
141
 
142
142
  ## Changelog
143
143
 
144
+ ### 2.0.13
145
+
146
+ - Fix `/panel` in the TUI not reflecting real context usage:
147
+ - The TUI command now reads live server measurements (`Session.Info.tokens` + `cost` + `model` + context window) via `context.client.session.get()` and prints them (measured tokens, %, cost, model) at the bottom of the panel, matching what the `panel` tool reports.
148
+ - Resolve the active session from `context.ui.router.current()` instead of a non-existent `context.router`, so the panel targets the focused session rather than always the first one.
149
+ - Call `context.data.session.message.sync()` before reading the transcript so stats aren't computed from an empty/stale cache.
150
+
144
151
  ### 2.0.12
145
152
 
146
153
  - Bind panel/nudge to real OpenCode context measurements:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serkanalgur/opencodev2-slim",
3
- "version": "2.0.12",
3
+ "version": "2.0.14",
4
4
  "description": "Smart context management plugin for OpenCode v2 - semantic compression, cost-aware pruning, adaptive thresholds",
5
5
  "keywords": [
6
6
  "opencode",
package/src/index.ts CHANGED
@@ -1,15 +1,25 @@
1
1
  import { Plugin } from "@opencode/plugin"
2
- import { loadConfig, createDefaultConfig, resolveTokenLimit } from "./lib/config"
3
2
  import {
4
- loadSessionState,
5
- saveSessionState,
6
- addCompressionRecord,
7
- } from "./lib/state"
8
- import { countTokens, shouldCompress, getMessageText, getToolResultContent } from "./lib/compress"
9
- import { pruneMessages } from "./lib/strategies"
10
- import { getSystemPrompt, getCompressToolDescription, getNudgeMessage } from "./lib/prompts"
3
+ loadConfig,
4
+ createDefaultConfig,
5
+ resolveTokenLimit,
6
+ resolveCompressLimits,
7
+ } from "./lib/config"
8
+ import { loadSessionState, saveSessionState, addCompressionRecord } from "./lib/state"
9
+ import { countTokens, getMessageText, getToolResultContent } from "./lib/compress"
10
+ import {
11
+ syncCompressionBlocks,
12
+ applyCompressedRanges,
13
+ registerCompressionBlock,
14
+ buildCompressionSummary,
15
+ purgeStaleToolErrors,
16
+ pruneInPlace,
17
+ injectLimitNudges,
18
+ findLastUserMessage,
19
+ } from "./lib/strategies"
20
+ import { getSystemPrompt, getCompressToolDescription } from "./lib/prompts"
11
21
  import { buildPanelData, renderPanel } from "./lib/tui"
12
- import type { SlimConfig, SessionState, MessageWithParts } from "./lib/types"
22
+ import type { SlimConfig, SessionState, MessageWithParts, CompressionBlock } from "./lib/types"
13
23
 
14
24
  // ─── State Management ───────────────────────────────────────────────────────
15
25
 
@@ -58,70 +68,59 @@ function stringifyTranscript(v: unknown): string {
58
68
 
59
69
  // ─── Helpers ────────────────────────────────────────────────────────────────
60
70
 
61
- function buildCompressionSummary(messages: MessageWithParts[], focus: string): string {
62
- const lines: string[] = []
63
- lines.push(`## Compression Summary`)
64
- lines.push(`Focus: ${focus}`)
65
- lines.push(`Messages compressed: ${messages.length}`)
66
- lines.push("")
67
-
68
- const toolCalls: string[] = []
69
- const errors: string[] = []
70
- const decisions: string[] = []
71
-
72
- for (const msg of messages) {
73
- for (const part of msg.parts) {
74
- if (part.type === "tool-call") {
75
- const toolPart = part as any
76
- toolCalls.push(
77
- `${toolPart.name}: ${JSON.stringify(toolPart.input || {}).slice(0, 100)}`,
78
- )
79
- }
80
- if (part.type === "tool-result") {
81
- const toolPart = part as any
82
- if (toolPart.result?.type === "error") {
83
- errors.push(String(toolPart.result.value).slice(0, 200) || "Unknown error")
84
- }
85
- }
86
- if (part.type === "text") {
87
- const textPart = part as any
88
- const text = textPart.text || ""
89
- if (
90
- text.includes("decided") ||
91
- text.includes("chose") ||
92
- text.includes("implemented")
93
- ) {
94
- decisions.push(text.slice(0, 200))
95
- }
96
- }
97
- }
71
+ // Register a DCP-style compression block for the selected range. The range is
72
+ // covered (removed from future outgoing requests) and the summary is injected
73
+ // at the anchor: the first message after the range, or the latest message when
74
+ // the range reaches the end (the active user turn is never replaced).
75
+ function registerBlockForRange(
76
+ state: SessionState,
77
+ topic: string,
78
+ messageWithParts: MessageWithParts[],
79
+ targetIndices: number[],
80
+ summary: string,
81
+ summaryTokens: number,
82
+ ): CompressionBlock | null {
83
+ if (targetIndices.length === 0 || messageWithParts.length === 0) return null
84
+
85
+ const sorted = [...targetIndices].sort((a, b) => a - b)
86
+ const coveredIndices = new Set(sorted)
87
+
88
+ let anchorIndex = sorted[sorted.length - 1] + 1
89
+ if (anchorIndex >= messageWithParts.length) {
90
+ anchorIndex = messageWithParts.length - 1
91
+ coveredIndices.delete(anchorIndex)
98
92
  }
99
-
100
- if (toolCalls.length > 0) {
101
- lines.push("### Tool Calls")
102
- toolCalls.slice(0, 10).forEach((tc) => lines.push(`- ${tc}`))
103
- lines.push("")
104
- }
105
-
106
- if (errors.length > 0) {
107
- lines.push("### Errors Encountered")
108
- errors.slice(0, 5).forEach((e) => lines.push(`- ${e}`))
109
- lines.push("")
110
- }
111
-
112
- if (decisions.length > 0) {
113
- lines.push("### Key Decisions")
114
- decisions.slice(0, 5).forEach((d) => lines.push(`- ${d}`))
115
- lines.push("")
116
- }
117
-
118
- return lines.join("\n")
93
+ if (anchorIndex < 0 || anchorIndex >= messageWithParts.length) return null
94
+
95
+ const anchorId = messageWithParts[anchorIndex].info?.id
96
+ if (!anchorId) return null
97
+
98
+ const coveredIds = [...coveredIndices]
99
+ .map((i) => messageWithParts[i].info?.id)
100
+ .filter((id): id is string => typeof id === "string" && id.length > 0)
101
+ if (coveredIds.length === 0) return null
102
+
103
+ return registerCompressionBlock(state, {
104
+ coveredIds,
105
+ anchorMessageId: anchorId,
106
+ summary,
107
+ topic,
108
+ summaryTokens,
109
+ })
119
110
  }
120
111
 
121
112
  function wrapAsMessageWithParts(msg: any): MessageWithParts {
113
+ const msgInfo = msg.info
114
+ const id = (msg && (msg.id || msgInfo?.id)) || ""
115
+ const role = (msg && (msg.role || msgInfo?.role)) || "user"
122
116
  return {
123
- info: msg.info || { id: msg.id || "", role: msg.role, sessionID: "", time: { created: Date.now() } },
124
- parts: msg.parts || msg.content || [],
117
+ info: {
118
+ id,
119
+ role,
120
+ sessionID: (msg && (msg.sessionID || msgInfo?.sessionID)) || "",
121
+ time: { created: Date.now() },
122
+ } as any,
123
+ parts: (msg && (msg.parts || msg.content)) || [],
125
124
  }
126
125
  }
127
126
 
@@ -241,10 +240,34 @@ export default Plugin.define({
241
240
  }
242
241
 
243
242
  const targetMessages = targetIndices.map((i) => messageWithParts[i])
244
- const summary = buildCompressionSummary(targetMessages, args.focus)
243
+ const summary = await buildCompressionSummary(
244
+ targetMessages,
245
+ args.focus,
246
+ config.compress.protectedTools,
247
+ config.compress.protectUserMessages,
248
+ )
245
249
  const outputTokens = await countTokens(summary)
246
250
  const ratio = inputTokens > 0 ? 1 - outputTokens / inputTokens : 0
247
251
 
252
+ // DCP: register a compression block so future outgoing
253
+ // requests replace this range with the summary.
254
+ let blockNote = ""
255
+ try {
256
+ const block = registerBlockForRange(
257
+ state,
258
+ args.focus,
259
+ messageWithParts,
260
+ targetIndices,
261
+ summary,
262
+ outputTokens,
263
+ )
264
+ if (block) {
265
+ blockNote = `\n\n_Block #${block.blockId}: ${block.coveredMessageIds.length} messages will collapse into this summary on future requests (${Math.round((1 - outputTokens / Math.max(1, inputTokens)) * 100)}% smaller)._\n_To restore them: ask to reset context._`
266
+ }
267
+ } catch {
268
+ // Best-effort: the summary is still returned to the model.
269
+ }
270
+
248
271
  addCompressionRecord(
249
272
  state,
250
273
  {
@@ -261,7 +284,7 @@ export default Plugin.define({
261
284
  saveSessionState(state, config.persistence.directory)
262
285
 
263
286
  return {
264
- content: `## Compressed ${targetMessages.length} messages\n\n${summary}\n\n---\n**Stats:** ${inputTokens} → ${outputTokens} tokens (${Math.round(ratio * 100)}% saved) | Mode: ${mode} | Focus: ${args.focus}`,
287
+ content: `## Compressed ${targetMessages.length} messages\n\n${summary}\n\n---\n**Stats:** ${inputTokens} → ${outputTokens} tokens (${Math.round(ratio * 100)}% saved) | Mode: ${mode} | Focus: ${args.focus}${blockNote}`,
265
288
  }
266
289
  } catch (error) {
267
290
  return {
@@ -363,6 +386,10 @@ export default Plugin.define({
363
386
  })
364
387
 
365
388
  // ─── Messages Transform Hook (sync) ──────────────────────────────
389
+ // DCP pipeline for every outgoing request: sync compression blocks,
390
+ // replace covered ranges with summary placeholders, prune (dedup +
391
+ // purge errored tool inputs), then apply DCP limit rules as anchored
392
+ // nudges. Session history is never modified — only this request.
366
393
  await ctx.session.hook("context", (event) => {
367
394
  const sessionId = event.sessionID
368
395
  const config = getConfig(sessionId)
@@ -371,72 +398,49 @@ export default Plugin.define({
371
398
  const state = getState(sessionId, config)
372
399
  state.modelContextLimit = sessionModelLimits.get(sessionId) || initialModelLimit
373
400
 
374
- // Apply pruning - work with original OpenCode message format
375
- // event.messages contains { role, content: Part[], ... } objects
376
- const wrapped = event.messages.map((m: any) => wrapAsMessageWithParts(m))
377
- const pruned = pruneMessages(wrapped, config, event.messages.length)
378
-
379
- // Build a Set of pruned message IDs to keep
380
- const keepIds = new Set(pruned.map((m) => m.info.id))
381
-
382
- // Remove duplicates in-place, preserving OpenCode's message format
383
- for (let i = event.messages.length - 1; i >= 0; i--) {
384
- const msg = event.messages[i] as any
385
- const id = msg.id || msg.info?.id
386
- if (id && !keepIds.has(id)) {
387
- event.messages.splice(i, 1)
388
- }
401
+ // 1) Compression blocks: activate/deactivate and replace ranges.
402
+ const presentIds = new Set<string>()
403
+ for (const msg of event.messages) {
404
+ const id = (msg as any)?.id ?? (msg as any)?.info?.id
405
+ if (typeof id === "string") presentIds.add(id)
406
+ }
407
+ syncCompressionBlocks(state, presentIds)
408
+ const filtered = applyCompressedRanges(state, event.messages)
409
+ event.messages.splice(0, event.messages.length, ...filtered)
410
+
411
+ // 2) Pruning strategies (each request).
412
+ pruneInPlace(event.messages, config)
413
+ if (config.strategies.purgeErrors.enabled) {
414
+ purgeStaleToolErrors(event.messages, config.strategies.purgeErrors.turns)
389
415
  }
390
416
 
391
- // Quick token estimate (sync, ~4 chars per token). On the first pass
392
- // (before the panel has written real numbers into state) this is a
393
- // fallback; afterwards we prefer the server-measured value.
417
+ // 3) Token accounting: prefer the server-measured count; fall back
418
+ // to a quick estimate (~4 chars per token).
394
419
  let estimatedTokens = 0
395
420
  for (const msg of event.messages) {
396
- const content = (msg as any).content
421
+ const content = (msg as any)?.content ?? (msg as any)?.parts
397
422
  if (Array.isArray(content)) {
398
423
  for (const part of content) {
399
- if (part.type === "text" && part.text) {
424
+ if (part?.type === "text" && part.text) {
400
425
  estimatedTokens += Math.ceil(part.text.length / 4)
401
426
  }
402
427
  }
403
428
  }
404
429
  }
405
-
406
- // Prefer the real measured token count when available; else the estimate.
407
430
  const totalTokens =
408
431
  state.currentTokenCount > 0 ? state.currentTokenCount : estimatedTokens
409
432
  state.currentTokenCount = totalTokens
410
433
 
411
- const maxTokens = resolveTokenLimit(
412
- config.compress.maxContextLimit,
413
- state.modelContextLimit,
414
- )
415
- const minTokens = resolveTokenLimit(
416
- config.compress.minContextLimit,
417
- state.modelContextLimit,
418
- )
419
-
420
- const shouldComp = shouldCompress(
421
- totalTokens,
422
- maxTokens,
423
- minTokens,
424
- state.lastCompressionTime,
425
- config.compress.nudgeFrequency,
426
- event.messages.length,
427
- )
428
-
429
- if (shouldComp.compress && !state.manualMode) {
430
- const nudgeMessage = getNudgeMessage(
431
- shouldComp.reason,
432
- totalTokens,
433
- maxTokens,
434
- )
435
- event.messages.push({
436
- role: "assistant",
437
- content: [{ type: "text", text: nudgeMessage }],
438
- } as any)
439
- }
434
+ // 4) DCP limit rules → anchored nudges (max 100k / min 50k by
435
+ // default, model overrides supported via modelMax/MinLimits).
436
+ const lastUser = findLastUserMessage(event.messages)
437
+ const providerId =
438
+ lastUser?.model?.providerID ?? lastUser?.model?.id?.split?.("/")[0]
439
+ const modelId =
440
+ lastUser?.model?.modelID ??
441
+ lastUser?.model?.id?.split?.("/").slice(1).join("/")
442
+ const limits = resolveCompressLimits(config, state, providerId, modelId)
443
+ injectLimitNudges(state, config, event.messages, totalTokens, limits)
440
444
 
441
445
  saveSessionState(state, config.persistence.directory)
442
446
  })
package/src/lib/config.ts CHANGED
@@ -2,17 +2,21 @@ import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs"
2
2
  import { join } from "path"
3
3
  import { homedir } from "os"
4
4
  import { parse } from "jsonc-parser/lib/esm/main.js"
5
- import type { SlimConfig } from "./types"
5
+ import type { SlimConfig, SessionState } from "./types"
6
6
 
7
7
  const DEFAULT_CONFIG: SlimConfig = {
8
8
  enabled: true,
9
9
  debug: false,
10
10
  compress: {
11
11
  enabled: true,
12
+ mode: "range",
12
13
  permission: "allow",
13
- maxContextLimit: "80%",
14
- minContextLimit: "40%",
14
+ // DCP defaults: absolute token counts (not percentages).
15
+ maxContextLimit: 100000,
16
+ minContextLimit: 50000,
15
17
  nudgeFrequency: 5,
18
+ iterationNudgeThreshold: 15,
19
+ nudgeForce: "soft",
16
20
  protectUserMessages: false,
17
21
  protectedTools: ["task", "skill", "todowrite", "todoread"],
18
22
  },
@@ -46,7 +50,12 @@ function deepMerge(base: SlimConfig, override: Partial<SlimConfig>): SlimConfig
46
50
  return {
47
51
  ...base,
48
52
  ...override,
49
- compress: { ...base.compress, ...override.compress },
53
+ compress: {
54
+ ...base.compress,
55
+ ...override.compress,
56
+ modelMaxLimits: override.compress?.modelMaxLimits ?? base.compress.modelMaxLimits,
57
+ modelMinLimits: override.compress?.modelMinLimits ?? base.compress.modelMinLimits,
58
+ },
50
59
  strategies: {
51
60
  deduplication: { ...base.strategies.deduplication, ...override.strategies?.deduplication },
52
61
  purgeErrors: { ...base.strategies.purgeErrors, ...override.strategies?.purgeErrors },
@@ -101,14 +110,17 @@ export function createDefaultConfig(): void {
101
110
  writeFileSync(
102
111
  configPath,
103
112
  `{
104
- // Slim Configuration
113
+ // Slim Configuration (DCP-compatible limit rules)
105
114
  "enabled": true,
106
115
  "compress": {
107
116
  "enabled": true,
117
+ "mode": "range",
108
118
  "permission": "allow",
109
- "maxContextLimit": "80%",
110
- "minContextLimit": "40%",
111
- "nudgeFrequency": 5
119
+ "maxContextLimit": 100000,
120
+ "minContextLimit": 50000,
121
+ "nudgeFrequency": 5,
122
+ "iterationNudgeThreshold": 15,
123
+ "nudgeForce": "soft"
112
124
  }
113
125
  }`,
114
126
  "utf-8",
@@ -124,3 +136,39 @@ export function resolveTokenLimit(value: number | string, contextLimit: number):
124
136
  const percent = parseFloat(value.replace("%", "")) / 100
125
137
  return Math.floor(contextLimit * percent)
126
138
  }
139
+
140
+ /**
141
+ * DCP limit resolution. Prefers per-model overrides (compress.modelMinLimits /
142
+ * compress.modelMaxLimits keyed by "providerId/modelId"), then falls back to the
143
+ * global max/min limit. Percent strings resolve against the model's context window.
144
+ */
145
+ export function resolveCompressLimits(
146
+ config: SlimConfig,
147
+ state: SessionState,
148
+ providerId?: string,
149
+ modelId?: string,
150
+ ): { max: number; min: number } {
151
+ const parseLimit = (value: number | string | undefined): number => {
152
+ if (value === undefined) {
153
+ return 0
154
+ }
155
+ if (typeof value === "number") {
156
+ return value
157
+ }
158
+ const pct = parseFloat(value.replace("%", ""))
159
+ if (Number.isNaN(pct)) {
160
+ return 0
161
+ }
162
+ return Math.round((Math.max(0, Math.min(100, pct)) / 100) * state.modelContextLimit)
163
+ }
164
+
165
+ const providerModel = providerId && modelId ? `${providerId}/${modelId}` : undefined
166
+
167
+ const modelMin = providerModel ? config.compress.modelMinLimits?.[providerModel] : undefined
168
+ const modelMax = providerModel ? config.compress.modelMaxLimits?.[providerModel] : undefined
169
+
170
+ return {
171
+ max: parseLimit(modelMax ?? config.compress.maxContextLimit),
172
+ min: parseLimit(modelMin ?? config.compress.minContextLimit),
173
+ }
174
+ }
@@ -6,12 +6,17 @@ You have access to context management tools. Use them wisely:
6
6
 
7
7
  ### compress tool
8
8
  Use \`compress\` to reduce context size when it gets large. It supports:
9
+ - Range mode: Compress a specific message range (start/end indices) into one summary
10
+ - Topic mode: Compress messages matching a topic keyword
9
11
  - Auto mode: Intelligently selects what to compress
10
- - Range mode: Compress specific message range
11
- - Topic mode: Compress messages matching a topic
12
12
 
13
13
  Example: \`compress({ focus: "old exploration" })\`
14
14
 
15
+ Compressed ranges are replaced by their summary on outgoing requests, so the
16
+ model keeps the essential information while token usage drops on every
17
+ subsequent request. Protected tool results (task, skill, todowrite, todoread)
18
+ are preserved inside the summary.
19
+
15
20
  ### panel tool
16
21
  Use \`panel\` to view current context usage and statistics.
17
22
 
@@ -30,19 +35,30 @@ export function getCompressToolDescription(): string {
30
35
  - Range mode: Compress specific message range (start/end indices)
31
36
  - Topic mode: Compress messages matching a topic keyword
32
37
 
33
- The compression creates a summary preserving key information while removing redundancy.`
38
+ The compression creates a summary preserving key information (including
39
+ protected tool outputs) and replaces the selected messages with that summary on
40
+ future requests, so the context stays small.`
34
41
  }
35
42
 
36
- export function getNudgeMessage(reason: string, currentTokens: number, maxTokens: number): string {
37
- const percent = Math.round((currentTokens / maxTokens) * 100)
38
- return `💡 **Context Optimization Available**
43
+ // ─── DCP-style limit nudges ────────────────────────────────────────────────
44
+ //
45
+ // Every nudge carries a stable marker so the pipeline can detect an existing
46
+ // nudge regardless of its dynamic content (percentages change every request).
39
47
 
40
- ${reason} (${percent}% used)
48
+ export const NUDGE_MARKERS = {
49
+ contextLimit: "[[slim:context-limit]]",
50
+ turn: "[[slim:turn]]",
51
+ iteration: "[[slim:iteration]]",
52
+ } as const
41
53
 
42
- Consider using the \`compress\` tool to free up context space:
43
- \`\`\`
44
- compress({ focus: "describe what to compress" })
45
- \`\`\`
54
+ export function contextLimitNudge(percent: number, maxTokens: number): string {
55
+ return `${NUDGE_MARKERS.contextLimit}\n\n> ⚠️ **Context at capacity: ${percent}% of ${maxTokens.toLocaleString()} tokens.** Older completed work should be compressed now to keep the session efficient. Call \`compress\` with a focus describing the oldest exchange, e.g. \`compress({ focus: "initial exploration" })\`. Past ranges are replaced by summaries to avoid re-sending tokens.`
56
+ }
46
57
 
47
- This will create a summary of older messages, preserving key information while freeing tokens.`
58
+ export function turnNudge(percent: number): string {
59
+ return `${NUDGE_MARKERS.turn}\n\n> 💡 **Context is getting large (${percent}% of limit).** When you finish the current task, consider calling \`compress\` on the completed portion to keep the session fast and cheap.`
48
60
  }
61
+
62
+ export function iterationNudge(percent: number): string {
63
+ return `${NUDGE_MARKERS.iteration}\n\n> 💡 **Many tool iterations since the last user message (${percent}% of limit used).** If the explored subtree is done, call \`compress({ focus: "exploration so far" })\` to replace it with a summary.`
64
+ }
package/src/lib/state.ts CHANGED
@@ -13,6 +13,29 @@ const DEFAULT_STATE: SessionState = {
13
13
  compressionHistory: [],
14
14
  averageCompressionRatio: 0,
15
15
  toolCalls: new Map(),
16
+ compressionBlocks: [],
17
+ nextBlockId: 1,
18
+ nudges: { contextLimitAnchors: [], turnNudgeAnchors: [], iterationNudgeAnchors: [] },
19
+ }
20
+
21
+ export function normalizeState(state: SessionState): SessionState {
22
+ state.compressionBlocks = Array.isArray(state.compressionBlocks) ? state.compressionBlocks : []
23
+ state.nextBlockId =
24
+ typeof state.nextBlockId === "number" && state.nextBlockId > 0
25
+ ? state.nextBlockId
26
+ : state.compressionBlocks.reduce((max, b) => Math.max(max, b.blockId), 0) + 1
27
+ state.nudges = {
28
+ contextLimitAnchors: Array.isArray(state.nudges?.contextLimitAnchors)
29
+ ? state.nudges.contextLimitAnchors
30
+ : [],
31
+ turnNudgeAnchors: Array.isArray(state.nudges?.turnNudgeAnchors)
32
+ ? state.nudges.turnNudgeAnchors
33
+ : [],
34
+ iterationNudgeAnchors: Array.isArray(state.nudges?.iterationNudgeAnchors)
35
+ ? state.nudges.iterationNudgeAnchors
36
+ : [],
37
+ }
38
+ return state
16
39
  }
17
40
 
18
41
  export function loadSessionState(sessionId: string, persistenceDir: string): SessionState {
@@ -26,13 +49,13 @@ export function loadSessionState(sessionId: string, persistenceDir: string): Ses
26
49
  if (parsed.toolCalls && Array.isArray(parsed.toolCalls)) {
27
50
  parsed.toolCalls = new Map(parsed.toolCalls)
28
51
  }
29
- return { ...DEFAULT_STATE, ...parsed, sessionId }
52
+ return normalizeState({ ...DEFAULT_STATE, ...parsed, sessionId })
30
53
  } catch {
31
54
  // Use default
32
55
  }
33
56
  }
34
57
 
35
- return { ...DEFAULT_STATE, sessionId }
58
+ return normalizeState({ ...DEFAULT_STATE, sessionId })
36
59
  }
37
60
 
38
61
  export function saveSessionState(state: SessionState, persistenceDir: string): void {
@@ -1,8 +1,301 @@
1
- import type { MessageWithParts, SlimConfig } from "./types"
2
- import { getToolName, getToolResultContent, getMessageText } from "./compress"
3
- import { getDuplicateToolCalls, getErroredToolCalls } from "./state"
4
- import type { SessionState } from "./types"
1
+ import type { MessageWithParts, SlimConfig, SessionState, CompressionBlock } from "./types"
2
+ import { getToolName, getMessageText } from "./compress"
3
+ import { contextLimitNudge, turnNudge, iterationNudge, NUDGE_MARKERS } from "./prompts"
5
4
 
5
+ // ─── DCP-style Compression Blocks ───────────────────────────────────────────
6
+ //
7
+ // A compression block replaces a contiguous range of messages with a summary
8
+ // placeholder on every outgoing request. The summary is injected as a synthetic
9
+ // user message at the block's *anchor* message (the first message after the
10
+ // range, or the last message when the range reaches the end). The covered
11
+ // messages are removed from the outgoing request only — session history is
12
+ // never modified. Newer blocks "consume" older ones (nested compression).
13
+
14
+ /**
15
+ * Activates/deactivates blocks based on which messages are present in the
16
+ * current outgoing request. A block is active while both its origin message
17
+ * (compressMessageId) and its anchor message are still present. A newer active
18
+ * block deactivates any older block whose anchor falls inside its covered range.
19
+ */
20
+ export function syncCompressionBlocks(state: SessionState, presentIds: Set<string>): void {
21
+ const blocks = state.compressionBlocks ?? []
22
+ if (blocks.length === 0) return
23
+
24
+ for (const block of blocks) {
25
+ const hasOrigin =
26
+ block.compressMessageId.length > 0 ? presentIds.has(block.compressMessageId) : true
27
+ block.active = hasOrigin && presentIds.has(block.anchorMessageId)
28
+ }
29
+
30
+ // Nested consumption: newest active block wins over older blocks it covers,
31
+ // and inherits their covered messages so nothing resurfaces behind the
32
+ // newest summary. Loop until stable to handle chains (A -> B -> C).
33
+ const sorted = [...blocks].sort((a, b) => a.blockId - b.blockId)
34
+ let changed = true
35
+ while (changed) {
36
+ changed = false
37
+ for (const block of sorted) {
38
+ if (!block.active) continue
39
+ for (const older of sorted) {
40
+ if (older.blockId >= block.blockId || !older.active) continue
41
+ if (block.coveredMessageIds.includes(older.anchorMessageId)) {
42
+ older.active = false
43
+ for (const id of older.coveredMessageIds) {
44
+ if (!block.coveredMessageIds.includes(id)) {
45
+ block.coveredMessageIds.push(id)
46
+ changed = true
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ // Orphaned blocks: inactive and none of their referenced messages survive
55
+ // (e.g. after OpenCode compaction) — safe to forget, otherwise dead entries
56
+ // accumulate in persisted state forever.
57
+ const alive = blocks.filter((b) => {
58
+ if (b.active) return true
59
+ const refs = [b.anchorMessageId, b.compressMessageId, ...(b.coveredMessageIds ?? [])]
60
+ return refs.some((id) => typeof id === "string" && presentIds.has(id))
61
+ })
62
+ if (alive.length !== blocks.length) {
63
+ state.compressionBlocks = alive
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Produces the outgoing message list: active blocks inject their summary at the
69
+ * anchor and drop every covered message. Returns a new array; the caller should
70
+ * splice it back into the event.
71
+ */
72
+ export function applyCompressedRanges(state: SessionState, messages: any[]): any[] {
73
+ const blocks = (state.compressionBlocks ?? []).filter((b) => b.active)
74
+ if (blocks.length === 0 || messages.length === 0) return messages
75
+
76
+ const covered = new Set<string>()
77
+ const byAnchor = new Map<string, CompressionBlock>()
78
+ for (const block of blocks) {
79
+ for (const id of block.coveredMessageIds) covered.add(id)
80
+ byAnchor.set(block.anchorMessageId, block)
81
+ }
82
+
83
+ const result: any[] = []
84
+ for (const msg of messages) {
85
+ const id = (msg && (msg.id ?? msg.info?.id)) as string | undefined
86
+ if (typeof id === "string") {
87
+ const block = byAnchor.get(id)
88
+ if (block && block.summary) {
89
+ result.push({
90
+ role: "user",
91
+ id: `slim-summary-${block.blockId}`,
92
+ content: [{ type: "text", text: block.summary }],
93
+ })
94
+ }
95
+ if (covered.has(id)) {
96
+ continue
97
+ }
98
+ }
99
+ result.push(msg)
100
+ }
101
+ return result
102
+ }
103
+
104
+ export interface RegisterBlockOptions {
105
+ coveredIds: string[]
106
+ anchorMessageId: string
107
+ summary: string
108
+ topic: string
109
+ compressMessageId?: string
110
+ summaryTokens?: number
111
+ }
112
+
113
+ /**
114
+ * Registers a new compression block. Older active blocks whose anchor lies
115
+ * inside the new range are consumed (deactivated) so only the newest summary
116
+ * is injected — information survives through layers of compression.
117
+ */
118
+ export function registerCompressionBlock(
119
+ state: SessionState,
120
+ opts: RegisterBlockOptions,
121
+ ): CompressionBlock | null {
122
+ const blocks = state.compressionBlocks ?? []
123
+ const nextId =
124
+ state.nextBlockId ??
125
+ blocks.reduce((max, b) => Math.max(max, b.blockId), 0) + 1
126
+
127
+ const consumed = blocks
128
+ .filter((b) => b.active && opts.coveredIds.includes(b.anchorMessageId))
129
+ .map((b) => b.blockId)
130
+
131
+ const block: CompressionBlock = {
132
+ blockId: nextId,
133
+ topic: opts.topic,
134
+ summary: opts.summary,
135
+ anchorMessageId: opts.anchorMessageId,
136
+ compressMessageId: opts.compressMessageId ?? "",
137
+ coveredMessageIds: opts.coveredIds,
138
+ consumedBlockIds: consumed,
139
+ active: true,
140
+ createdAt: Date.now(),
141
+ summaryTokens: opts.summaryTokens ?? 0,
142
+ }
143
+
144
+ blocks.push(block)
145
+ state.compressionBlocks = blocks
146
+ state.nextBlockId = nextId + 1
147
+
148
+ for (const consumedId of consumed) {
149
+ const target = blocks.find((b) => b.blockId === consumedId)
150
+ if (target) {
151
+ target.active = false
152
+ // Inherit the consumed block's covered messages so they stay
153
+ // hidden behind the newer summary (nested compression).
154
+ for (const id of target.coveredMessageIds) {
155
+ if (!block.coveredMessageIds.includes(id)) {
156
+ block.coveredMessageIds.push(id)
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ return block
163
+ }
164
+
165
+ // ─── Summary building (with protected content) ─────────────────────────────
166
+
167
+ /**
168
+ * Builds the compression summary used as the placeholder. Protected tool
169
+ * outputs (task, skill, todowrite, todoread, ...) are appended verbatim so the
170
+ * most important information survives compression — DCP behaviour.
171
+ */
172
+ export async function buildCompressionSummary(
173
+ messages: MessageWithParts[],
174
+ focus: string,
175
+ protectedTools: string[],
176
+ protectUserMessages = false,
177
+ ): Promise<string> {
178
+ const lines: string[] = []
179
+ lines.push(`## Compression Summary`)
180
+ lines.push(`Focus: ${focus}`)
181
+ lines.push(`Messages compressed: ${messages.length}`)
182
+ lines.push("")
183
+
184
+ const toolCalls: string[] = []
185
+ const errors: string[] = []
186
+ const decisions: string[] = []
187
+
188
+ for (const msg of messages) {
189
+ for (const part of msg.parts) {
190
+ if (part.type === "tool-call") {
191
+ toolCalls.push(
192
+ `${part.name}: ${JSON.stringify(part.input || {}).slice(0, 100)}`,
193
+ )
194
+ }
195
+ if (part.type === "tool-result") {
196
+ if (part.result?.type === "error") {
197
+ errors.push(String(part.result.value).slice(0, 200) || "Unknown error")
198
+ }
199
+ }
200
+ if (part.type === "text") {
201
+ const text = part.text || ""
202
+ if (
203
+ text.includes("decided") ||
204
+ text.includes("chose") ||
205
+ text.includes("implemented")
206
+ ) {
207
+ decisions.push(text.slice(0, 200))
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ if (toolCalls.length > 0) {
214
+ lines.push("### Tool Calls")
215
+ toolCalls.slice(0, 10).forEach((tc) => lines.push(`- ${tc}`))
216
+ lines.push("")
217
+ }
218
+
219
+ if (errors.length > 0) {
220
+ lines.push("### Errors Encountered")
221
+ errors.slice(0, 5).forEach((e) => lines.push(`- ${e}`))
222
+ lines.push("")
223
+ }
224
+
225
+ if (decisions.length > 0) {
226
+ lines.push("### Key Decisions")
227
+ decisions.slice(0, 5).forEach((d) => lines.push(`- ${d}`))
228
+ lines.push("")
229
+ }
230
+
231
+ const protectedContent = collectProtectedToolOutputs(messages, protectedTools)
232
+ if (protectedContent.length > 0) {
233
+ lines.push("### Protected Tool Outputs")
234
+ lines.push(protectedContent)
235
+ lines.push("")
236
+ }
237
+
238
+ // DCP protectUserMessages: the user's own instructions survive compression
239
+ // verbatim inside the summary, so nothing the user asked is ever lost to a
240
+ // lossy paraphrase.
241
+ if (protectUserMessages) {
242
+ const userTexts: string[] = []
243
+ for (const msg of messages) {
244
+ if (msg.info.role !== "user") continue
245
+ const text = getMessageText(msg)
246
+ if (text.trim().length > 0) userTexts.push(text.trim())
247
+ }
248
+ if (userTexts.length > 0) {
249
+ lines.push("### User Messages (preserved verbatim)")
250
+ userTexts.forEach((t, i) => lines.push(`- [user ${i + 1}] ${t.slice(0, 2000)}`))
251
+ lines.push("")
252
+ }
253
+ }
254
+
255
+ return lines.join("\n")
256
+ }
257
+
258
+ function collectProtectedToolOutputs(
259
+ messages: MessageWithParts[],
260
+ protectedTools: string[],
261
+ ): string {
262
+ if (protectedTools.length === 0) return ""
263
+
264
+ const resultsByCallId = new Map<string, string>()
265
+ for (const msg of messages) {
266
+ for (const part of msg.parts) {
267
+ if (part.type !== "tool-result") continue
268
+ const callId = part.toolCallID ?? part.callID
269
+ if (!callId) continue
270
+ const val = part.result?.value ?? part.result
271
+ if (val !== undefined && val !== null && part.result?.type !== "error") {
272
+ resultsByCallId.set(String(callId), String(val))
273
+ }
274
+ }
275
+ }
276
+
277
+ const output: string[] = []
278
+ for (const msg of messages) {
279
+ for (const part of msg.parts) {
280
+ if (part.type !== "tool-call") continue
281
+ const name = part.name
282
+ if (!name || !protectedTools.includes(name)) continue
283
+ const input = JSON.stringify(part.input ?? {}).slice(0, 1000)
284
+ const callId = part.toolCallID ?? part.callID
285
+ const result = callId ? resultsByCallId.get(String(callId)) : undefined
286
+ output.push(
287
+ result
288
+ ? `- [${name}] input: ${input}\n output: ${result.slice(0, 2000)}`
289
+ : `- [${name}] input: ${input}`,
290
+ )
291
+ }
292
+ }
293
+ return output.join("\n")
294
+ }
295
+
296
+ // ─── Pruning: dedup + purge errored tool inputs ────────────────────────────
297
+
298
+ /** Kept for compatibility: pure deduplication over MessageWithParts. */
6
299
  export function pruneMessages(
7
300
  messages: MessageWithParts[],
8
301
  config: SlimConfig,
@@ -10,7 +303,6 @@ export function pruneMessages(
10
303
  ): MessageWithParts[] {
11
304
  let pruned = [...messages]
12
305
 
13
- // Apply deduplication
14
306
  if (config.strategies.deduplication.enabled) {
15
307
  pruned = applyDeduplication(pruned, config.strategies.deduplication.protectedTools)
16
308
  }
@@ -18,32 +310,302 @@ export function pruneMessages(
18
310
  return pruned
19
311
  }
20
312
 
21
- function applyDeduplication(messages: MessageWithParts[], protectedTools: string[]): MessageWithParts[] {
22
- const seen = new Map<string, number>()
313
+ export function applyDeduplication(
314
+ messages: MessageWithParts[],
315
+ protectedTools: string[],
316
+ ): MessageWithParts[] {
317
+ const seen = new Set<string>()
23
318
  const toRemove = new Set<number>()
24
319
 
25
320
  for (let i = 0; i < messages.length; i++) {
26
321
  const msg = messages[i]
27
322
  const toolName = getToolName(msg)
28
323
 
29
- // Skip protected tools
30
324
  if (toolName && protectedTools.includes(toolName)) {
31
325
  continue
32
326
  }
33
327
 
34
- // Create a fingerprint of the message
35
- const text = getMessageText(msg)
36
- const toolContent = getToolResultContent(msg)
37
- const fingerprint = `${msg.info.role}:${text.slice(0, 200)}:${toolContent.slice(0, 200)}`
38
-
39
- const existingIndex = seen.get(fingerprint)
40
- if (existingIndex !== undefined) {
41
- // Mark later duplicate for removal
328
+ // Exact full-content fingerprint: only identical messages are removed.
329
+ const fingerprint = `${msg.info.role}:${JSON.stringify(msg.parts)}`
330
+ if (seen.has(fingerprint)) {
42
331
  toRemove.add(i)
43
332
  } else {
44
- seen.set(fingerprint, i)
333
+ seen.add(fingerprint)
45
334
  }
46
335
  }
47
336
 
48
337
  return messages.filter((_, i) => !toRemove.has(i))
49
338
  }
339
+
340
+ /**
341
+ * DCP purge-errors: for tool calls whose result is an error, remove the large
342
+ * string inputs once the message is at least `turns` positions behind the end
343
+ * of the conversation. Error messages themselves are preserved.
344
+ */
345
+ export function purgeStaleToolErrors(messages: any[], turns: number): void {
346
+ const n = messages.length
347
+ if (n === 0) return
348
+
349
+ const erroredCallIds = new Set<string>()
350
+ for (const msg of messages) {
351
+ for (const part of msg?.content ?? msg?.parts ?? []) {
352
+ if (part?.type !== "tool-result") continue
353
+ if (part.result?.type !== "error") continue
354
+ const callId = part.toolCallID ?? part.callID
355
+ if (callId) erroredCallIds.add(String(callId))
356
+ }
357
+ }
358
+ if (erroredCallIds.size === 0) return
359
+
360
+ const turnsEffective = Math.max(1, Math.floor(turns) || 1)
361
+ for (let i = 0; i < n; i++) {
362
+ if (i > n - turnsEffective - 1) continue // too recent — keep
363
+ const msg = messages[i]
364
+ for (const part of msg?.content ?? msg?.parts ?? []) {
365
+ if (part?.type !== "tool-call") continue
366
+ const callId = part.toolCallID ?? part.callID
367
+ if (!callId || !erroredCallIds.has(String(callId))) continue
368
+ const input = part.input
369
+ if (input && typeof input === "object") {
370
+ for (const key of Object.keys(input)) {
371
+ if (typeof input[key] === "string" && input[key].length > 80) {
372
+ input[key] = "[input removed due to failed tool call]"
373
+ }
374
+ }
375
+ }
376
+ }
377
+ }
378
+ }
379
+
380
+ /** In-place dedup over raw outgoing messages; returns the keep count. */
381
+ export function pruneInPlace(messages: any[], config: SlimConfig): void {
382
+ if (!config.strategies.deduplication.enabled) return
383
+
384
+ const protectedTools = config.strategies.deduplication.protectedTools
385
+ const seen = new Set<string>()
386
+ const toRemove = new Set<number>()
387
+
388
+ for (let i = 0; i < messages.length; i++) {
389
+ const msg = messages[i] as any
390
+ const content = msg?.content ?? msg?.parts ?? []
391
+
392
+ // Protected tools (and messages carrying them) are never deduplicated.
393
+ let toolName: string | null = null
394
+ for (const part of content) {
395
+ if (part?.type === "tool-call") {
396
+ toolName = toolName ?? part.name ?? null
397
+ }
398
+ }
399
+ if (toolName && protectedTools.includes(toolName)) continue
400
+
401
+ // Exact full-content fingerprint — only truly identical messages are
402
+ // removed. Truncated fingerprints would eat distinct messages that share
403
+ // a common prefix.
404
+ const fingerprint = `${msg?.role}:${JSON.stringify(content)}`
405
+ if (seen.has(fingerprint)) {
406
+ toRemove.add(i)
407
+ } else {
408
+ seen.add(fingerprint)
409
+ }
410
+ }
411
+
412
+ if (toRemove.size === 0) return
413
+ const kept = messages.filter((_, i) => !toRemove.has(i))
414
+ messages.splice(0, messages.length, ...kept)
415
+ }
416
+
417
+ // ─── DCP limit rules → anchored nudges ─────────────────────────────────────
418
+
419
+ export function messageHasCompress(msg: any): boolean {
420
+ const content = msg?.content ?? msg?.parts ?? []
421
+ return content.some(
422
+ (part: any) => part?.type === "tool-call" && part?.name === "compress",
423
+ )
424
+ }
425
+
426
+ export function findLastUserMessage(messages: any[]): any | undefined {
427
+ for (let i = messages.length - 1; i >= 0; i--) {
428
+ if (messages[i]?.role === "user") return messages[i]
429
+ }
430
+ return undefined
431
+ }
432
+
433
+ function getNudgeFrequency(config: SlimConfig): number {
434
+ return Math.max(1, Math.floor(config.compress.nudgeFrequency || 1))
435
+ }
436
+
437
+ function getIterationThreshold(config: SlimConfig): number {
438
+ return Math.max(1, Math.floor(config.compress.iterationNudgeThreshold || 1))
439
+ }
440
+
441
+ function addAnchor(
442
+ anchors: string[],
443
+ messageId: string | undefined,
444
+ index: number,
445
+ messages: any[],
446
+ interval: number,
447
+ ): boolean {
448
+ if (!messageId || index < 0) return false
449
+
450
+ let latestAnchorIndex = -1
451
+ for (let i = messages.length - 1; i >= 0; i--) {
452
+ const m = messages[i] as any
453
+ const id = m?.id ?? m?.info?.id
454
+ if (typeof id === "string" && anchors.includes(id)) {
455
+ latestAnchorIndex = i
456
+ break
457
+ }
458
+ }
459
+
460
+ const shouldAdd = latestAnchorIndex < 0 || index - latestAnchorIndex >= interval
461
+ if (!shouldAdd) return false
462
+
463
+ if (!anchors.includes(messageId)) {
464
+ anchors.push(messageId)
465
+ return true
466
+ }
467
+ return false
468
+ }
469
+
470
+ function addSpecificAnchor(anchors: string[], messageId: string | undefined): void {
471
+ if (messageId && !anchors.includes(messageId)) {
472
+ anchors.push(messageId)
473
+ }
474
+ }
475
+
476
+ function messageHasNudge(msg: any, marker: string): boolean {
477
+ const content = msg?.content ?? msg?.parts ?? []
478
+ return content.some(
479
+ (part: any) => part?.type === "text" && typeof part.text === "string" && part.text.includes(marker),
480
+ )
481
+ }
482
+
483
+ function appendToMessage(msg: any, nudgeText: string): void {
484
+ const content = (msg?.content ?? msg?.parts ?? []) as any[]
485
+ for (const part of content) {
486
+ if (part?.type === "text") {
487
+ part.text = `${part.text}\n\n${nudgeText}`
488
+ return
489
+ }
490
+ }
491
+ content.push({ type: "text", text: nudgeText })
492
+ }
493
+
494
+ /**
495
+ * DCP limit rules: compare current usage against maxContextLimit /
496
+ * minContextLimit and anchor nudges so the model is pushed to compress at most
497
+ * once per nudgeFrequency messages. If the last assistant turn already ran the
498
+ * compress tool, all anchors are cleared.
499
+ */
500
+ export function injectLimitNudges(
501
+ state: SessionState,
502
+ config: SlimConfig,
503
+ messages: any[],
504
+ currentTokens: number,
505
+ limits: { max: number; min: number },
506
+ ): void {
507
+ if (config.compress.permission === "deny") return
508
+ if (state.manualMode) return
509
+ if (messages.length === 0) return
510
+
511
+ const nudges = state.nudges ?? {
512
+ contextLimitAnchors: [],
513
+ turnNudgeAnchors: [],
514
+ iterationNudgeAnchors: [],
515
+ }
516
+
517
+ const lastAssistant = [...messages].reverse().find((m) => (m as any)?.role === "assistant")
518
+ if (lastAssistant && messageHasCompress(lastAssistant)) {
519
+ nudges.contextLimitAnchors = []
520
+ nudges.turnNudgeAnchors = []
521
+ nudges.iterationNudgeAnchors = []
522
+ state.nudges = nudges
523
+ return
524
+ }
525
+
526
+ const overMax = limits.max > 0 && currentTokens > limits.max
527
+ const overMin = limits.min > 0 && currentTokens >= limits.min
528
+
529
+ if (!overMin) {
530
+ if (nudges.turnNudgeAnchors.length > 0 || nudges.iterationNudgeAnchors.length > 0) {
531
+ nudges.turnNudgeAnchors = []
532
+ nudges.iterationNudgeAnchors = []
533
+ }
534
+ }
535
+
536
+ const lastIndex = messages.length - 1
537
+ const lastMessage = messages[lastIndex] as any
538
+ const lastMessageId = lastMessage?.id ?? lastMessage?.info?.id
539
+
540
+ if (overMax) {
541
+ addAnchor(
542
+ nudges.contextLimitAnchors,
543
+ lastMessageId,
544
+ lastIndex,
545
+ messages,
546
+ getNudgeFrequency(config),
547
+ )
548
+ } else if (overMin) {
549
+ // Turn nudge: fire at a user/assistant turn boundary.
550
+ if (lastMessage?.role === "user" && lastAssistant) {
551
+ addSpecificAnchor(nudges.turnNudgeAnchors, lastMessageId)
552
+ const lastAssistantId = lastAssistant?.id ?? lastAssistant?.info?.id
553
+ addSpecificAnchor(nudges.turnNudgeAnchors, lastAssistantId)
554
+ }
555
+
556
+ // Iteration nudge: too many messages since the last user request.
557
+ const lastUserIndex = messages.findIndex((m) => (m as any)?.role === "user")
558
+ if (lastUserIndex >= 0 && lastIndex > lastUserIndex) {
559
+ const sinceUser = lastIndex - lastUserIndex
560
+ if (sinceUser >= getIterationThreshold(config)) {
561
+ addAnchor(
562
+ nudges.iterationNudgeAnchors,
563
+ lastMessageId,
564
+ lastIndex,
565
+ messages,
566
+ getNudgeFrequency(config),
567
+ )
568
+ }
569
+ }
570
+ }
571
+
572
+ const percent = limits.max > 0 ? Math.round((currentTokens / limits.max) * 100) : 0
573
+ // DCP nudgeForce: "soft" anchors the turn nudge on the assistant message,
574
+ // "strong" on the user message.
575
+ const targetRole = config.compress.nudgeForce === "strong" ? "user" : "assistant"
576
+
577
+ const injectForAnchors = (anchors: string[], marker: string, text: string, roleFilter?: string) => {
578
+ if (!text) return
579
+ for (const anchorId of anchors) {
580
+ const msg = messages.find((m) => {
581
+ const id = (m as any)?.id ?? (m as any)?.info?.id
582
+ return id === anchorId
583
+ })
584
+ if (!msg) continue
585
+ if (roleFilter && (msg as any)?.role !== roleFilter) continue
586
+ // Idempotency via stable marker: the dynamic part of the nudge
587
+ // (percentages) changes every request, so match on the marker only.
588
+ if (messageHasNudge(msg, marker)) continue
589
+ appendToMessage(msg, text)
590
+ }
591
+ }
592
+
593
+ injectForAnchors(
594
+ nudges.contextLimitAnchors,
595
+ NUDGE_MARKERS.contextLimit,
596
+ contextLimitNudge(percent, limits.max),
597
+ )
598
+ injectForAnchors(
599
+ nudges.turnNudgeAnchors,
600
+ NUDGE_MARKERS.turn,
601
+ turnNudge(percent),
602
+ targetRole,
603
+ )
604
+ injectForAnchors(
605
+ nudges.iterationNudgeAnchors,
606
+ NUDGE_MARKERS.iteration,
607
+ iterationNudge(percent),
608
+ )
609
+
610
+ state.nudges = nudges
611
+ }
package/src/lib/types.ts CHANGED
@@ -6,13 +6,26 @@ export interface SlimConfig {
6
6
  enabled: boolean
7
7
  debug: boolean
8
8
 
9
- // Compression settings
9
+ // Compression settings (DCP-compatible semantics)
10
10
  compress: {
11
11
  enabled: boolean
12
+ /** DCP mode: "range" (contiguous spans) or "message" (surgical, single messages) */
13
+ mode?: "range" | "message"
12
14
  permission: "allow" | "ask" | "deny"
13
- maxContextLimit: number | string // number or "80%"
14
- minContextLimit: number | string // number or "40%"
15
+ /** Absolute token count or percent string like "80%" (DCP default: 100000) */
16
+ maxContextLimit: number | string
17
+ /** Absolute token count or percent string like "40%" (DCP default: 50000) */
18
+ minContextLimit: number | string
19
+ /** Per-model overrides, keyed "providerId/modelId" (DCP: compress.modelMaxLimits) */
20
+ modelMaxLimits?: Record<string, number | string>
21
+ /** Per-model overrides, keyed "providerId/modelId" (DCP: compress.modelMinLimits) */
22
+ modelMinLimits?: Record<string, number | string>
23
+ /** At most one limit-nudge per this many messages (DCP default: 5) */
15
24
  nudgeFrequency: number
25
+ /** Messages since last user message before iteration nudge fires (DCP default: 15) */
26
+ iterationNudgeThreshold?: number
27
+ /** Where the turn nudge is anchored: "strong" -> user, "soft" -> assistant (DCP default: soft) */
28
+ nudgeForce?: "strong" | "soft"
16
29
  protectUserMessages: boolean
17
30
  protectedTools: string[]
18
31
  }
@@ -67,6 +80,40 @@ export interface SessionState {
67
80
 
68
81
  // Tool call tracking
69
82
  toolCalls: Map<string, ToolCallInfo>
83
+
84
+ // DCP-style compression blocks (range -> summary placeholders)
85
+ compressionBlocks?: CompressionBlock[]
86
+ nextBlockId?: number
87
+ // DCP-style nudge anchors
88
+ nudges?: NudgeState
89
+ }
90
+
91
+ /**
92
+ * A DCP-style compression block. When active, the covered messages are
93
+ * removed from every outgoing request and replaced by a synthetic summary
94
+ * message injected at the anchor message (the message right after the range).
95
+ */
96
+ export interface CompressionBlock {
97
+ blockId: number
98
+ topic: string
99
+ summary: string
100
+ /** Message id where the summary is injected (first message after the range, or the last message when the range reaches the end). */
101
+ anchorMessageId: string
102
+ /** The assistant message that executed the compress call; used to deactivate blocks when the source is gone. */
103
+ compressMessageId: string
104
+ /** Original message ids covered (excluded) by this block. */
105
+ coveredMessageIds: string[]
106
+ /** Older blocks swallowed by this block (nested compression). */
107
+ consumedBlockIds: number[]
108
+ active: boolean
109
+ createdAt: number
110
+ summaryTokens: number
111
+ }
112
+
113
+ export interface NudgeState {
114
+ contextLimitAnchors: string[]
115
+ turnNudgeAnchors: string[]
116
+ iterationNudgeAnchors: string[]
70
117
  }
71
118
 
72
119
  export interface CompressionRecord {
package/src/tui.tsx CHANGED
@@ -94,7 +94,14 @@ export function deriveStats(messages: readonly unknown[]): PanelStats {
94
94
  }
95
95
 
96
96
  // Builds a human-readable panel as plain text (injected into the message stream).
97
- function renderPanelText(sessionID: string, stats: PanelStats): string {
97
+ function renderPanelText(
98
+ sessionID: string,
99
+ stats: PanelStats,
100
+ real?: MeasuredReal | null,
101
+ ): string {
102
+ const limit = real?.contextLimit ?? 0
103
+ const pct = real?.usagePercent ?? 0
104
+ const status = real ? (pct >= 90 ? "critical" : pct >= 70 ? "warning" : "healthy") : "n/a"
98
105
  const lines: string[] = []
99
106
  lines.push("┌─────────────────────────────────────────────────────────────┐")
100
107
  lines.push("│ SLIM CONTEXT PANEL │")
@@ -107,22 +114,67 @@ function renderPanelText(sessionID: string, stats: PanelStats): string {
107
114
  lines.push(`│ Tool calls: ${stats.toolCalls} Compactions: ${stats.compactionCount}`)
108
115
  lines.push(`│ Tokens (est): User ${stats.tokensByRole.user} | Assistant ${stats.tokensByRole.assistant} | System ${stats.tokensByRole.system}`)
109
116
  lines.push(`│ Total token estimate: ${stats.totalTokens}`)
117
+ if (real) {
118
+ lines.push("├─────────────────────────────────────────────────────────────┤")
119
+ lines.push(`│ Measured tokens: ${real.tokens} (${pct}% of ${limit}) [${status}]`)
120
+ if (real.cost > 0) lines.push(`│ Cost: $${real.cost.toFixed(6)}`)
121
+ lines.push(`│ Model: ${real.model}`)
122
+ }
110
123
  lines.push("└─────────────────────────────────────────────────────────────┘")
111
124
  return lines.join("\n")
112
125
  }
113
126
 
114
- // Resolves the "current" session: the focused session if any, else the most recent.
127
+ // Resolves the "current" session: the router-focused session if any, else the most recent.
115
128
  function resolveCurrentSession(context: any): string | null {
116
129
  const sessions = context.data.session.list() || []
117
130
  if (sessions.length === 0) return null
118
- // Prefer the focused session if exposed; otherwise fall back to the first.
119
- const focused = context.router?.current?.()
120
- if (focused && typeof focused === "object" && "sessionID" in focused) {
121
- return focused.sessionID as string
131
+ // The TUI host exposes the active route via context.ui.router (not context.router).
132
+ const route = context.ui?.router?.current?.()
133
+ if (route && typeof route === "object" && "sessionID" in route) {
134
+ return route.sessionID as string
122
135
  }
123
136
  return sessions[0].id
124
137
  }
125
138
 
139
+ // Server-measured context numbers for a session (Session.Info.tokens + cost + model),
140
+ // mirroring what the `panel` tool in index.ts reads via ctx.session.get().
141
+ interface MeasuredReal {
142
+ tokens: number
143
+ cost: number
144
+ contextLimit: number
145
+ model: string
146
+ usagePercent: number
147
+ }
148
+
149
+ async function measureSession(context: any, sessionID: string): Promise<MeasuredReal | null> {
150
+ try {
151
+ const info: any = await context.client.session.get({ sessionID })
152
+ if (!info) return null
153
+ const tokens: any = info.tokens ?? {}
154
+ const tokenCount =
155
+ (typeof tokens.input === "number" ? tokens.input : 0) +
156
+ (typeof tokens.output === "number" ? tokens.output : 0) +
157
+ (typeof tokens.reasoning === "number" ? tokens.reasoning : 0) +
158
+ (typeof tokens.cache?.read === "number" ? tokens.cache.read : 0) +
159
+ (typeof tokens.cache?.write === "number" ? tokens.cache.write : 0)
160
+ const contextLimit: number =
161
+ typeof info.model?.limit?.context === "number" && info.model.limit.context > 0
162
+ ? info.model.limit.context
163
+ : 200000
164
+ const usagePercent =
165
+ contextLimit > 0 ? Math.min(100, Math.round((tokenCount / contextLimit) * 100)) : 0
166
+ return {
167
+ tokens: tokenCount,
168
+ cost: typeof info.cost === "number" ? info.cost : 0,
169
+ contextLimit,
170
+ model: info.model?.id || "unknown",
171
+ usagePercent,
172
+ }
173
+ } catch {
174
+ return null
175
+ }
176
+ }
177
+
126
178
  export default Plugin.define({
127
179
  id: "opencodev2-slim.cli",
128
180
  setup(context) {
@@ -160,10 +212,14 @@ export default Plugin.define({
160
212
  }
161
213
 
162
214
  try {
215
+ // Make sure the cached transcript is loaded before reading it.
216
+ await context.data.session.message.sync(sessionID)
163
217
  const messages =
164
218
  context.data.session.message.list(sessionID) || []
219
+ // Prefer live server-measured context numbers when available.
220
+ const real = await measureSession(context, sessionID)
165
221
  const stats = deriveStats(messages)
166
- const text = renderPanelText(sessionID, stats)
222
+ const text = renderPanelText(sessionID, stats, real)
167
223
  // Inject the panel as plain text into the session stream,
168
224
  // so it doesn't take over OpenCode's own panel UI.
169
225
  await context.client.session.synthetic({