@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 +1 -1
- package/src/index.ts +255 -32
- package/src/lib/compress.ts +56 -12
- package/src/lib/state.ts +3 -0
- package/src/lib/strategies.ts +95 -21
- package/src/lib/types.ts +4 -0
package/package.json
CHANGED
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 {
|
|
51
|
-
//
|
|
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
|
|
55
|
-
await ctx.model.default()
|
|
56
|
-
const
|
|
57
|
-
|
|
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
|
-
|
|
78
|
+
// Fall through to default
|
|
60
79
|
}
|
|
80
|
+
return DEFAULT_MODEL_LIMIT
|
|
61
81
|
}
|
|
62
82
|
|
|
63
|
-
//
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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:
|
|
225
|
+
sessionID: msg?.sessionID ?? msg?.info?.sessionID ?? "",
|
|
122
226
|
time: { created: Date.now() },
|
|
123
227
|
} as any,
|
|
124
|
-
parts
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
476
|
-
const
|
|
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 >
|
|
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
|
},
|
package/src/lib/compress.ts
CHANGED
|
@@ -2,34 +2,71 @@ import type { MessageWithParts } from "./types"
|
|
|
2
2
|
|
|
3
3
|
// ─── Token Counting ─────────────────────────────────────────────────────────
|
|
4
4
|
|
|
5
|
-
let
|
|
5
|
+
let anthropicTokenizer: any = null
|
|
6
|
+
let tiktokenTokenizer: any = null
|
|
6
7
|
|
|
7
|
-
async function
|
|
8
|
-
if (!
|
|
8
|
+
async function getAnthropicTokenizer() {
|
|
9
|
+
if (!anthropicTokenizer) {
|
|
9
10
|
try {
|
|
10
11
|
const mod = await import("@anthropic-ai/tokenizer")
|
|
11
|
-
|
|
12
|
+
anthropicTokenizer = mod
|
|
12
13
|
} catch {
|
|
13
14
|
return null
|
|
14
15
|
}
|
|
15
16
|
}
|
|
16
|
-
return
|
|
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
|
-
|
|
23
|
-
|
|
41
|
+
// Try Anthropic tokenizer first
|
|
42
|
+
const anth = await getAnthropicTokenizer()
|
|
43
|
+
if (anth && anth.encode) {
|
|
24
44
|
try {
|
|
25
|
-
return
|
|
45
|
+
return anth.encode(text).length
|
|
26
46
|
} catch {
|
|
27
|
-
//
|
|
47
|
+
// Fall through
|
|
28
48
|
}
|
|
29
49
|
}
|
|
30
50
|
|
|
31
|
-
//
|
|
32
|
-
|
|
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
|
|
package/src/lib/strategies.ts
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
if (
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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