@serkanalgur/opencodev2-slim 2.0.15 → 2.1.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serkanalgur/opencodev2-slim",
3
- "version": "2.0.15",
3
+ "version": "2.1.0",
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
@@ -47,27 +47,40 @@ function getConfig(sessionId: string): SlimConfig {
47
47
  }
48
48
 
49
49
  // Resolve the active model's real context limit instead of hard-coding 200k.
50
- // ctx.model.default() returns { data: ModelInfo | null }, where ModelInfo.limit.context
51
- // holds the model's context window. Use it directly and only fall back when missing.
52
- async function resolveModelContextLimit(ctx: any): Promise<number> {
50
+ // ctx.model.default() only returns { providerID, modelID } no limit info.
51
+ // Use ctx.model.list() to find the full Model.Info which includes limit.context.
52
+ export async function resolveModelContextLimit(ctx: any): Promise<number> {
53
53
  try {
54
- const selected: { data?: { limit?: { context?: number } } | null } | undefined =
55
- await ctx.model.default()
56
- const limit = selected?.data?.limit?.context
57
- return typeof limit === "number" && limit > 0 ? limit : DEFAULT_MODEL_LIMIT
54
+ const defaultRef: { providerID?: string; modelID?: string } | undefined =
55
+ typeof ctx.model.default === "function" ? await ctx.model.default() : undefined
56
+ const providerID = defaultRef?.providerID
57
+ const modelID = defaultRef?.modelID
58
+
59
+ if (providerID && modelID && typeof ctx.model.list === "function") {
60
+ const models: Array<{ providerID: string; modelID: string; limit?: { context?: number } }> =
61
+ ctx.model.list()
62
+ const found = models.find(
63
+ (m) => m.providerID === providerID && m.modelID === modelID,
64
+ )
65
+ const limit = found?.limit?.context
66
+ if (typeof limit === "number" && limit > 0) return limit
67
+ }
68
+
69
+ // Fallback: try model.list() for any model with a limit
70
+ if (typeof ctx.model.list === "function") {
71
+ const models: Array<{ limit?: { context?: number } }> = ctx.model.list()
72
+ for (const m of models) {
73
+ const limit = m.limit?.context
74
+ if (typeof limit === "number" && limit > 0) return limit
75
+ }
76
+ }
58
77
  } catch {
59
- return DEFAULT_MODEL_LIMIT
78
+ // Fall through to default
60
79
  }
80
+ return DEFAULT_MODEL_LIMIT
61
81
  }
62
82
 
63
- // Compose the exact text used to summarize a transcript (used by compaction).
64
- function stringifyTranscript(v: unknown): string {
65
- // A compact but useful representation of the transcript to be summarized.
66
- const text = String(v)
67
- return text.length > 4000 ? `${text.slice(0, 4000)}\n…` : text
68
- }
69
-
70
- // ─── Helpers ────────────────────────────────────────────────────────────────
83
+ // ─── State Management ───────────────────────────────────────────────────────
71
84
 
72
85
  // Register a DCP-style compression block for the selected range. The range is
73
86
  // covered (removed from future outgoing requests) and the summary is injected
@@ -110,18 +123,109 @@ function registerBlockForRange(
110
123
  })
111
124
  }
112
125
 
