@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 +1 -1
- package/src/index.ts +267 -32
- package/src/lib/compress.ts +56 -12
- package/src/lib/config.ts +1 -0
- package/src/lib/state.ts +3 -0
- package/src/lib/strategies.ts +221 -22
- package/src/lib/types.ts +6 -0
package/package.json
CHANGED
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 {
|
|
50
|
-
//
|
|
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
|
|
54
|
-
await ctx.model.default()
|
|
55
|
-
const
|
|
56
|
-
|
|
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
|
-
|
|
78
|
+
// Fall through to default
|
|
59
79
|
}
|
|
80
|
+
return DEFAULT_MODEL_LIMIT
|
|
60
81
|
}
|
|
61
82
|
|
|
62
|
-
//
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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:
|
|
225
|
+
sessionID: msg?.sessionID ?? msg?.info?.sessionID ?? "",
|
|
121
226
|
time: { created: Date.now() },
|
|
122
227
|
} as any,
|
|
123
|
-
parts
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
464
|
-
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"))
|
|
465
700
|
const outputTokens = await countTokens(summary)
|
|
466
701
|
|
|
467
|
-
if (outputTokens > 0 && inputTokens >
|
|
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
|
},
|
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/config.ts
CHANGED
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
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if (
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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
|
/**
|