@serkanalgur/opencodev2-slim 2.0.14 → 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.14",
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
@@ -16,6 +16,7 @@ import {
16
16
  pruneInPlace,
17
17
  injectLimitNudges,
18
18
  findLastUserMessage,
19
+ autoCompress,
19
20
  } from "./lib/strategies"
20
21
  import { getSystemPrompt, getCompressToolDescription } from "./lib/prompts"
21
22
  import { buildPanelData, renderPanel } from "./lib/tui"
@@ -46,27 +47,40 @@ function getConfig(sessionId: string): SlimConfig {
46
47
  }
47
48
 
48
49
  // Resolve the active model's real context limit instead of hard-coding 200k.
49
- // ctx.model.default() returns { data: ModelInfo | null }, where ModelInfo.limit.context
50
- // holds the model's context window. Use it directly and only fall back when missing.
51
- 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> {
52
53
  try {
53
- const selected: { data?: { limit?: { context?: number } } | null } | undefined =
54
- await ctx.model.default()
55
- const limit = selected?.data?.limit?.context
56
- 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
+ }
57
77
  } catch {
58
- return DEFAULT_MODEL_LIMIT
78
+ // Fall through to default
59
79
  }
80
+ return DEFAULT_MODEL_LIMIT
60
81
  }
61
82
 
62
- // Compose the exact text used to summarize a transcript (used by compaction).
63
- function stringifyTranscript(v: unknown): string {
64
- // A compact but useful representation of the transcript to be summarized.
65
- const text = String(v)
66
- return text.length > 4000 ? `${text.slice(0, 4000)}\n…` : text
67
- }
68
-
69
- // ─── Helpers ────────────────────────────────────────────────────────────────
83
+ // ─── State Management ───────────────────────────────────────────────────────
70
84
 
71
85
  // Register a DCP-style compression block for the selected range. The range is
72
86
  // covered (removed from future outgoing requests) and the summary is injected
@@ -109,18 +123,109 @@ function registerBlockForRange(
109
123
  })
110
124
  }
111
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
+ */
112
133
  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"
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
+
116
221
  return {
117
222
  info: {
118
223
  id,
119
224
  role,
120
- sessionID: (msg && (msg.sessionID || msgInfo?.sessionID)) || "",
225
+ sessionID: msg?.sessionID ?? msg?.info?.sessionID ?? "",
121
226
  time: { created: Date.now() },
122
227
  } as any,
123
- parts: (msg && (msg.parts || msg.content)) || [],
228
+ parts,
124
229
  }
125
230
  }
126
231
 