126
+ /**
127
+ * Wraps any message format into MessageWithParts for internal processing.
128
+ * Handles both:
129
+ * - Raw Message format (from context hook): { id, role, parts/content }
130
+ * - SessionMessageInfo format (from session.context()): { id, type, text/content }
131
+ * - Transcript format (from TUI): { type, text, content }
132
+ */
113
133
  function wrapAsMessageWithParts(msg: any): MessageWithParts {
114
- const msgInfo = msg.info
115
- const id = (msg && (msg.id || msgInfo?.id)) || ""
116
- const role = (msg && (msg.role || msgInfo?.role)) || "user"
134
+ // Determine the role from various possible fields
135
+ let role = "assistant"
136
+ if (msg?.role) {
137
+ role = msg.role
138
+ } else if (msg?.type) {
139
+ // SessionMessageInfo / transcript format: type -> role mapping
140
+ const type = msg.type as string
141
+ if (type === "user" || type === "shell" || type === "synthetic") {
142
+ role = "user"
143
+ } else if (type === "assistant") {
144
+ role = "assistant"
145
+ } else if (type === "compaction" || type === "agent" || type === "model" || type === "skill") {
146
+ role = "system"
147
+ } else {
148
+ role = "assistant"
149
+ }
150
+ }
151
+
152
+ // Extract ID
153
+ const id = msg?.id ?? msg?.info?.id ?? ""
154
+
155
+ // Build parts array from whatever format we receive
156
+ const parts: any[] = []
157
+
158
+ if (role === "user") {
159
+ // User messages: text can be in msg.text, msg.content, or msg.parts
160
+ if (typeof msg.text === "string" && msg.text) {
161
+ parts.push({ type: "text", text: msg.text })
162
+ } else if (Array.isArray(msg.parts)) {
163
+ for (const p of msg.parts) {
164
+ if (p?.type === "text" && p.text) {
165
+ parts.push({ type: "text", text: p.text })
166
+ }
167
+ }
168
+ } else if (Array.isArray(msg.content)) {
169
+ for (const p of msg.content) {
170
+ if (p?.type === "text" && p.text) {
171
+ parts.push({ type: "text", text: p.text })
172
+ }
173
+ }
174
+ }
175
+ } else if (role === "assistant") {
176
+ // Assistant messages: content can be an array of parts
177
+ const contentArr = msg.content ?? msg.parts ?? []
178
+ if (Array.isArray(contentArr)) {
179
+ for (const p of contentArr) {
180
+ if (p?.type === "text") {
181
+ parts.push({ type: "text", text: p.text || "" })
182
+ } else if (p?.type === "tool") {
183
+ // SessionMessageAssistantTool format: has callID, name, state
184
+ const state = p.state
185
+ if (state?.status === "completed") {
186
+ parts.push({
187
+ type: "tool-result",
188
+ toolCallID: p.callID,
189
+ result: { value: state.content ?? state.output ?? "" },
190
+ })
191
+ } else if (state?.status === "error") {
192
+ parts.push({
193
+ type: "tool-result",
194
+ toolCallID: p.callID,
195
+ result: { type: "error", value: state.error ?? "Unknown error" },
196
+ })
197
+ }
198
+ // Tool call part
199
+ parts.push({
200
+ type: "tool-call",
201
+ name: p.name || p.tool || "",
202
+ input: state?.input ?? {},
203
+ toolCallID: p.callID,
204
+ })
205
+ } else if (p?.type === "tool-call") {
206
+ parts.push(p)
207
+ } else if (p?.type === "tool-result") {
208
+ parts.push(p)
209
+ }
210
+ }
211
+ }
212
+ } else if (role === "system") {
213
+ // System / compaction messages
214
+ if (typeof msg.summary === "string" && msg.summary) {
215
+ parts.push({ type: "text", text: msg.summary })
216
+ } else if (typeof msg.text === "string" && msg.text) {
217
+ parts.push({ type: "text", text: msg.text })
218
+ }
219
+ }
220
+
117
221
  return {
118
222
  info: {
119
223
  id,
120
224
  role,
121
- sessionID: (msg && (msg.sessionID || msgInfo?.sessionID)) || "",
225
+ sessionID: msg?.sessionID ?? msg?.info?.sessionID ?? "",
122
226
  time: { created: Date.now() },
123
227
  } as any,
124
- parts: (msg && (msg.parts || msg.content)) || [],
228
+ parts,
125
229
  }
126
230
  }
127
231
 
