@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.
- package/package.json +1 -1
- package/src/index.ts +138 -122
- package/src/lib/config.ts +57 -8
- package/src/lib/prompts.ts +28 -12
- package/src/lib/state.ts +25 -2
- package/src/lib/strategies.ts +704 -17
- package/src/lib/types.ts +52 -3
package/src/lib/strategies.ts
CHANGED
|
@@ -1,8 +1,302 @@
|
|
|
1
|
-
import type { MessageWithParts, SlimConfig } from "./types"
|
|
2
|
-
import { getToolName, getToolResultContent,
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
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(
|
|
22
|
-
|
|
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
|
-
//
|
|
35
|
-
const
|
|
36
|
-
|
|
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.
|
|
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
|
+
}
|