@serkanalgur/opencodev2-slim 2.0.13 → 2.0.15

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.
@@ -1,8 +1,302 @@
1
- import type { MessageWithParts, SlimConfig } from "./types"
2
- import { getToolName, getToolResultContent, getMessageText } from "./compress"
3
- import { getDuplicateToolCalls, getErroredToolCalls } from "./state"
4
- import type { SessionState } from "./types"
1
+ import type { MessageWithParts, SlimConfig, SessionState, CompressionBlock } from "./types"
2
+ import { getToolName, getMessageText, getToolResultContent, countTokens } from "./compress"
3
+ import { contextLimitNudge, turnNudge, iterationNudge, NUDGE_MARKERS } from "./prompts"
4
+ import { addCompressionRecord } from "./state"
5
5
 
6
+ // ─── DCP-style Compression Blocks ───────────────────────────────────────────
7
+ //
8
+ // A compression block replaces a contiguous range of messages with a summary
9
+ // placeholder on every outgoing request. The summary is injected as a synthetic
10
+ // user message at the block's *anchor* message (the first message after the
11
+ // range, or the last message when the range reaches the end). The covered
12
+ // messages are removed from the outgoing request only — session history is
13
+ // never modified. Newer blocks "consume" older ones (nested compression).
14
+
15
+ /**
16
+ * Activates/deactivates blocks based on which messages are present in the
17
+ * current outgoing request. A block is active while both its origin message
18
+ * (compressMessageId) and its anchor message are still present. A newer active
19
+ * block deactivates any older block whose anchor falls inside its covered range.
20
+ */
21
+ export function syncCompressionBlocks(state: SessionState, presentIds: Set<string>): void {
22
+ const blocks = state.compressionBlocks ?? []
23
+ if (blocks.length === 0) return
24
+
25
+ for (const block of blocks) {
26
+ const hasOrigin =
27
+ block.compressMessageId.length > 0 ? presentIds.has(block.compressMessageId) : true
28
+ block.active = hasOrigin && presentIds.has(block.anchorMessageId)
29
+ }
30
+
31
+ // Nested consumption: newest active block wins over older blocks it covers,
32
+ // and inherits their covered messages so nothing resurfaces behind the
33
+ // newest summary. Loop until stable to handle chains (A -> B -> C).
34
+ const sorted = [...blocks].sort((a, b) => a.blockId - b.blockId)
35
+ let changed = true
36
+ while (changed) {
37
+ changed = false
38
+ for (const block of sorted) {
39
+ if (!block.active) continue
40
+ for (const older of sorted) {
41
+ if (older.blockId >= block.blockId || !older.active) continue
42
+ if (block.coveredMessageIds.includes(older.anchorMessageId)) {
43
+ older.active = false
44
+ for (const id of older.coveredMessageIds) {
45
+ if (!block.coveredMessageIds.includes(id)) {
46
+ block.coveredMessageIds.push(id)
47
+ changed = true
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ // Orphaned blocks: inactive and none of their referenced messages survive
56
+ // (e.g. after OpenCode compaction) — safe to forget, otherwise dead entries
57
+ // accumulate in persisted state forever.
58
+ const alive = blocks.filter((b) => {
59
+ if (b.active) return true
60
+ const refs = [b.anchorMessageId, b.compressMessageId, ...(b.coveredMessageIds ?? [])]
61
+ return refs.some((id) => typeof id === "string" && presentIds.has(id))
62
+ })
63
+ if (alive.length !== blocks.length) {
64
+ state.compressionBlocks = alive
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Produces the outgoing message list: active blocks inject their summary at the
70
+ * anchor and drop every covered message. Returns a new array; the caller should
71
+ * splice it back into the event.
72
+ */
73
+ export function applyCompressedRanges(state: SessionState, messages: any[]): any[] {
74
+ const blocks = (state.compressionBlocks ?? []).filter((b) => b.active)
75
+ if (blocks.length === 0 || messages.length === 0) return messages
76
+
77
+ const covered = new Set<string>()
78
+ const byAnchor = new Map<string, CompressionBlock>()
79
+ for (const block of blocks) {
80
+ for (const id of block.coveredMessageIds) covered.add(id)
81
+ byAnchor.set(block.anchorMessageId, block)
82
+ }
83
+
84
+ const result: any[] = []
85
+ for (const msg of messages) {
86
+ const id = (msg && (msg.id ?? msg.info?.id)) as string | undefined
87
+ if (typeof id === "string") {
88
+ const block = byAnchor.get(id)
89
+ if (block && block.summary) {
90
+ result.push({
91
+ role: "user",
92
+ id: `slim-summary-${block.blockId}`,
93
+ content: [{ type: "text", text: block.summary }],
94
+ })
95
+ }
96
+ if (covered.has(id)) {
97
+ continue
98
+ }
99
+ }
100
+ result.push(msg)
101
+ }
102
+ return result
103
+ }
104
+
105
+ export interface RegisterBlockOptions {
106
+ coveredIds: string[]
107
+ anchorMessageId: string
108
+ summary: string
109
+ topic: string
110
+ compressMessageId?: string
111
+ summaryTokens?: number
112
+ }
113
+
114
+ /**
115
+ * Registers a new compression block. Older active blocks whose anchor lies
116
+ * inside the new range are consumed (deactivated) so only the newest summary
117
+ * is injected — information survives through layers of compression.
118
+ */
119
+ export function registerCompressionBlock(
120
+ state: SessionState,
121
+ opts: RegisterBlockOptions,
122
+ ): CompressionBlock | null {
123
+ const blocks = state.compressionBlocks ?? []
124
+ const nextId =
125
+ state.nextBlockId ??
126
+ blocks.reduce((max, b) => Math.max(max, b.blockId), 0) + 1
127
+
128
+ const consumed = blocks
129
+ .filter((b) => b.active && opts.coveredIds.includes(b.anchorMessageId))
130
+ .map((b) => b.blockId)
131
+
132
+ const block: CompressionBlock = {
133
+ blockId: nextId,
134
+ topic: opts.topic,
135
+ summary: opts.summary,
136
+ anchorMessageId: opts.anchorMessageId,
137
+ compressMessageId: opts.compressMessageId ?? "",
138
+ coveredMessageIds: opts.coveredIds,
139
+ consumedBlockIds: consumed,
140
+ active: true,
141
+ createdAt: Date.now(),
142
+ summaryTokens: opts.summaryTokens ?? 0,
143
+ }
144
+
145
+ blocks.push(block)
146
+ state.compressionBlocks = blocks
147
+ state.nextBlockId = nextId + 1
148
+
149
+ for (const consumedId of consumed) {
150
+ const target = blocks.find((b) => b.blockId === consumedId)
151
+ if (target) {
152
+ target.active = false
153
+ // Inherit the consumed block's covered messages so they stay
154
+ // hidden behind the newer summary (nested compression).
155
+ for (const id of target.coveredMessageIds) {
156
+ if (!block.coveredMessageIds.includes(id)) {
157
+ block.coveredMessageIds.push(id)
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ return block
164
+ }
165
+
166
+ // ─── Summary building (with protected content) ─────────────────────────────
167
+
168
+ /**
169
+ * Builds the compression summary used as the placeholder. Protected tool
170
+ * outputs (task, skill, todowrite, todoread, ...) are appended verbatim so the
171
+ * most important information survives compression — DCP behaviour.
172
+ */
173
+ export async function buildCompressionSummary(
174
+ messages: MessageWithParts[],
175
+ focus: string,
176
+ protectedTools: string[],
177
+ protectUserMessages = false,
178
+ ): Promise<string> {
179
+ const lines: string[] = []
180
+ lines.push(`## Compression Summary`)
181
+ lines.push(`Focus: ${focus}`)
182
+ lines.push(`Messages compressed: ${messages.length}`)
183
+ lines.push("")
184
+
185
+ const toolCalls: string[] = []
186
+ const errors: string[] = []
187
+ const decisions: string[] = []
188
+
189
+ for (const msg of messages) {
190
+ for (const part of msg.parts) {
191
+ if (part.type === "tool-call") {
192
+ toolCalls.push(
193
+ `${part.name}: ${JSON.stringify(part.input || {}).slice(0, 100)}`,
194
+ )
195
+ }
196
+ if (part.type === "tool-result") {
197
+ if (part.result?.type === "error") {
198
+ errors.push(String(part.result.value).slice(0, 200) || "Unknown error")
199
+ }
200
+ }
201
+ if (part.type === "text") {
202
+ const text = part.text || ""
203
+ if (
204
+ text.includes("decided") ||
205
+ text.includes("chose") ||
206
+ text.includes("implemented")
207
+ ) {
208
+ decisions.push(text.slice(0, 200))
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ if (toolCalls.length > 0) {
215
+ lines.push("### Tool Calls")
216
+ toolCalls.slice(0, 10).forEach((tc) => lines.push(`- ${tc}`))
217
+ lines.push("")
218
+ }
219
+
220
+ if (errors.length > 0) {
221
+ lines.push("### Errors Encountered")
222
+ errors.slice(0, 5).forEach((e) => lines.push(`- ${e}`))
223
+ lines.push("")
224
+ }
225
+
226
+ if (decisions.length > 0) {
227
+ lines.push("### Key Decisions")
228
+ decisions.slice(0, 5).forEach((d) => lines.push(`- ${d}`))
229
+ lines.push("")
230
+ }
231
+
232
+ const protectedContent = collectProtectedToolOutputs(messages, protectedTools)
233
+ if (protectedContent.length > 0) {
234
+ lines.push("### Protected Tool Outputs")
235
+ lines.push(protectedContent)
236
+ lines.push("")
237
+ }
238
+
239
+ // DCP protectUserMessages: the user's own instructions survive compression
240
+ // verbatim inside the summary, so nothing the user asked is ever lost to a
241
+ // lossy paraphrase.
242
+ if (protectUserMessages) {
243
+ const userTexts: string[] = []
244
+ for (const msg of messages) {
245
+ if (msg.info.role !== "user") continue
246
+ const text = getMessageText(msg)
247
+ if (text.trim().length > 0) userTexts.push(text.trim())
248
+ }
249
+ if (userTexts.length > 0) {
250
+ lines.push("### User Messages (preserved verbatim)")
251
+ userTexts.forEach((t, i) => lines.push(`- [user ${i + 1}] ${t.slice(0, 2000)}`))
252
+ lines.push("")
253
+ }
254
+ }
255
+
256
+ return lines.join("\n")
257
+ }
258
+
259
+ function collectProtectedToolOutputs(
260
+ messages: MessageWithParts[],
261
+ protectedTools: string[],
262
+ ): string {
263
+ if (protectedTools.length === 0) return ""
264
+
265
+ const resultsByCallId = new Map<string, string>()
266
+ for (const msg of messages) {
267
+ for (const part of msg.parts) {
268
+ if (part.type !== "tool-result") continue
269
+ const callId = part.toolCallID ?? part.callID
270
+ if (!callId) continue
271
+ const val = part.result?.value ?? part.result
272
+ if (val !== undefined && val !== null && part.result?.type !== "error") {
273
+ resultsByCallId.set(String(callId), String(val))
274
+ }
275
+ }
276
+ }
277
+
278
+ const output: string[] = []
279
+ for (const msg of messages) {
280
+ for (const part of msg.parts) {
281
+ if (part.type !== "tool-call") continue
282
+ const name = part.name
283
+ if (!name || !protectedTools.includes(name)) continue
284
+ const input = JSON.stringify(part.input ?? {}).slice(0, 1000)
285
+ const callId = part.toolCallID ?? part.callID
286
+ const result = callId ? resultsByCallId.get(String(callId)) : undefined
287
+ output.push(
288
+ result
289
+ ? `- [${name}] input: ${input}\n output: ${result.slice(0, 2000)}`
290
+ : `- [${name}] input: ${input}`,
291
+ )
292
+ }
293
+ }
294
+ return output.join("\n")
295
+ }
296
+
297
+ // ─── Pruning: dedup + purge errored tool inputs ────────────────────────────
298
+
299
+ /** Kept for compatibility: pure deduplication over MessageWithParts. */
6
300
  export function pruneMessages(
7
301
  messages: MessageWithParts[],
8
302
  config: SlimConfig,
@@ -10,7 +304,6 @@ export function pruneMessages(
10
304
  ): MessageWithParts[] {
11
305
  let pruned = [...messages]
12
306
 
13
- // Apply deduplication
14
307
  if (config.strategies.deduplication.enabled) {
15
308
  pruned = applyDeduplication(pruned, config.strategies.deduplication.protectedTools)
16
309
  }
@@ -18,32 +311,426 @@ export function pruneMessages(
18
311
  return pruned
19
312
  }
20
313
 
21
- function applyDeduplication(messages: MessageWithParts[], protectedTools: string[]): MessageWithParts[] {
22
- const seen = new Map<string, number>()
314
+ export function applyDeduplication(
315
+ messages: MessageWithParts[],
316
+ protectedTools: string[],
317
+ ): MessageWithParts[] {
318
+ const seen = new Set<string>()
23
319
  const toRemove = new Set<number>()
24
320
 
25
321
  for (let i = 0; i < messages.length; i++) {
26
322
  const msg = messages[i]
27
323
  const toolName = getToolName(msg)
28
324
 
29
- // Skip protected tools
30
325
  if (toolName && protectedTools.includes(toolName)) {
31
326
  continue
32
327
  }
33
328
 
34
- // Create a fingerprint of the message
35
- const text = getMessageText(msg)
36
- const toolContent = getToolResultContent(msg)
37
- const fingerprint = `${msg.info.role}:${text.slice(0, 200)}:${toolContent.slice(0, 200)}`
38
-
39
- const existingIndex = seen.get(fingerprint)
40
- if (existingIndex !== undefined) {
41
- // Mark later duplicate for removal
329
+ // Exact full-content fingerprint: only identical messages are removed.
330
+ const fingerprint = `${msg.info.role}:${JSON.stringify(msg.parts)}`
331
+ if (seen.has(fingerprint)) {
42
332
  toRemove.add(i)
43
333
  } else {
44
- seen.set(fingerprint, i)
334
+ seen.add(fingerprint)
45
335
  }
46
336
  }
47
337
 
48
338
  return messages.filter((_, i) => !toRemove.has(i))
49
339
  }
340
+
341
+ /**
342
+ * DCP purge-errors: for tool calls whose result is an error, remove the large
343
+ * string inputs once the message is at least `turns` positions behind the end
344
+ * of the conversation. Error messages themselves are preserved.
345
+ */
346
+ export function purgeStaleToolErrors(messages: any[], turns: number): void {
347
+ const n = messages.length
348
+ if (n === 0) return
349
+
350
+ const erroredCallIds = new Set<string>()
351
+ for (const msg of messages) {
352
+ for (const part of msg?.content ?? msg?.parts ?? []) {
353
+ if (part?.type !== "tool-result") continue
354
+ if (part.result?.type !== "error") continue
355
+ const callId = part.toolCallID ?? part.callID
356
+ if (callId) erroredCallIds.add(String(callId))
357
+ }
358
+ }
359
+ if (erroredCallIds.size === 0) return
360
+
361
+ const turnsEffective = Math.max(1, Math.floor(turns) || 1)
362
+ for (let i = 0; i < n; i++) {
363
+ if (i > n - turnsEffective - 1) continue // too recent — keep
364
+ const msg = messages[i]
365
+ for (const part of msg?.content ?? msg?.parts ?? []) {
366
+ if (part?.type !== "tool-call") continue
367
+ const callId = part.toolCallID ?? part.callID
368
+ if (!callId || !erroredCallIds.has(String(callId))) continue
369
+ const input = part.input
370
+ if (input && typeof input === "object") {
371
+ for (const key of Object.keys(input)) {
372
+ if (typeof input[key] === "string" && input[key].length > 80) {
373
+ input[key] = "[input removed due to failed tool call]"
374
+ }
375
+ }
376
+ }
377
+ }
378
+ }
379
+ }
380
+
381
+ /** In-place dedup over raw outgoing messages; returns the keep count. */
382
+ export function pruneInPlace(messages: any[], config: SlimConfig): void {
383
+ if (!config.strategies.deduplication.enabled) return
384
+
385
+ const protectedTools = config.strategies.deduplication.protectedTools
386
+ const seen = new Set<string>()
387
+ const toRemove = new Set<number>()
388
+
389
+ for (let i = 0; i < messages.length; i++) {
390
+ const msg = messages[i] as any
391
+ const content = msg?.content ?? msg?.parts ?? []
392
+
393
+ // Protected tools (and messages carrying them) are never deduplicated.
394
+ let toolName: string | null = null
395
+ for (const part of content) {
396
+ if (part?.type === "tool-call") {
397
+ toolName = toolName ?? part.name ?? null
398
+ }
399
+ }
400
+ if (toolName && protectedTools.includes(toolName)) continue
401
+
402
+ // Exact full-content fingerprint — only truly identical messages are
403
+ // removed. Truncated fingerprints would eat distinct messages that share
404
+ // a common prefix.
405
+ const fingerprint = `${msg?.role}:${JSON.stringify(content)}`
406
+ if (seen.has(fingerprint)) {
407
+ toRemove.add(i)
408
+ } else {
409
+ seen.add(fingerprint)
410
+ }
411
+ }
412
+
413
+ if (toRemove.size === 0) return
414
+ const kept = messages.filter((_, i) => !toRemove.has(i))
415
+ messages.splice(0, messages.length, ...kept)
416
+ }
417
+
418
+ // ─── DCP limit rules → anchored nudges ─────────────────────────────────────
419
+
420
+ export function messageHasCompress(msg: any): boolean {
421
+ const content = msg?.content ?? msg?.parts ?? []
422
+ return content.some(
423
+ (part: any) => part?.type === "tool-call" && part?.name === "compress",
424
+ )
425
+ }
426
+
427
+ export function findLastUserMessage(messages: any[]): any | undefined {
428
+ for (let i = messages.length - 1; i >= 0; i--) {
429
+ if (messages[i]?.role === "user") return messages[i]
430
+ }
431
+ return undefined
432
+ }
433
+
434
+ function getNudgeFrequency(config: SlimConfig): number {
435
+ return Math.max(1, Math.floor(config.compress.nudgeFrequency || 1))
436
+ }
437
+
438
+ function getIterationThreshold(config: SlimConfig): number {
439
+ return Math.max(1, Math.floor(config.compress.iterationNudgeThreshold || 1))
440
+ }
441
+
442
+ function addAnchor(
443
+ anchors: string[],
444
+ messageId: string | undefined,
445
+ index: number,
446
+ messages: any[],
447
+ interval: number,
448
+ ): boolean {
449
+ if (!messageId || index < 0) return false
450
+
451
+ let latestAnchorIndex = -1
452
+ for (let i = messages.length - 1; i >= 0; i--) {
453
+ const m = messages[i] as any
454
+ const id = m?.id ?? m?.info?.id
455
+ if (typeof id === "string" && anchors.includes(id)) {
456
+ latestAnchorIndex = i
457
+ break
458
+ }
459
+ }
460
+
461
+ const shouldAdd = latestAnchorIndex < 0 || index - latestAnchorIndex >= interval
462
+ if (!shouldAdd) return false
463
+
464
+ if (!anchors.includes(messageId)) {
465
+ anchors.push(messageId)
466
+ return true
467
+ }
468
+ return false
469
+ }
470
+
471
+ function addSpecificAnchor(anchors: string[], messageId: string | undefined): void {
472
+ if (messageId && !anchors.includes(messageId)) {
473
+ anchors.push(messageId)
474
+ }
475
+ }
476
+
477
+ function messageHasNudge(msg: any, marker: string): boolean {
478
+ const content = msg?.content ?? msg?.parts ?? []
479
+ return content.some(
480
+ (part: any) => part?.type === "text" && typeof part.text === "string" && part.text.includes(marker),
481
+ )
482
+ }
483
+
484
+ function appendToMessage(msg: any, nudgeText: string): void {
485
+ const content = (msg?.content ?? msg?.parts ?? []) as any[]
486
+ for (const part of content) {
487
+ if (part?.type === "text") {
488
+ part.text = `${part.text}\n\n${nudgeText}`
489
+ return
490
+ }
491
+ }
492
+ content.push({ type: "text", text: nudgeText })
493
+ }
494
+
495
+ /**
496
+ * DCP limit rules: compare current usage against maxContextLimit /
497
+ * minContextLimit and anchor nudges so the model is pushed to compress at most
498
+ * once per nudgeFrequency messages. If the last assistant turn already ran the
499
+ * compress tool, all anchors are cleared.
500
+ */
501
+ export function injectLimitNudges(
502
+ state: SessionState,
503
+ config: SlimConfig,
504
+ messages: any[],
505
+ currentTokens: number,
506
+ limits: { max: number; min: number },
507
+ ): void {
508
+ if (config.compress.permission === "deny") return
509
+ if (state.manualMode) return
510
+ if (messages.length === 0) return
511
+
512
+ const nudges = state.nudges ?? {
513
+ contextLimitAnchors: [],
514
+ turnNudgeAnchors: [],
515
+ iterationNudgeAnchors: [],
516
+ }
517
+
518
+ const lastAssistant = [...messages].reverse().find((m) => (m as any)?.role === "assistant")
519
+ if (lastAssistant && messageHasCompress(lastAssistant)) {
520
+ nudges.contextLimitAnchors = []
521
+ nudges.turnNudgeAnchors = []
522
+ nudges.iterationNudgeAnchors = []
523
+ state.nudges = nudges
524
+ return
525
+ }
526
+
527
+ const overMax = limits.max > 0 && currentTokens > limits.max
528
+ const overMin = limits.min > 0 && currentTokens >= limits.min
529
+
530
+ if (!overMin) {
531
+ if (nudges.turnNudgeAnchors.length > 0 || nudges.iterationNudgeAnchors.length > 0) {
532
+ nudges.turnNudgeAnchors = []
533
+ nudges.iterationNudgeAnchors = []
534
+ }
535
+ }
536
+
537
+ const lastIndex = messages.length - 1
538
+ const lastMessage = messages[lastIndex] as any
539
+ const lastMessageId = lastMessage?.id ?? lastMessage?.info?.id
540
+
541
+ if (overMax) {
542
+ addAnchor(
543
+ nudges.contextLimitAnchors,
544
+ lastMessageId,
545
+ lastIndex,
546
+ messages,
547
+ getNudgeFrequency(config),
548
+ )
549
+ } else if (overMin) {
550
+ // Turn nudge: fire at a user/assistant turn boundary.
551
+ if (lastMessage?.role === "user" && lastAssistant) {
552
+ addSpecificAnchor(nudges.turnNudgeAnchors, lastMessageId)
553
+ const lastAssistantId = lastAssistant?.id ?? lastAssistant?.info?.id
554
+ addSpecificAnchor(nudges.turnNudgeAnchors, lastAssistantId)
555
+ }
556
+
557
+ // Iteration nudge: too many messages since the last user request.
558
+ const lastUserIndex = messages.findIndex((m) => (m as any)?.role === "user")
559
+ if (lastUserIndex >= 0 && lastIndex > lastUserIndex) {
560
+ const sinceUser = lastIndex - lastUserIndex
561
+ if (sinceUser >= getIterationThreshold(config)) {
562
+ addAnchor(
563
+ nudges.iterationNudgeAnchors,
564
+ lastMessageId,
565
+ lastIndex,
566
+ messages,
567
+ getNudgeFrequency(config),
568
+ )
569
+ }
570
+ }
571
+ }
572
+
573
+ const percent = limits.max > 0 ? Math.round((currentTokens / limits.max) * 100) : 0
574
+ // DCP nudgeForce: "soft" anchors the turn nudge on the assistant message,
575
+ // "strong" on the user message.
576
+ const targetRole = config.compress.nudgeForce === "strong" ? "user" : "assistant"
577
+
578
+ const injectForAnchors = (anchors: string[], marker: string, text: string, roleFilter?: string) => {
579
+ if (!text) return
580
+ for (const anchorId of anchors) {
581
+ const msg = messages.find((m) => {
582
+ const id = (m as any)?.id ?? (m as any)?.info?.id
583
+ return id === anchorId
584
+ })
585
+ if (!msg) continue
586
+ if (roleFilter && (msg as any)?.role !== roleFilter) continue
587
+ // Idempotency via stable marker: the dynamic part of the nudge
588
+ // (percentages) changes every request, so match on the marker only.
589
+ if (messageHasNudge(msg, marker)) continue
590
+ appendToMessage(msg, text)
591
+ }
592
+ }
593
+
594
+ injectForAnchors(
595
+ nudges.contextLimitAnchors,
596
+ NUDGE_MARKERS.contextLimit,
597
+ contextLimitNudge(percent, limits.max),
598
+ )
599
+ injectForAnchors(
600
+ nudges.turnNudgeAnchors,
601
+ NUDGE_MARKERS.turn,
602
+ turnNudge(percent),
603
+ targetRole,
604
+ )
605
+ injectForAnchors(
606
+ nudges.iterationNudgeAnchors,
607
+ NUDGE_MARKERS.iteration,
608
+ iterationNudge(percent),
609
+ )
610
+
611
+ state.nudges = nudges
612
+ }
613
+
614
+ // ─── Auto-compress: directly compress when over limit ───────────────────────
615
+
616
+ /**
617
+ * Automatically compresses old messages when context exceeds the max limit.
618
+ * Called from the context hook when overMax is true — no model cooperation needed.
619
+ * Registers a compression block so future requests use the summary instead.
620
+ */
621
+ export async function autoCompress(
622
+ state: SessionState,
623
+ config: SlimConfig,
624
+ messages: any[],
625
+ currentTokens: number,
626
+ limits: { max: number; min: number },
627
+ ): Promise<{ compressed: boolean; messageCount?: number; tokensSaved?: number }> {
628
+ if (config.compress.permission === "deny") return { compressed: false }
629
+ if (state.manualMode) return { compressed: false }
630
+ if (limits.max <= 0) return { compressed: false }
631
+ if (currentTokens <= limits.max) return { compressed: false }
632
+
633
+ // Throttle: don't auto-compress more than once every 5 minutes
634
+ const now = Date.now()
635
+ const lastAuto = (state as any).lastAutoCompressTime ?? 0
636
+ if (now - lastAuto < 5 * 60 * 1000) return { compressed: false }
637
+
638
+ // Don't auto-compress if the model just compressed in the last assistant turn
639
+ const lastAssistant = [...messages].reverse().find((m: any) => m?.role === "assistant")
640
+ if (lastAssistant && messageHasCompress(lastAssistant)) return { compressed: false }
641
+
642
+ const keepRecent = Math.max(2, config.compress.keepRecent ?? 5)
643
+ const messageWithParts: MessageWithParts[] = messages.map((m: any) => ({
644
+ info: {
645
+ id: m?.id ?? m?.info?.id ?? "",
646
+ role: m?.role ?? m?.info?.role ?? "user",
647
+ sessionID: m?.sessionID ?? m?.info?.sessionID ?? "",
648
+ time: { created: Date.now() },
649
+ } as any,
650
+ parts: m?.parts ?? m?.content ?? [],
651
+ }))
652
+
653
+ // Select messages to compress: all except recent ones, with >100 tokens
654
+ const targetIndices: number[] = []
655
+ let inputTokens = 0
656
+ for (let i = 0; i < messageWithParts.length - keepRecent; i++) {
657
+ const msg = messageWithParts[i]
658
+ const text = getMessageText(msg) + getToolResultContent(msg)
659
+ const tokens = await countTokens(text)
660
+ if (tokens < 100) continue
661
+ targetIndices.push(i)
662
+ inputTokens += tokens
663
+ }
664
+
665
+ if (targetIndices.length === 0) {
666
+ ;(state as any).lastAutoCompressTime = now
667
+ return { compressed: false }
668
+ }
669
+
670
+ // Build summary
671
+ const targetMessages = targetIndices.map((i) => messageWithParts[i])
672
+ const summary = await buildCompressionSummary(
673
+ targetMessages,
674
+ "auto-compress: context limit exceeded",
675
+ config.compress.protectedTools,
676
+ config.compress.protectUserMessages,
677
+ )
678
+ const outputTokens = await countTokens(summary)
679
+
680
+ // Register compression block
681
+ const sorted = [...targetIndices].sort((a, b) => a - b)
682
+ const coveredIndices = new Set(sorted)
683
+ let anchorIndex = sorted[sorted.length - 1] + 1
684
+ if (anchorIndex >= messageWithParts.length) {
685
+ anchorIndex = messageWithParts.length - 1
686
+ coveredIndices.delete(anchorIndex)
687
+ }
688
+ if (anchorIndex < 0 || anchorIndex >= messageWithParts.length) {
689
+ ;(state as any).lastAutoCompressTime = now
690
+ return { compressed: false }
691
+ }
692
+
693
+ const anchorId = messageWithParts[anchorIndex].info?.id
694
+ if (!anchorId) {
695
+ ;(state as any).lastAutoCompressTime = now
696
+ return { compressed: false }
697
+ }
698
+
699
+ const coveredIds = [...coveredIndices]
700
+ .map((i) => messageWithParts[i].info?.id)
701
+ .filter((id): id is string => typeof id === "string" && id.length > 0)
702
+ if (coveredIds.length === 0) {
703
+ ;(state as any).lastAutoCompressTime = now
704
+ return { compressed: false }
705
+ }
706
+
707
+ registerCompressionBlock(state, {
708
+ coveredIds,
709
+ anchorMessageId: anchorId,
710
+ summary,
711
+ topic: "auto-compress",
712
+ summaryTokens: outputTokens,
713
+ })
714
+
715
+ // Record compression stats
716
+ const ratio = inputTokens > 0 ? 1 - outputTokens / inputTokens : 0
717
+ addCompressionRecord(
718
+ state,
719
+ {
720
+ timestamp: now,
721
+ inputTokens,
722
+ outputTokens,
723
+ ratio,
724
+ messageCount: targetMessages.length,
725
+ success: true,
726
+ },
727
+ config.adaptive.learningRate,
728
+ )
729
+
730
+ ;(state as any).lastAutoCompressTime = now
731
+ return {
732
+ compressed: true,
733
+ messageCount: targetMessages.length,
734
+ tokensSaved: inputTokens - outputTokens,
735
+ }
736
+ }