@@ -198,10 +302,21 @@ export default Plugin.define({
198
302
  return { content: "No messages found in session" }
199
303
  }
200
304
 
305
+ if (config.debug) {
306
+ const first = messages[0] as any
307
+ console.log(`[slim] compress: received ${messages.length} messages from session.context()`)
308
+ console.log(`[slim] compress: first message type: ${first?.type}, has text: ${typeof first?.text}, has content: ${Array.isArray(first?.content)}`)
309
+ }
310
+
201
311
  const messageWithParts: MessageWithParts[] = messages.map(
202
312
  (m: any) => wrapAsMessageWithParts(m),
203
313
  )
204
314
 
315
+ if (config.debug) {
316
+ const partsCounts = messageWithParts.map((m) => m.parts.length)
317
+ console.log(`[slim] compress: parts per message: [${partsCounts.join(", ")}]`)
318
+ }
319
+
205
320
  let targetIndices: number[] = []
206
321
  let inputTokens = 0
207
322
 
@@ -399,6 +514,10 @@ export default Plugin.define({
399
514
  const state = getState(sessionId, config)
400
515
  state.modelContextLimit = sessionModelLimits.get(sessionId) || initialModelLimit
401
516
 
517
+ if (config.debug) {
518
+ console.log(`[slim] context hook: session=${sessionId}, messages=${event.messages.length}, modelLimit=${state.modelContextLimit}`)
519
+ }
520
+
402
521
  // 1) Compression blocks: activate/deactivate and replace ranges.
403
522
  const presentIds = new Set<string>()
404
523
  for (const msg of event.messages) {
@@ -406,9 +525,14 @@ export default Plugin.define({
406
525
  if (typeof id === "string") presentIds.add(id)
407
526
  }
408
527
  syncCompressionBlocks(state, presentIds)
528
+ const beforeCount = event.messages.length
409
529
  const filtered = applyCompressedRanges(state, event.messages)
410
530
  event.messages.splice(0, event.messages.length, ...filtered)
411
531
 
532
+ if (config.debug && beforeCount !== filtered.length) {
533
+ console.log(`[slim] compressed ranges: ${beforeCount} -> ${filtered.length} messages`)
534
+ }
535
+
412
536
  // 2) Pruning strategies (each request).
413
537
  pruneInPlace(event.messages, config)
414
538
  if (config.strategies.purgeErrors.enabled) {
@@ -419,7 +543,7 @@ export default Plugin.define({
419
543
  // to a quick estimate (~4 chars per token).
420
544
  let estimatedTokens = 0
421
545
  for (const msg of event.messages) {
422
- const content = (msg as any)?.content ?? (msg as any)?.parts
546
+ const content = (msg as any)?.content ?? (msg as any)?.parts ?? []
423
547
  if (Array.isArray(content)) {
424
548
  for (const part of content) {
425
549
  if (part?.type === "text" && part.text) {
@@ -434,32 +558,49 @@ export default Plugin.define({
434
558
 
435
559
  // 4) DCP limit rules → anchored nudges (max 100k / min 50k by
436
560
  // default, model overrides supported via modelMax/MinLimits).
561
+ // Extract provider/model from the last user message for per-model limits.
437
562
  const lastUser = findLastUserMessage(event.messages)
438
563
  const providerId =
439
- lastUser?.model?.providerID ?? lastUser?.model?.id?.split?.("/")[0]
564
+ lastUser?.model?.providerID ??
565
+ state._lastProviderId ??
566
+ (typeof lastUser?.model?.id === "string" ? lastUser.model.id.split("/")[0] : undefined)
440
567
  const modelId =
441
568
  lastUser?.model?.modelID ??
442
- lastUser?.model?.id?.split?.("/").slice(1).join("/")
569
+ state._lastModelId ??
570
+ (typeof lastUser?.model?.id === "string" ? lastUser.model.id.split("/").slice(1).join("/") : undefined)
443
571
  const limits = resolveCompressLimits(config, state, providerId, modelId)
444
- injectLimitNudges(state, config, event.messages, totalTokens, limits)
572
+ injectLimitNudges(state, config, event.messages, totalTokens, limits, providerId, modelId)
445
573
 
446
574
  // 5) Auto-compress: when over the max limit, directly compress old
447
575
  // messages without waiting for the model to call the compress tool.
448
576
  // Registers a compression block so future requests use the summary.
449
577
  if (totalTokens > limits.max) {
578
+ if (config.debug) {
579
+ console.log(`[slim] auto-compress triggered: ${totalTokens} > ${limits.max} (max)`)
580
+ }
450
581
  try {
451
- await autoCompress(state, config, event.messages, totalTokens, limits)
452
- } catch {
582
+ const result = await autoCompress(state, config, event.messages, totalTokens, limits)
583
+ if (config.debug && result.compressed) {
584
+ console.log(`[slim] auto-compress: compressed ${result.messageCount} messages, saved ~${result.tokensSaved} tokens`)
585
+ }
586
+ } catch (err) {
587
+ if (config.debug) {
588
+ console.log(`[slim] auto-compress failed:`, err)
589
+ }
453
590
  // Best-effort: auto-compress failure should never break the request.
454
591
  }
455
592
  }
456
593
 
594
+ if (config.debug) {
595
+ console.log(`[slim] final messages: ${event.messages.length}, tokens: ${totalTokens}, limits: max=${limits.max} min=${limits.min}`)
596
+ }
597
+
457
598
  saveSessionState(state, config.persistence.directory)
458
599
  })
459
600
 
460
601
  // ─── Compaction Hook ────────────────────────────────────────────
461
602
  // Real, persistent context compression: when OpenCode compacts a session,
462
- // summarize the transcript so history actually shrinks (unlike the
603
+ // provide a structured summary so history actually shrinks (unlike the
463
604
  // `context` hook, which only affects the outgoing model request).
464
605
  await ctx.session.hook("compaction", async (event) => {
465
606
  const sessionId = (event as any).sessionID
@@ -472,18 +613,100 @@ export default Plugin.define({
472
613
  const state = getState(sessionId, config)
473
614
  state.modelContextLimit = sessionModelLimits.get(sessionId) || initialModelLimit
474
615
 
475
- const summary = stringifyTranscript(messages)
476
- const inputTokens = await countTokens(summary)
616
+ // Build a structured summary instead of just stringifying.
617
+ const lines: string[] = []
618
+ lines.push("## Session Summary (Compacted)")
619
+ lines.push("")
620
+
621
+ const summaryParts: string[] = []
622
+ const toolCallsSummary: string[] = []
623
+ const keyDecisions: string[] = []
624
+ let userMessageCount = 0
625
+ let assistantMessageCount = 0
626
+
627
+ for (const msg of messages) {
628
+ const type = msg?.type ?? msg?.role ?? ""
629
+ if (type === "user" || type === "shell" || type === "synthetic") {
630
+ userMessageCount++
631
+ const text = msg.text ?? ""
632
+ if (text.trim().length > 0) {
633
+ summaryParts.push(`[User]: ${text.slice(0, 300)}`)
634
+ }
635
+ } else if (type === "assistant") {
636
+ assistantMessageCount++
637
+ const content = msg.content ?? msg.parts ?? []
638
+ if (Array.isArray(content)) {
639
+ for (const part of content) {
640
+ if (part?.type === "text" && part.text) {
641
+ const t = part.text
642
+ if (t.length > 0) {
643
+ summaryParts.push(`[Assistant]: ${t.slice(0, 300)}`)
644
+ }
645
+ // Capture decisions
646
+ if (t.includes("decided") || t.includes("implemented") || t.includes("created")) {
647
+ keyDecisions.push(t.slice(0, 200))
648
+ }
649
+ } else if (part?.type === "tool" || part?.type === "tool-call") {
650
+ const name = part.name ?? part.tool ?? "unknown"
651
+ toolCallsSummary.push(name)
652
+ }
653
+ }
654
+ }
655
+ } else if (type === "compaction") {
656
+ // Previous compaction summary — include verbatim
657
+ if (msg.summary) {
658
+ summaryParts.push(`[Previous summary]: ${msg.summary.slice(0, 500)}`)
659
+ }
660
+ }
661
+ }
662
+
663
+ // Compose summary
664
+ lines.push(`Messages: ${userMessageCount} user, ${assistantMessageCount} assistant`)
665
+ if (toolCallsSummary.length > 0) {
666
+ const uniqueTools = [...new Set(toolCallsSummary)]
667
+ lines.push(`Tools used: ${uniqueTools.join(", ")}`)
668
+ }
669
+ lines.push("")
670
+
671
+ // Key exchanges (first few and last few, skip middle)
672
+ const keepFirst = Math.min(3, summaryParts.length)
673
+ const keepLast = Math.min(3, summaryParts.length)
674
+ if (keepFirst + keepLast < summaryParts.length) {
675
+ lines.push("### Key exchanges")
676
+ for (const s of summaryParts.slice(0, keepFirst)) {
677
+ lines.push(s)
678
+ }
679
+ lines.push("...")
680
+ for (const s of summaryParts.slice(-keepLast)) {
681
+ lines.push(s)
682
+ }
683
+ } else {
684
+ lines.push("### Conversation")
685
+ for (const s of summaryParts) {
686
+ lines.push(s)
687
+ }
688
+ }
689
+
690
+ if (keyDecisions.length > 0) {
691
+ lines.push("")
692
+ lines.push("### Key decisions")
693
+ for (const d of keyDecisions.slice(0, 5)) {
694
+ lines.push(`- ${d}`)
695
+ }
696
+ }
697
+
698
+ const summary = lines.join("\n")
699
+ const inputTokens = await countTokens(messages.map((m: any) => m.text ?? "").join("\n"))
477
700
  const outputTokens = await countTokens(summary)
478
701
 
479
- if (outputTokens > 0 && inputTokens > outputTokens) {
702
+ if (outputTokens > 0 && inputTokens > 0) {
480
703
  addCompressionRecord(
481
704
  state,
482
705
  {
483
706
  timestamp: Date.now(),
484
707
  inputTokens,
485
708
  outputTokens,
486
- ratio: 1 - outputTokens / inputTokens,
709
+ ratio: inputTokens > outputTokens ? 1 - outputTokens / inputTokens : 0,
487
710
  messageCount: messages.length,
488
711
  success: true,
489
712
  },
@@ -2,34 +2,71 @@ import type { MessageWithParts } from "./types"
2
2
 
3
3
  // ─── Token Counting ─────────────────────────────────────────────────────────
4
4
 
5
- let tokenizer: any = null
5
+ let anthropicTokenizer: any = null
6
+ let tiktokenTokenizer: any = null
6
7
 
7
- async function getTokenizer() {
8
- if (!tokenizer) {
8
+ async function getAnthropicTokenizer() {
9
+ if (!anthropicTokenizer) {
9
10
  try {
10
11
  const mod = await import("@anthropic-ai/tokenizer")
11
- tokenizer = mod
12
+ anthropicTokenizer = mod
12
13
  } catch {
13
14
  return null
14
15
  }
15
16
  }
16
- return tokenizer
17
+ return anthropicTokenizer
17
18
  }
18
19
 
20
+ async function getTiktoken() {
21
+ if (!tiktokenTokenizer) {
22
+ try {
23
+ const mod = await import("tiktoken")
24
+ tiktokenTokenizer = mod
25
+ } catch {
26
+ return null
27
+ }
28
+ }
29
+ return tiktokenTokenizer
30
+ }
31
+
32
+ /**
33
+ * Count tokens for the given text. Tries providers in order:
34
+ * 1. Anthropic tokenizer (if available) — most accurate for Claude models
35
+ * 2. tiktoken (if available) — accurate for OpenAI models
36
+ * 3. Rough estimation: ~4 chars per token (best guess for mixed content)
37
+ */
19
38
  export async function countTokens(text: string): Promise<number> {
20
39
  if (!text) return 0
21
40
 
22
- const tok = await getTokenizer()
23
- if (tok && tok.encode) {
41
+ // Try Anthropic tokenizer first
42
+ const anth = await getAnthropicTokenizer()
43
+ if (anth && anth.encode) {
24
44
  try {
25
- return tok.encode(text).length
45
+ return anth.encode(text).length
26
46
  } catch {
27
- // Fallback to estimation
47
+ // Fall through
28
48
  }
29
49
  }
30
50
 
31
- // Rough estimation: ~4 chars per token for English
32
- return Math.ceil(text.length / 4)
51
+ // Try tiktoken
52
+ const tk = await getTiktoken()
53
+ if (tk && tk.encoding_for_model) {
54
+ try {
55
+ const enc = tk.encoding_for_model("gpt-4")
56
+ const tokens = enc.encode(text)
57
+ enc.free()
58
+ return tokens.length
59
+ } catch {
60
+ // Fall through
61
+ }
62
+ }
63
+
64
+ // Rough estimation: ~4 chars per token for English, ~2-3 for CJK
65
+ // Count non-ASCII characters for a slightly better estimate
66
+ const nonAsciiCount = (text.match(/[^\x00-\x7F]/g) || []).length
67
+ const asciiLen = text.length - nonAsciiCount
68
+ const estimatedTokens = Math.ceil(asciiLen / 4) + Math.ceil(nonAsciiCount / 2)
69
+ return Math.max(1, estimatedTokens)
33
70
  }
34
71
 
35
72
  // ─── Message Text Extraction ────────────────────────────────────────────────
@@ -63,6 +100,13 @@ export function getToolResultContent(msg: MessageWithParts): string {
63
100
  results.push(String(val).slice(0, 500))
64
101
  }
65
102
  }
103
+ // SessionMessageInfo format: tool with state.status === "completed"
104
+ if (part.type === "tool" && part.state?.status === "completed") {
105
+ const output = part.state.content ?? part.state.output
106
+ if (output !== undefined && output !== null) {
107
+ results.push(String(output).slice(0, 500))
108
+ }
109
+ }
66
110
  }
67
111
 
68
112
  return results.join("\n")
@@ -72,7 +116,7 @@ export function getToolName(msg: MessageWithParts): string | null {
72
116
  for (const part of msg.parts) {
73
117
  // v1 SDK format
74
118
  if (part.type === "tool") {
75
- return part.tool || null
119
+ return part.tool || part.name || null
76
120
  }
77
121
  // v2 AI format
78
122
  if (part.type === "tool-call") {
package/src/lib/state.ts CHANGED
@@ -35,6 +35,9 @@ export function normalizeState(state: SessionState): SessionState {
35
35
  ? state.nudges.iterationNudgeAnchors
36
36
  : [],
37
37
  }
38
+ // Optional fields — preserve if present, leave undefined if not
39
+ state._lastProviderId = state._lastProviderId ?? undefined
40
+ state._lastModelId = state._lastModelId ?? undefined
38
41
  return state
39
42
  }
40
43
 
@@ -69,6 +69,8 @@ export function syncCompressionBlocks(state: SessionState, presentIds: Set<strin
69
69
  * Produces the outgoing message list: active blocks inject their summary at the
70
70
  * anchor and drop every covered message. Returns a new array; the caller should
71
71
  * splice it back into the event.
72
+ *
73
+ * Handles both Message[] (hook format) and SessionMessageInfo[] (transcript format).
72
74
  */
73
75
  export function applyCompressedRanges(state: SessionState, messages: any[]): any[] {
74
76
  const blocks = (state.compressionBlocks ?? []).filter((b) => b.active)
@@ -83,15 +85,30 @@ export function applyCompressedRanges(state: SessionState, messages: any[]): any
83
85
 
84
86
  const result: any[] = []
85
87
  for (const msg of messages) {
88
+ // Extract ID from various formats
86
89
  const id = (msg && (msg.id ?? msg.info?.id)) as string | undefined
87
90
  if (typeof id === "string") {
88
91
  const block = byAnchor.get(id)
89
92
  if (block && block.summary) {
90
- result.push({
91
- role: "user",
92
- id: `slim-summary-${block.blockId}`,
93
- content: [{ type: "text", text: block.summary }],
94
- })
93
+ // Detect format: if messages have 'role', it's Message format.
94
+ // If they have 'type', it's SessionMessageInfo format.
95
+ const isHookFormat = messages.length > 0 && "role" in (messages[0] ?? {})
96
+ if (isHookFormat) {
97
+ // Hook format: inject as a synthetic user message
98
+ result.push({
99
+ role: "user",
100
+ id: `slim-summary-${block.blockId}`,
101
+ content: [{ type: "text", text: block.summary }],
102
+ })
103
+ } else {
104
+ // Transcript / SessionMessageInfo format
105
+ result.push({
106
+ type: "user",
107
+ id: `slim-summary-${block.blockId}`,
108
+ text: block.summary,
109
+ time: { created: Date.now() },
110
+ })
111
+ }
95
112
  }
96
113
  if (covered.has(id)) {
97
114
  continue
@@ -342,18 +359,32 @@ export function applyDeduplication(
342
359
  * DCP purge-errors: for tool calls whose result is an error, remove the large
343
360
  * string inputs once the message is at least `turns` positions behind the end
344
361
  * of the conversation. Error messages themselves are preserved.
362
+ * Handles both hook format (content with tool-call/tool-result parts) and
363
+ * SessionMessageInfo format (content with tool parts).
345
364
  */
346
365
  export function purgeStaleToolErrors(messages: any[], turns: number): void {
347
366
  const n = messages.length
348
367
  if (n === 0) return
349
368
 
369
+ // Collect errored call IDs from all message formats
350
370
  const erroredCallIds = new Set<string>()
351
371
  for (const msg of messages) {
352
- for (const part of msg?.content ?? msg?.parts ?? []) {
353
- if (part?.type !== "tool-result") continue
354
- if (part.result?.type !== "error") continue
355
- const callId = part.toolCallID ?? part.callID
356
- if (callId) erroredCallIds.add(String(callId))
372
+ const contentArr = msg?.content ?? msg?.parts ?? []
373
+ if (!Array.isArray(contentArr)) continue
374
+
375
+ for (const part of contentArr) {
376
+ // Format 1: tool-result with result.type === "error"
377
+ if (part?.type === "tool-result") {
378
+ if (part.result?.type === "error") {
379
+ const callId = part.toolCallID ?? part.callID
380
+ if (callId) erroredCallIds.add(String(callId))
381
+ }
382
+ }
383
+ // Format 2: tool with state.status === "error"
384
+ if (part?.type === "tool" && part?.state?.status === "error") {
385
+ const callId = part.callID
386
+ if (callId) erroredCallIds.add(String(callId))
387
+ }
357
388
  }
358
389
  }
359
390
  if (erroredCallIds.size === 0) return
@@ -362,15 +393,33 @@ export function purgeStaleToolErrors(messages: any[], turns: number): void {
362
393
  for (let i = 0; i < n; i++) {
363
394
  if (i > n - turnsEffective - 1) continue // too recent — keep
364
395
  const msg = messages[i]
365
- for (const part of msg?.content ?? msg?.parts ?? []) {
366
- if (part?.type !== "tool-call") continue
367
- const callId = part.toolCallID ?? part.callID
368
- if (!callId || !erroredCallIds.has(String(callId))) continue
369
- const input = part.input
370
- if (input && typeof input === "object") {
371
- for (const key of Object.keys(input)) {
372
- if (typeof input[key] === "string" && input[key].length > 80) {
373
- input[key] = "[input removed due to failed tool call]"
396
+ const contentArr = msg?.content ?? msg?.parts ?? []
397
+ if (!Array.isArray(contentArr)) continue
398
+
399
+ for (const part of contentArr) {
400
+ // Format 1: tool-call part
401
+ if (part?.type === "tool-call") {
402
+ const callId = part.toolCallID ?? part.callID
403
+ if (!callId || !erroredCallIds.has(String(callId))) continue
404
+ const input = part.input
405
+ if (input && typeof input === "object") {
406
+ for (const key of Object.keys(input)) {
407
+ if (typeof input[key] === "string" && input[key].length > 80) {
408
+ input[key] = "[input removed due to failed tool call]"
409
+ }
410
+ }
411
+ }
412
+ }
413
+ // Format 2: tool part with state containing input
414
+ if (part?.type === "tool") {
415
+ const callId = part.callID
416
+ if (!callId || !erroredCallIds.has(String(callId))) continue
417
+ const state = part.state
418
+ if (state?.input && typeof state.input === "object") {
419
+ for (const key of Object.keys(state.input)) {
420
+ if (typeof state.input[key] === "string" && state.input[key].length > 80) {
421
+ state.input[key] = "[input removed due to failed tool call]"
422
+ }
374
423
  }
375
424
  }
376
425
  }
@@ -417,11 +466,30 @@ export function pruneInPlace(messages: any[], config: SlimConfig): void {
417
466
 
418
467
  // ─── DCP limit rules → anchored nudges ─────────────────────────────────────
419
468
 
469
+ /**
470
+ * Detects whether a message contains a compress tool call.
471
+ * Handles both the hook format (content array with tool-call parts) and the
472
+ * SessionMessageInfo format (assistant content with tool parts).
473
+ */
420
474
  export function messageHasCompress(msg: any): boolean {
421
- const content = msg?.content ?? msg?.parts ?? []
422
- return content.some(
475
+ // Format 1: Hook format — content array with tool-call parts
476
+ const content1 = msg?.content ?? msg?.parts ?? []
477
+ const hasInContent = content1.some(
423
478
  (part: any) => part?.type === "tool-call" && part?.name === "compress",
424
479
  )
480
+ if (hasInContent) return true
481
+
482
+ // Format 2: SessionMessageInfo / transcript format — content array with tool parts
483
+ const content2 = msg?.content ?? []
484
+ if (Array.isArray(content2)) {
485
+ for (const part of content2) {
486
+ if (part?.type === "tool" && part?.name === "compress") return true
487
+ // Some formats store tool name inside state or as text
488
+ if (part?.type === "tool" && typeof part?.text === "string" && part.text.includes('"compress"')) return true
489
+ }
490
+ }
491
+
492
+ return false
425
493
  }
426
494
 
427
495
  export function findLastUserMessage(messages: any[]): any | undefined {
@@ -504,11 +572,17 @@ export function injectLimitNudges(
504
572
  messages: any[],
505
573
  currentTokens: number,
506
574
  limits: { max: number; min: number },
575
+ providerId?: string,
576
+ modelId?: string,
507
577
  ): void {
508
578
  if (config.compress.permission === "deny") return
509
579
  if (state.manualMode) return
510
580
  if (messages.length === 0) return
511
581
 
582
+ // Store provider/model info on state for external access
583
+ if (providerId) state._lastProviderId = providerId
584
+ if (modelId) state._lastModelId = modelId
585
+
512
586
  const nudges = state.nudges ?? {
513
587
  contextLimitAnchors: [],
514
588
  turnNudgeAnchors: [],
package/src/lib/types.ts CHANGED
@@ -88,6 +88,10 @@ export interface SessionState {
88
88
  nextBlockId?: number
89
89
  // DCP-style nudge anchors
90
90
  nudges?: NudgeState
91
+
92
+ // Last resolved provider/model (set by injectLimitNudges)
93
+ _lastProviderId?: string
94
+ _lastModelId?: string
91
95
  }
92
96
 
93
97
  /**