@@ -197,10 +302,21 @@ export default Plugin.define({
197
302
  return { content: "No messages found in session" }
198
303
  }
199
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
+
200
311
  const messageWithParts: MessageWithParts[] = messages.map(
201
312
  (m: any) => wrapAsMessageWithParts(m),
202
313
  )
203
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
+
204
320
  let targetIndices: number[] = []
205
321
  let inputTokens = 0
206
322
 
@@ -385,12 +501,12 @@ export default Plugin.define({
385
501
  event.system.push({ type: "text", text: getSystemPrompt() })
386
502
  })
387
503
 
388
- // ─── Messages Transform Hook (sync) ──────────────────────────────
504
+ // ─── Messages Transform Hook (sync → async) ─────────────────────────
389
505
  // DCP pipeline for every outgoing request: sync compression blocks,
390
506
  // replace covered ranges with summary placeholders, prune (dedup +
391
507
  // purge errored tool inputs), then apply DCP limit rules as anchored
392
508
  // nudges. Session history is never modified — only this request.
393
- await ctx.session.hook("context", (event) => {
509
+ await ctx.session.hook("context", async (event) => {
394
510
  const sessionId = event.sessionID
395
511
  const config = getConfig(sessionId)
396
512
  if (!config.enabled) return
@@ -398,6 +514,10 @@ export default Plugin.define({
398
514
  const state = getState(sessionId, config)
399
515
  state.modelContextLimit = sessionModelLimits.get(sessionId) || initialModelLimit
400
516
 
517
+ if (config.debug) {
518
+ console.log(`[slim] context hook: session=${sessionId}, messages=${event.messages.length}, modelLimit=${state.modelContextLimit}`)
519
+ }
520
+
401
521
  // 1) Compression blocks: activate/deactivate and replace ranges.
402
522
  const presentIds = new Set<string>()
403
523
  for (const msg of event.messages) {
@@ -405,9 +525,14 @@ export default Plugin.define({
405
525
  if (typeof id === "string") presentIds.add(id)
406
526
  }
407
527
  syncCompressionBlocks(state, presentIds)
528
+ const beforeCount = event.messages.length
408
529
  const filtered = applyCompressedRanges(state, event.messages)
409
530
  event.messages.splice(0, event.messages.length, ...filtered)
410
531
 
532
+ if (config.debug && beforeCount !== filtered.length) {
533
+ console.log(`[slim] compressed ranges: ${beforeCount} -> ${filtered.length} messages`)
534
+ }
535
+
411
536
  // 2) Pruning strategies (each request).
412
537
  pruneInPlace(event.messages, config)
413
538
  if (config.strategies.purgeErrors.enabled) {
@@ -418,7 +543,7 @@ export default Plugin.define({
418
543
  // to a quick estimate (~4 chars per token).
419
544
  let estimatedTokens = 0
420
545
  for (const msg of event.messages) {
421
- const content = (msg as any)?.content ?? (msg as any)?.parts
546
+ const content = (msg as any)?.content ?? (msg as any)?.parts ?? []
422
547
  if (Array.isArray(content)) {
423
548
  for (const part of content) {
424
549
  if (part?.type === "text" && part.text) {
@@ -433,21 +558,49 @@ export default Plugin.define({
433
558
 
434
559
  // 4) DCP limit rules → anchored nudges (max 100k / min 50k by
435
560
  // default, model overrides supported via modelMax/MinLimits).
561
+ // Extract provider/model from the last user message for per-model limits.
436
562
  const lastUser = findLastUserMessage(event.messages)
437
563
  const providerId =
438
- 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)
439
567
  const modelId =
440
568
  lastUser?.model?.modelID ??
441
- lastUser?.model?.id?.split?.("/").slice(1).join("/")
569
+ state._lastModelId ??
570
+ (typeof lastUser?.model?.id === "string" ? lastUser.model.id.split("/").slice(1).join("/") : undefined)
442
571
  const limits = resolveCompressLimits(config, state, providerId, modelId)
443
- injectLimitNudges(state, config, event.messages, totalTokens, limits)
572
+ injectLimitNudges(state, config, event.messages, totalTokens, limits, providerId, modelId)
573
+
574
+ // 5) Auto-compress: when over the max limit, directly compress old
575
+ // messages without waiting for the model to call the compress tool.
576
+ // Registers a compression block so future requests use the summary.
577
+ if (totalTokens > limits.max) {
578
+ if (config.debug) {
579
+ console.log(`[slim] auto-compress triggered: ${totalTokens} > ${limits.max} (max)`)
580
+ }
581
+ try {
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
+ }
590
+ // Best-effort: auto-compress failure should never break the request.
591
+ }
592
+ }
593
+
594
+ if (config.debug) {
595
+ console.log(`[slim] final messages: ${event.messages.length}, tokens: ${totalTokens}, limits: max=${limits.max} min=${limits.min}`)
596
+ }
444
597
 
445
598
  saveSessionState(state, config.persistence.directory)
446
599
  })
447
600
 
448
601
  // ─── Compaction Hook ────────────────────────────────────────────
449
602
  // Real, persistent context compression: when OpenCode compacts a session,
450
- // summarize the transcript so history actually shrinks (unlike the
603
+ // provide a structured summary so history actually shrinks (unlike the
451
604
  // `context` hook, which only affects the outgoing model request).
452
605
  await ctx.session.hook("compaction", async (event) => {
453
606
  const sessionId = (event as any).sessionID
@@ -460,18 +613,100 @@ export default Plugin.define({
460
613
  const state = getState(sessionId, config)
461
614
  state.modelContextLimit = sessionModelLimits.get(sessionId) || initialModelLimit
462
615
 
463
- const summary = stringifyTranscript(messages)
464
- 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"))
465
700
  const outputTokens = await countTokens(summary)
466
701
 
467
- if (outputTokens > 0 && inputTokens > outputTokens) {
702
+ if (outputTokens > 0 && inputTokens > 0) {
468
703
  addCompressionRecord(
469
704
  state,
470
705
  {
471
706
  timestamp: Date.now(),
472
707
  inputTokens,
473
708
  outputTokens,
474
- ratio: 1 - outputTokens / inputTokens,
709
+ ratio: inputTokens > outputTokens ? 1 - outputTokens / inputTokens : 0,
475
710
  messageCount: messages.length,
476
711
  success: true,
477
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/config.ts CHANGED
@@ -19,6 +19,7 @@ const DEFAULT_CONFIG: SlimConfig = {
19
19
  nudgeForce: "soft",
20
20
  protectUserMessages: false,
21
21
  protectedTools: ["task", "skill", "todowrite", "todoread"],
22
+ keepRecent: 5,
22
23
  },
23
24
  strategies: {
24
25
  deduplication: {
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
 
@@ -1,6 +1,7 @@
1
1
  import type { MessageWithParts, SlimConfig, SessionState, CompressionBlock } from "./types"
2
- import { getToolName, getMessageText } from "./compress"
2
+ import { getToolName, getMessageText, getToolResultContent, countTokens } from "./compress"
3
3
  import { contextLimitNudge, turnNudge, iterationNudge, NUDGE_MARKERS } from "./prompts"
4
+ import { addCompressionRecord } from "./state"
4
5
 
5
6
  // ─── DCP-style Compression Blocks ───────────────────────────────────────────
6
7
  //
@@ -68,6 +69,8 @@ export function syncCompressionBlocks(state: SessionState, presentIds: Set<strin
68
69
  * Produces the outgoing message list: active blocks inject their summary at the
69
70
  * anchor and drop every covered message. Returns a new array; the caller should
70
71
  * splice it back into the event.
72
+ *
73
+ * Handles both Message[] (hook format) and SessionMessageInfo[] (transcript format).
71
74
  */
72
75
  export function applyCompressedRanges(state: SessionState, messages: any[]): any[] {
73
76
  const blocks = (state.compressionBlocks ?? []).filter((b) => b.active)
@@ -82,15 +85,30 @@ export function applyCompressedRanges(state: SessionState, messages: any[]): any
82
85
 
83
86
  const result: any[] = []
84
87
  for (const msg of messages) {
88
+ // Extract ID from various formats
85
89
  const id = (msg && (msg.id ?? msg.info?.id)) as string | undefined
86
90
  if (typeof id === "string") {
87
91
  const block = byAnchor.get(id)
88
92
  if (block && block.summary) {
89
- result.push({
90
- role: "user",
91
- id: `slim-summary-${block.blockId}`,
92
- content: [{ type: "text", text: block.summary }],
93
- })
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
+ }
94
112
  }
95
113
  if (covered.has(id)) {
96
114
  continue
@@ -341,18 +359,32 @@ export function applyDeduplication(
341
359
  * DCP purge-errors: for tool calls whose result is an error, remove the large
342
360
  * string inputs once the message is at least `turns` positions behind the end
343
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).
344
364
  */
345
365
  export function purgeStaleToolErrors(messages: any[], turns: number): void {
346
366
  const n = messages.length
347
367
  if (n === 0) return
348
368
 
369
+ // Collect errored call IDs from all message formats
349
370
  const erroredCallIds = new Set<string>()
350
371
  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))
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
+ }
356
388
  }
357
389
  }
358
390
  if (erroredCallIds.size === 0) return
@@ -361,15 +393,33 @@ export function purgeStaleToolErrors(messages: any[], turns: number): void {
361
393
  for (let i = 0; i < n; i++) {
362
394
  if (i > n - turnsEffective - 1) continue // too recent — keep
363
395
  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]"
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
+ }
373
423
  }
374
424
  }
375
425
  }
@@ -416,11 +466,30 @@ export function pruneInPlace(messages: any[], config: SlimConfig): void {
416
466
 
417
467
  // ─── DCP limit rules → anchored nudges ─────────────────────────────────────
418
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
+ */
419
474
  export function messageHasCompress(msg: any): boolean {
420
- const content = msg?.content ?? msg?.parts ?? []
421
- 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(
422
478
  (part: any) => part?.type === "tool-call" && part?.name === "compress",
423
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
424
493
  }
425
494
 
426
495
  export function findLastUserMessage(messages: any[]): any | undefined {
@@ -503,11 +572,17 @@ export function injectLimitNudges(
503
572
  messages: any[],
504
573
  currentTokens: number,
505
574
  limits: { max: number; min: number },
575
+ providerId?: string,
576
+ modelId?: string,
506
577
  ): void {
507
578
  if (config.compress.permission === "deny") return
508
579
  if (state.manualMode) return
509
580
  if (messages.length === 0) return
510
581
 
582
+ // Store provider/model info on state for external access
583
+ if (providerId) state._lastProviderId = providerId
584
+ if (modelId) state._lastModelId = modelId
585
+
511
586
  const nudges = state.nudges ?? {
512
587
  contextLimitAnchors: [],
513
588
  turnNudgeAnchors: [],
@@ -608,4 +683,128 @@ export function injectLimitNudges(
608
683
  )
609
684
 
610
685
  state.nudges = nudges
686
+ }
687
+
688
+ // ─── Auto-compress: directly compress when over limit ───────────────────────
689
+
690
+ /**
691
+ * Automatically compresses old messages when context exceeds the max limit.
692
+ * Called from the context hook when overMax is true — no model cooperation needed.
693
+ * Registers a compression block so future requests use the summary instead.
694
+ */
695
+ export async function autoCompress(
696
+ state: SessionState,
697
+ config: SlimConfig,
698
+ messages: any[],
699
+ currentTokens: number,
700
+ limits: { max: number; min: number },
701
+ ): Promise<{ compressed: boolean; messageCount?: number; tokensSaved?: number }> {
702
+ if (config.compress.permission === "deny") return { compressed: false }
703
+ if (state.manualMode) return { compressed: false }
704
+ if (limits.max <= 0) return { compressed: false }
705
+ if (currentTokens <= limits.max) return { compressed: false }
706
+
707
+ // Throttle: don't auto-compress more than once every 5 minutes
708
+ const now = Date.now()
709
+ const lastAuto = (state as any).lastAutoCompressTime ?? 0
710
+ if (now - lastAuto < 5 * 60 * 1000) return { compressed: false }
711
+
712
+ // Don't auto-compress if the model just compressed in the last assistant turn
713
+ const lastAssistant = [...messages].reverse().find((m: any) => m?.role === "assistant")
714
+ if (lastAssistant && messageHasCompress(lastAssistant)) return { compressed: false }
715
+
716
+ const keepRecent = Math.max(2, config.compress.keepRecent ?? 5)
717
+ const messageWithParts: MessageWithParts[] = messages.map((m: any) => ({
718
+ info: {
719
+ id: m?.id ?? m?.info?.id ?? "",
720
+ role: m?.role ?? m?.info?.role ?? "user",
721
+ sessionID: m?.sessionID ?? m?.info?.sessionID ?? "",
722
+ time: { created: Date.now() },
723
+ } as any,
724
+ parts: m?.parts ?? m?.content ?? [],
725
+ }))
726
+
727
+ // Select messages to compress: all except recent ones, with >100 tokens
728
+ const targetIndices: number[] = []
729
+ let inputTokens = 0
730
+ for (let i = 0; i < messageWithParts.length - keepRecent; i++) {
731
+ const msg = messageWithParts[i]
732
+ const text = getMessageText(msg) + getToolResultContent(msg)
733
+ const tokens = await countTokens(text)
734
+ if (tokens < 100) continue
735
+ targetIndices.push(i)
736
+ inputTokens += tokens
737
+ }
738
+
739
+ if (targetIndices.length === 0) {
740
+ ;(state as any).lastAutoCompressTime = now
741
+ return { compressed: false }
742
+ }
743
+
744
+ // Build summary
745
+ const targetMessages = targetIndices.map((i) => messageWithParts[i])
746
+ const summary = await buildCompressionSummary(
747
+ targetMessages,
748
+ "auto-compress: context limit exceeded",
749
+ config.compress.protectedTools,
750
+ config.compress.protectUserMessages,
751
+ )
752
+ const outputTokens = await countTokens(summary)
753
+
754
+ // Register compression block
755
+ const sorted = [...targetIndices].sort((a, b) => a - b)
756
+ const coveredIndices = new Set(sorted)
757
+ let anchorIndex = sorted[sorted.length - 1] + 1
758
+ if (anchorIndex >= messageWithParts.length) {
759
+ anchorIndex = messageWithParts.length - 1
760
+ coveredIndices.delete(anchorIndex)
761
+ }
762
+ if (anchorIndex < 0 || anchorIndex >= messageWithParts.length) {
763
+ ;(state as any).lastAutoCompressTime = now
764
+ return { compressed: false }
765
+ }
766
+
767
+ const anchorId = messageWithParts[anchorIndex].info?.id
768
+ if (!anchorId) {
769
+ ;(state as any).lastAutoCompressTime = now
770
+ return { compressed: false }
771
+ }
772
+
773
+ const coveredIds = [...coveredIndices]
774
+ .map((i) => messageWithParts[i].info?.id)
775
+ .filter((id): id is string => typeof id === "string" && id.length > 0)
776
+ if (coveredIds.length === 0) {
777
+ ;(state as any).lastAutoCompressTime = now
778
+ return { compressed: false }
779
+ }
780
+
781
+ registerCompressionBlock(state, {
782
+ coveredIds,
783
+ anchorMessageId: anchorId,
784
+ summary,
785
+ topic: "auto-compress",
786
+ summaryTokens: outputTokens,
787
+ })
788
+
789
+ // Record compression stats
790
+ const ratio = inputTokens > 0 ? 1 - outputTokens / inputTokens : 0
791
+ addCompressionRecord(
792
+ state,
793
+ {
794
+ timestamp: now,
795
+ inputTokens,
796
+ outputTokens,
797
+ ratio,
798
+ messageCount: targetMessages.length,
799
+ success: true,
800
+ },
801
+ config.adaptive.learningRate,
802
+ )
803
+
804
+ ;(state as any).lastAutoCompressTime = now
805
+ return {
806
+ compressed: true,
807
+ messageCount: targetMessages.length,
808
+ tokensSaved: inputTokens - outputTokens,
809
+ }
611
810
  }
package/src/lib/types.ts CHANGED
@@ -28,6 +28,8 @@ export interface SlimConfig {
28
28
  nudgeForce?: "strong" | "soft"
29
29
  protectUserMessages: boolean
30
30
  protectedTools: string[]
31
+ /** Number of recent messages to always keep during auto-compress (default: 5) */
32
+ keepRecent?: number
31
33
  }
32
34
 
33
35
  // Strategy settings
@@ -86,6 +88,10 @@ export interface SessionState {
86
88
  nextBlockId?: number
87
89
  // DCP-style nudge anchors
88
90
  nudges?: NudgeState
91
+
92
+ // Last resolved provider/model (set by injectLimitNudges)
93
+ _lastProviderId?: string
94
+ _lastModelId?: string
89
95
  }
90
96
 
91
97
  /**