@serkanalgur/opencodev2-slim 2.0.13 → 2.0.14
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 +125 -121
- package/src/lib/config.ts +56 -8
- package/src/lib/prompts.ts +28 -12
- package/src/lib/state.ts +25 -2
- package/src/lib/strategies.ts +579 -17
- package/src/lib/types.ts +50 -3
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin"
|
|
2
|
-
import { loadConfig, createDefaultConfig, resolveTokenLimit } from "./lib/config"
|
|
3
2
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
3
|
+
loadConfig,
|
|
4
|
+
createDefaultConfig,
|
|
5
|
+
resolveTokenLimit,
|
|
6
|
+
resolveCompressLimits,
|
|
7
|
+
} from "./lib/config"
|
|
8
|
+
import { loadSessionState, saveSessionState, addCompressionRecord } from "./lib/state"
|
|
9
|
+
import { countTokens, getMessageText, getToolResultContent } from "./lib/compress"
|
|
10
|
+
import {
|
|
11
|
+
syncCompressionBlocks,
|
|
12
|
+
applyCompressedRanges,
|
|
13
|
+
registerCompressionBlock,
|
|
14
|
+
buildCompressionSummary,
|
|
15
|
+
purgeStaleToolErrors,
|
|
16
|
+
pruneInPlace,
|
|
17
|
+
injectLimitNudges,
|
|
18
|
+
findLastUserMessage,
|
|
19
|
+
} from "./lib/strategies"
|
|
20
|
+
import { getSystemPrompt, getCompressToolDescription } from "./lib/prompts"
|
|
11
21
|
import { buildPanelData, renderPanel } from "./lib/tui"
|
|
12
|
-
import type { SlimConfig, SessionState, MessageWithParts } from "./lib/types"
|
|
22
|
+
import type { SlimConfig, SessionState, MessageWithParts, CompressionBlock } from "./lib/types"
|
|
13
23
|
|
|
14
24
|
// ─── State Management ───────────────────────────────────────────────────────
|
|
15
25
|
|
|
@@ -58,70 +68,59 @@ function stringifyTranscript(v: unknown): string {
|
|
|
58
68
|
|
|
59
69
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
60
70
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (toolPart.result?.type === "error") {
|
|
83
|
-
errors.push(String(toolPart.result.value).slice(0, 200) || "Unknown error")
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
if (part.type === "text") {
|
|
87
|
-
const textPart = part as any
|
|
88
|
-
const text = textPart.text || ""
|
|
89
|
-
if (
|
|
90
|
-
text.includes("decided") ||
|
|
91
|
-
text.includes("chose") ||
|
|
92
|
-
text.includes("implemented")
|
|
93
|
-
) {
|
|
94
|
-
decisions.push(text.slice(0, 200))
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
71
|
+
// Register a DCP-style compression block for the selected range. The range is
|
|
72
|
+
// covered (removed from future outgoing requests) and the summary is injected
|
|
73
|
+
// at the anchor: the first message after the range, or the latest message when
|
|
74
|
+
// the range reaches the end (the active user turn is never replaced).
|
|
75
|
+
function registerBlockForRange(
|
|
76
|
+
state: SessionState,
|
|
77
|
+
topic: string,
|
|
78
|
+
messageWithParts: MessageWithParts[],
|
|
79
|
+
targetIndices: number[],
|
|
80
|
+
summary: string,
|
|
81
|
+
summaryTokens: number,
|
|
82
|
+
): CompressionBlock | null {
|
|
83
|
+
if (targetIndices.length === 0 || messageWithParts.length === 0) return null
|
|
84
|
+
|
|
85
|
+
const sorted = [...targetIndices].sort((a, b) => a - b)
|
|
86
|
+
const coveredIndices = new Set(sorted)
|
|
87
|
+
|
|
88
|
+
let anchorIndex = sorted[sorted.length - 1] + 1
|
|
89
|
+
if (anchorIndex >= messageWithParts.length) {
|
|
90
|
+
anchorIndex = messageWithParts.length - 1
|
|
91
|
+
coveredIndices.delete(anchorIndex)
|
|
98
92
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return lines.join("\n")
|
|
93
|
+
if (anchorIndex < 0 || anchorIndex >= messageWithParts.length) return null
|
|
94
|
+
|
|
95
|
+
const anchorId = messageWithParts[anchorIndex].info?.id
|
|
96
|
+
if (!anchorId) return null
|
|
97
|
+
|
|
98
|
+
const coveredIds = [...coveredIndices]
|
|
99
|
+
.map((i) => messageWithParts[i].info?.id)
|
|
100
|
+
.filter((id): id is string => typeof id === "string" && id.length > 0)
|
|
101
|
+
if (coveredIds.length === 0) return null
|
|
102
|
+
|
|
103
|
+
return registerCompressionBlock(state, {
|
|
104
|
+
coveredIds,
|
|
105
|
+
anchorMessageId: anchorId,
|
|
106
|
+
summary,
|
|
107
|
+
topic,
|
|
108
|
+
summaryTokens,
|
|
109
|
+
})
|
|
119
110
|
}
|
|
120
111
|
|
|
121
112
|
function wrapAsMessageWithParts(msg: any): MessageWithParts {
|
|
113
|
+
const msgInfo = msg.info
|
|
114
|
+
const id = (msg && (msg.id || msgInfo?.id)) || ""
|
|
115
|
+
const role = (msg && (msg.role || msgInfo?.role)) || "user"
|
|
122
116
|
return {
|
|
123
|
-
info:
|
|
124
|
-
|
|
117
|
+
info: {
|
|
118
|
+
id,
|
|
119
|
+
role,
|
|
120
|
+
sessionID: (msg && (msg.sessionID || msgInfo?.sessionID)) || "",
|
|
121
|
+
time: { created: Date.now() },
|
|
122
|
+
} as any,
|
|
123
|
+
parts: (msg && (msg.parts || msg.content)) || [],
|
|
125
124
|
}
|
|
126
125
|
}
|
|
127
126
|
|
|
@@ -241,10 +240,34 @@ export default Plugin.define({
|
|
|
241
240
|
}
|
|
242
241
|
|
|
243
242
|
const targetMessages = targetIndices.map((i) => messageWithParts[i])
|
|
244
|
-
const summary = buildCompressionSummary(
|
|
243
|
+
const summary = await buildCompressionSummary(
|
|
244
|
+
targetMessages,
|
|
245
|
+
args.focus,
|
|
246
|
+
config.compress.protectedTools,
|
|
247
|
+
config.compress.protectUserMessages,
|
|
248
|
+
)
|
|
245
249
|
const outputTokens = await countTokens(summary)
|
|
246
250
|
const ratio = inputTokens > 0 ? 1 - outputTokens / inputTokens : 0
|
|
247
251
|
|
|
252
|
+
// DCP: register a compression block so future outgoing
|
|
253
|
+
// requests replace this range with the summary.
|
|
254
|
+
let blockNote = ""
|
|
255
|
+
try {
|
|
256
|
+
const block = registerBlockForRange(
|
|
257
|
+
state,
|
|
258
|
+
args.focus,
|
|
259
|
+
messageWithParts,
|
|
260
|
+
targetIndices,
|
|
261
|
+
summary,
|
|
262
|
+
outputTokens,
|
|
263
|
+
)
|
|
264
|
+
if (block) {
|
|
265
|
+
blockNote = `\n\n_Block #${block.blockId}: ${block.coveredMessageIds.length} messages will collapse into this summary on future requests (${Math.round((1 - outputTokens / Math.max(1, inputTokens)) * 100)}% smaller)._\n_To restore them: ask to reset context._`
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
// Best-effort: the summary is still returned to the model.
|
|
269
|
+
}
|
|
270
|
+
|
|
248
271
|
addCompressionRecord(
|
|
249
272
|
state,
|
|
250
273
|
{
|
|
@@ -261,7 +284,7 @@ export default Plugin.define({
|
|
|
261
284
|
saveSessionState(state, config.persistence.directory)
|
|
262
285
|
|
|
263
286
|
return {
|
|
264
|
-
content: `## Compressed ${targetMessages.length} messages\n\n${summary}\n\n---\n**Stats:** ${inputTokens} → ${outputTokens} tokens (${Math.round(ratio * 100)}% saved) | Mode: ${mode} | Focus: ${args.focus}`,
|
|
287
|
+
content: `## Compressed ${targetMessages.length} messages\n\n${summary}\n\n---\n**Stats:** ${inputTokens} → ${outputTokens} tokens (${Math.round(ratio * 100)}% saved) | Mode: ${mode} | Focus: ${args.focus}${blockNote}`,
|
|
265
288
|
}
|
|
266
289
|
} catch (error) {
|
|
267
290
|
return {
|
|
@@ -363,6 +386,10 @@ export default Plugin.define({
|
|
|
363
386
|
})
|
|
364
387
|
|
|
365
388
|
// ─── Messages Transform Hook (sync) ──────────────────────────────
|
|
389
|
+
// DCP pipeline for every outgoing request: sync compression blocks,
|
|
390
|
+
// replace covered ranges with summary placeholders, prune (dedup +
|
|
391
|
+
// purge errored tool inputs), then apply DCP limit rules as anchored
|
|
392
|
+
// nudges. Session history is never modified — only this request.
|
|
366
393
|
await ctx.session.hook("context", (event) => {
|
|
367
394
|
const sessionId = event.sessionID
|
|
368
395
|
const config = getConfig(sessionId)
|
|
@@ -371,72 +398,49 @@ export default Plugin.define({
|
|
|
371
398
|
const state = getState(sessionId, config)
|
|
372
399
|
state.modelContextLimit = sessionModelLimits.get(sessionId) || initialModelLimit
|
|
373
400
|
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
401
|
+
// 1) Compression blocks: activate/deactivate and replace ranges.
|
|
402
|
+
const presentIds = new Set<string>()
|
|
403
|
+
for (const msg of event.messages) {
|
|
404
|
+
const id = (msg as any)?.id ?? (msg as any)?.info?.id
|
|
405
|
+
if (typeof id === "string") presentIds.add(id)
|
|
406
|
+
}
|
|
407
|
+
syncCompressionBlocks(state, presentIds)
|
|
408
|
+
const filtered = applyCompressedRanges(state, event.messages)
|
|
409
|
+
event.messages.splice(0, event.messages.length, ...filtered)
|
|
410
|
+
|
|
411
|
+
// 2) Pruning strategies (each request).
|
|
412
|
+
pruneInPlace(event.messages, config)
|
|
413
|
+
if (config.strategies.purgeErrors.enabled) {
|
|
414
|
+
purgeStaleToolErrors(event.messages, config.strategies.purgeErrors.turns)
|
|
389
415
|
}
|
|
390
416
|
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
// fallback; afterwards we prefer the server-measured value.
|
|
417
|
+
// 3) Token accounting: prefer the server-measured count; fall back
|
|
418
|
+
// to a quick estimate (~4 chars per token).
|
|
394
419
|
let estimatedTokens = 0
|
|
395
420
|
for (const msg of event.messages) {
|
|
396
|
-
const content = (msg as any)
|
|
421
|
+
const content = (msg as any)?.content ?? (msg as any)?.parts
|
|
397
422
|
if (Array.isArray(content)) {
|
|
398
423
|
for (const part of content) {
|
|
399
|
-
if (part
|
|
424
|
+
if (part?.type === "text" && part.text) {
|
|
400
425
|
estimatedTokens += Math.ceil(part.text.length / 4)
|
|
401
426
|
}
|
|
402
427
|
}
|
|
403
428
|
}
|
|
404
429
|
}
|
|
405
|
-
|
|
406
|
-
// Prefer the real measured token count when available; else the estimate.
|
|
407
430
|
const totalTokens =
|
|
408
431
|
state.currentTokenCount > 0 ? state.currentTokenCount : estimatedTokens
|
|
409
432
|
state.currentTokenCount = totalTokens
|
|
410
433
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
totalTokens,
|
|
422
|
-
maxTokens,
|
|
423
|
-
minTokens,
|
|
424
|
-
state.lastCompressionTime,
|
|
425
|
-
config.compress.nudgeFrequency,
|
|
426
|
-
event.messages.length,
|
|
427
|
-
)
|
|
428
|
-
|
|
429
|
-
if (shouldComp.compress && !state.manualMode) {
|
|
430
|
-
const nudgeMessage = getNudgeMessage(
|
|
431
|
-
shouldComp.reason,
|
|
432
|
-
totalTokens,
|
|
433
|
-
maxTokens,
|
|
434
|
-
)
|
|
435
|
-
event.messages.push({
|
|
436
|
-
role: "assistant",
|
|
437
|
-
content: [{ type: "text", text: nudgeMessage }],
|
|
438
|
-
} as any)
|
|
439
|
-
}
|
|
434
|
+
// 4) DCP limit rules → anchored nudges (max 100k / min 50k by
|
|
435
|
+
// default, model overrides supported via modelMax/MinLimits).
|
|
436
|
+
const lastUser = findLastUserMessage(event.messages)
|
|
437
|
+
const providerId =
|
|
438
|
+
lastUser?.model?.providerID ?? lastUser?.model?.id?.split?.("/")[0]
|
|
439
|
+
const modelId =
|
|
440
|
+
lastUser?.model?.modelID ??
|
|
441
|
+
lastUser?.model?.id?.split?.("/").slice(1).join("/")
|
|
442
|
+
const limits = resolveCompressLimits(config, state, providerId, modelId)
|
|
443
|
+
injectLimitNudges(state, config, event.messages, totalTokens, limits)
|
|
440
444
|
|
|
441
445
|
saveSessionState(state, config.persistence.directory)
|
|
442
446
|
})
|
package/src/lib/config.ts
CHANGED
|
@@ -2,17 +2,21 @@ import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs"
|
|
|
2
2
|
import { join } from "path"
|
|
3
3
|
import { homedir } from "os"
|
|
4
4
|
import { parse } from "jsonc-parser/lib/esm/main.js"
|
|
5
|
-
import type { SlimConfig } from "./types"
|
|
5
|
+
import type { SlimConfig, SessionState } from "./types"
|
|
6
6
|
|
|
7
7
|
const DEFAULT_CONFIG: SlimConfig = {
|
|
8
8
|
enabled: true,
|
|
9
9
|
debug: false,
|
|
10
10
|
compress: {
|
|
11
11
|
enabled: true,
|
|
12
|
+
mode: "range",
|
|
12
13
|
permission: "allow",
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
// DCP defaults: absolute token counts (not percentages).
|
|
15
|
+
maxContextLimit: 100000,
|
|
16
|
+
minContextLimit: 50000,
|
|
15
17
|
nudgeFrequency: 5,
|
|
18
|
+
iterationNudgeThreshold: 15,
|
|
19
|
+
nudgeForce: "soft",
|
|
16
20
|
protectUserMessages: false,
|
|
17
21
|
protectedTools: ["task", "skill", "todowrite", "todoread"],
|
|
18
22
|
},
|
|
@@ -46,7 +50,12 @@ function deepMerge(base: SlimConfig, override: Partial<SlimConfig>): SlimConfig
|
|
|
46
50
|
return {
|
|
47
51
|
...base,
|
|
48
52
|
...override,
|
|
49
|
-
compress: {
|
|
53
|
+
compress: {
|
|
54
|
+
...base.compress,
|
|
55
|
+
...override.compress,
|
|
56
|
+
modelMaxLimits: override.compress?.modelMaxLimits ?? base.compress.modelMaxLimits,
|
|
57
|
+
modelMinLimits: override.compress?.modelMinLimits ?? base.compress.modelMinLimits,
|
|
58
|
+
},
|
|
50
59
|
strategies: {
|
|
51
60
|
deduplication: { ...base.strategies.deduplication, ...override.strategies?.deduplication },
|
|
52
61
|
purgeErrors: { ...base.strategies.purgeErrors, ...override.strategies?.purgeErrors },
|
|
@@ -101,14 +110,17 @@ export function createDefaultConfig(): void {
|
|
|
101
110
|
writeFileSync(
|
|
102
111
|
configPath,
|
|
103
112
|
`{
|
|
104
|
-
// Slim Configuration
|
|
113
|
+
// Slim Configuration (DCP-compatible limit rules)
|
|
105
114
|
"enabled": true,
|
|
106
115
|
"compress": {
|
|
107
116
|
"enabled": true,
|
|
117
|
+
"mode": "range",
|
|
108
118
|
"permission": "allow",
|
|
109
|
-
"maxContextLimit":
|
|
110
|
-
"minContextLimit":
|
|
111
|
-
"nudgeFrequency": 5
|
|
119
|
+
"maxContextLimit": 100000,
|
|
120
|
+
"minContextLimit": 50000,
|
|
121
|
+
"nudgeFrequency": 5,
|
|
122
|
+
"iterationNudgeThreshold": 15,
|
|
123
|
+
"nudgeForce": "soft"
|
|
112
124
|
}
|
|
113
125
|
}`,
|
|
114
126
|
"utf-8",
|
|
@@ -124,3 +136,39 @@ export function resolveTokenLimit(value: number | string, contextLimit: number):
|
|
|
124
136
|
const percent = parseFloat(value.replace("%", "")) / 100
|
|
125
137
|
return Math.floor(contextLimit * percent)
|
|
126
138
|
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* DCP limit resolution. Prefers per-model overrides (compress.modelMinLimits /
|
|
142
|
+
* compress.modelMaxLimits keyed by "providerId/modelId"), then falls back to the
|
|
143
|
+
* global max/min limit. Percent strings resolve against the model's context window.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveCompressLimits(
|
|
146
|
+
config: SlimConfig,
|
|
147
|
+
state: SessionState,
|
|
148
|
+
providerId?: string,
|
|
149
|
+
modelId?: string,
|
|
150
|
+
): { max: number; min: number } {
|
|
151
|
+
const parseLimit = (value: number | string | undefined): number => {
|
|
152
|
+
if (value === undefined) {
|
|
153
|
+
return 0
|
|
154
|
+
}
|
|
155
|
+
if (typeof value === "number") {
|
|
156
|
+
return value
|
|
157
|
+
}
|
|
158
|
+
const pct = parseFloat(value.replace("%", ""))
|
|
159
|
+
if (Number.isNaN(pct)) {
|
|
160
|
+
return 0
|
|
161
|
+
}
|
|
162
|
+
return Math.round((Math.max(0, Math.min(100, pct)) / 100) * state.modelContextLimit)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const providerModel = providerId && modelId ? `${providerId}/${modelId}` : undefined
|
|
166
|
+
|
|
167
|
+
const modelMin = providerModel ? config.compress.modelMinLimits?.[providerModel] : undefined
|
|
168
|
+
const modelMax = providerModel ? config.compress.modelMaxLimits?.[providerModel] : undefined
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
max: parseLimit(modelMax ?? config.compress.maxContextLimit),
|
|
172
|
+
min: parseLimit(modelMin ?? config.compress.minContextLimit),
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/lib/prompts.ts
CHANGED
|
@@ -6,12 +6,17 @@ You have access to context management tools. Use them wisely:
|
|
|
6
6
|
|
|
7
7
|
### compress tool
|
|
8
8
|
Use \`compress\` to reduce context size when it gets large. It supports:
|
|
9
|
+
- Range mode: Compress a specific message range (start/end indices) into one summary
|
|
10
|
+
- Topic mode: Compress messages matching a topic keyword
|
|
9
11
|
- Auto mode: Intelligently selects what to compress
|
|
10
|
-
- Range mode: Compress specific message range
|
|
11
|
-
- Topic mode: Compress messages matching a topic
|
|
12
12
|
|
|
13
13
|
Example: \`compress({ focus: "old exploration" })\`
|
|
14
14
|
|
|
15
|
+
Compressed ranges are replaced by their summary on outgoing requests, so the
|
|
16
|
+
model keeps the essential information while token usage drops on every
|
|
17
|
+
subsequent request. Protected tool results (task, skill, todowrite, todoread)
|
|
18
|
+
are preserved inside the summary.
|
|
19
|
+
|
|
15
20
|
### panel tool
|
|
16
21
|
Use \`panel\` to view current context usage and statistics.
|
|
17
22
|
|
|
@@ -30,19 +35,30 @@ export function getCompressToolDescription(): string {
|
|
|
30
35
|
- Range mode: Compress specific message range (start/end indices)
|
|
31
36
|
- Topic mode: Compress messages matching a topic keyword
|
|
32
37
|
|
|
33
|
-
The compression creates a summary preserving key information
|
|
38
|
+
The compression creates a summary preserving key information (including
|
|
39
|
+
protected tool outputs) and replaces the selected messages with that summary on
|
|
40
|
+
future requests, so the context stays small.`
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
// ─── DCP-style limit nudges ────────────────────────────────────────────────
|
|
44
|
+
//
|
|
45
|
+
// Every nudge carries a stable marker so the pipeline can detect an existing
|
|
46
|
+
// nudge regardless of its dynamic content (percentages change every request).
|
|
39
47
|
|
|
40
|
-
|
|
48
|
+
export const NUDGE_MARKERS = {
|
|
49
|
+
contextLimit: "[[slim:context-limit]]",
|
|
50
|
+
turn: "[[slim:turn]]",
|
|
51
|
+
iteration: "[[slim:iteration]]",
|
|
52
|
+
} as const
|
|
41
53
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
\`\`\`
|
|
54
|
+
export function contextLimitNudge(percent: number, maxTokens: number): string {
|
|
55
|
+
return `${NUDGE_MARKERS.contextLimit}\n\n> ⚠️ **Context at capacity: ${percent}% of ${maxTokens.toLocaleString()} tokens.** Older completed work should be compressed now to keep the session efficient. Call \`compress\` with a focus describing the oldest exchange, e.g. \`compress({ focus: "initial exploration" })\`. Past ranges are replaced by summaries to avoid re-sending tokens.`
|
|
56
|
+
}
|
|
46
57
|
|
|
47
|
-
|
|
58
|
+
export function turnNudge(percent: number): string {
|
|
59
|
+
return `${NUDGE_MARKERS.turn}\n\n> 💡 **Context is getting large (${percent}% of limit).** When you finish the current task, consider calling \`compress\` on the completed portion to keep the session fast and cheap.`
|
|
48
60
|
}
|
|
61
|
+
|
|
62
|
+
export function iterationNudge(percent: number): string {
|
|
63
|
+
return `${NUDGE_MARKERS.iteration}\n\n> 💡 **Many tool iterations since the last user message (${percent}% of limit used).** If the explored subtree is done, call \`compress({ focus: "exploration so far" })\` to replace it with a summary.`
|
|
64
|
+
}
|
package/src/lib/state.ts
CHANGED
|
@@ -13,6 +13,29 @@ const DEFAULT_STATE: SessionState = {
|
|
|
13
13
|
compressionHistory: [],
|
|
14
14
|
averageCompressionRatio: 0,
|
|
15
15
|
toolCalls: new Map(),
|
|
16
|
+
compressionBlocks: [],
|
|
17
|
+
nextBlockId: 1,
|
|
18
|
+
nudges: { contextLimitAnchors: [], turnNudgeAnchors: [], iterationNudgeAnchors: [] },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeState(state: SessionState): SessionState {
|
|
22
|
+
state.compressionBlocks = Array.isArray(state.compressionBlocks) ? state.compressionBlocks : []
|
|
23
|
+
state.nextBlockId =
|
|
24
|
+
typeof state.nextBlockId === "number" && state.nextBlockId > 0
|
|
25
|
+
? state.nextBlockId
|
|
26
|
+
: state.compressionBlocks.reduce((max, b) => Math.max(max, b.blockId), 0) + 1
|
|
27
|
+
state.nudges = {
|
|
28
|
+
contextLimitAnchors: Array.isArray(state.nudges?.contextLimitAnchors)
|
|
29
|
+
? state.nudges.contextLimitAnchors
|
|
30
|
+
: [],
|
|
31
|
+
turnNudgeAnchors: Array.isArray(state.nudges?.turnNudgeAnchors)
|
|
32
|
+
? state.nudges.turnNudgeAnchors
|
|
33
|
+
: [],
|
|
34
|
+
iterationNudgeAnchors: Array.isArray(state.nudges?.iterationNudgeAnchors)
|
|
35
|
+
? state.nudges.iterationNudgeAnchors
|
|
36
|
+
: [],
|
|
37
|
+
}
|
|
38
|
+
return state
|
|
16
39
|
}
|
|
17
40
|
|
|
18
41
|
export function loadSessionState(sessionId: string, persistenceDir: string): SessionState {
|
|
@@ -26,13 +49,13 @@ export function loadSessionState(sessionId: string, persistenceDir: string): Ses
|
|
|
26
49
|
if (parsed.toolCalls && Array.isArray(parsed.toolCalls)) {
|
|
27
50
|
parsed.toolCalls = new Map(parsed.toolCalls)
|
|
28
51
|
}
|
|
29
|
-
return { ...DEFAULT_STATE, ...parsed, sessionId }
|
|
52
|
+
return normalizeState({ ...DEFAULT_STATE, ...parsed, sessionId })
|
|
30
53
|
} catch {
|
|
31
54
|
// Use default
|
|
32
55
|
}
|
|
33
56
|
}
|
|
34
57
|
|
|
35
|
-
return { ...DEFAULT_STATE, sessionId }
|
|
58
|
+
return normalizeState({ ...DEFAULT_STATE, sessionId })
|
|
36
59
|
}
|
|
37
60
|
|
|
38
61
|
export function saveSessionState(state: SessionState, persistenceDir: string): void {
|
package/src/lib/strategies.ts
CHANGED
|
@@ -1,8 +1,301 @@
|
|
|
1
|
-
import type { MessageWithParts, SlimConfig } from "./types"
|
|
2
|
-
import { getToolName,
|
|
3
|
-
import {
|
|
4
|
-
import type { SessionState } from "./types"
|
|
1
|
+
import type { MessageWithParts, SlimConfig, SessionState, CompressionBlock } from "./types"
|
|
2
|
+
import { getToolName, getMessageText } from "./compress"
|
|
3
|
+
import { contextLimitNudge, turnNudge, iterationNudge, NUDGE_MARKERS } from "./prompts"
|
|
5
4
|
|
|
5
|
+
// ─── DCP-style Compression Blocks ───────────────────────────────────────────
|
|
6
|
+
//
|
|
7
|
+
// A compression block replaces a contiguous range of messages with a summary
|
|
8
|
+
// placeholder on every outgoing request. The summary is injected as a synthetic
|
|
9
|
+
// user message at the block's *anchor* message (the first message after the
|
|
10
|
+
// range, or the last message when the range reaches the end). The covered
|
|
11
|
+
// messages are removed from the outgoing request only — session history is
|
|
12
|
+
// never modified. Newer blocks "consume" older ones (nested compression).
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Activates/deactivates blocks based on which messages are present in the
|
|
16
|
+
* current outgoing request. A block is active while both its origin message
|
|
17
|
+
* (compressMessageId) and its anchor message are still present. A newer active
|
|
18
|
+
* block deactivates any older block whose anchor falls inside its covered range.
|
|
19
|
+
*/
|
|
20
|
+
export function syncCompressionBlocks(state: SessionState, presentIds: Set<string>): void {
|
|
21
|
+
const blocks = state.compressionBlocks ?? []
|
|
22
|
+
if (blocks.length === 0) return
|
|
23
|
+
|
|
24
|
+
for (const block of blocks) {
|
|
25
|
+
const hasOrigin =
|
|
26
|
+
block.compressMessageId.length > 0 ? presentIds.has(block.compressMessageId) : true
|
|
27
|
+
block.active = hasOrigin && presentIds.has(block.anchorMessageId)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Nested consumption: newest active block wins over older blocks it covers,
|
|
31
|
+
// and inherits their covered messages so nothing resurfaces behind the
|
|
32
|
+
// newest summary. Loop until stable to handle chains (A -> B -> C).
|
|
33
|
+
const sorted = [...blocks].sort((a, b) => a.blockId - b.blockId)
|
|
34
|
+
let changed = true
|
|
35
|
+
while (changed) {
|
|
36
|
+
changed = false
|
|
37
|
+
for (const block of sorted) {
|
|
38
|
+
if (!block.active) continue
|
|
39
|
+
for (const older of sorted) {
|
|
40
|
+
if (older.blockId >= block.blockId || !older.active) continue
|
|
41
|
+
if (block.coveredMessageIds.includes(older.anchorMessageId)) {
|
|
42
|
+
older.active = false
|
|
43
|
+
for (const id of older.coveredMessageIds) {
|
|
44
|
+
if (!block.coveredMessageIds.includes(id)) {
|
|
45
|
+
block.coveredMessageIds.push(id)
|
|
46
|
+
changed = true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Orphaned blocks: inactive and none of their referenced messages survive
|
|
55
|
+
// (e.g. after OpenCode compaction) — safe to forget, otherwise dead entries
|
|
56
|
+
// accumulate in persisted state forever.
|
|
57
|
+
const alive = blocks.filter((b) => {
|
|
58
|
+
if (b.active) return true
|
|
59
|
+
const refs = [b.anchorMessageId, b.compressMessageId, ...(b.coveredMessageIds ?? [])]
|
|
60
|
+
return refs.some((id) => typeof id === "string" && presentIds.has(id))
|
|
61
|
+
})
|
|
62
|
+
if (alive.length !== blocks.length) {
|
|
63
|
+
state.compressionBlocks = alive
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Produces the outgoing message list: active blocks inject their summary at the
|
|
69
|
+
* anchor and drop every covered message. Returns a new array; the caller should
|
|
70
|
+
* splice it back into the event.
|
|
71
|
+
*/
|
|
72
|
+
export function applyCompressedRanges(state: SessionState, messages: any[]): any[] {
|
|
73
|
+
const blocks = (state.compressionBlocks ?? []).filter((b) => b.active)
|
|
74
|
+
if (blocks.length === 0 || messages.length === 0) return messages
|
|
75
|
+
|
|
76
|
+
const covered = new Set<string>()
|
|
77
|
+
const byAnchor = new Map<string, CompressionBlock>()
|
|
78
|
+
for (const block of blocks) {
|
|
79
|
+
for (const id of block.coveredMessageIds) covered.add(id)
|
|
80
|
+
byAnchor.set(block.anchorMessageId, block)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result: any[] = []
|
|
84
|
+
for (const msg of messages) {
|
|
85
|
+
const id = (msg && (msg.id ?? msg.info?.id)) as string | undefined
|
|
86
|
+
if (typeof id === "string") {
|
|
87
|
+
const block = byAnchor.get(id)
|
|
88
|
+
if (block && block.summary) {
|
|
89
|
+
result.push({
|
|
90
|
+
role: "user",
|
|
91
|
+
id: `slim-summary-${block.blockId}`,
|
|
92
|
+
content: [{ type: "text", text: block.summary }],
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
if (covered.has(id)) {
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
result.push(msg)
|
|
100
|
+
}
|
|
101
|
+
return result
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface RegisterBlockOptions {
|
|
105
|
+
coveredIds: string[]
|
|
106
|
+
anchorMessageId: string
|
|
107
|
+
summary: string
|
|
108
|
+
topic: string
|
|
109
|
+
compressMessageId?: string
|
|
110
|
+
summaryTokens?: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Registers a new compression block. Older active blocks whose anchor lies
|
|
115
|
+
* inside the new range are consumed (deactivated) so only the newest summary
|
|
116
|
+
* is injected — information survives through layers of compression.
|
|
117
|
+
*/
|
|
118
|
+
export function registerCompressionBlock(
|
|
119
|
+
state: SessionState,
|
|
120
|
+
opts: RegisterBlockOptions,
|
|
121
|
+
): CompressionBlock | null {
|
|
122
|
+
const blocks = state.compressionBlocks ?? []
|
|
123
|
+
const nextId =
|
|
124
|
+
state.nextBlockId ??
|
|
125
|
+
blocks.reduce((max, b) => Math.max(max, b.blockId), 0) + 1
|
|
126
|
+
|
|
127
|
+
const consumed = blocks
|
|
128
|
+
.filter((b) => b.active && opts.coveredIds.includes(b.anchorMessageId))
|
|
129
|
+
.map((b) => b.blockId)
|
|
130
|
+
|
|
131
|
+
const block: CompressionBlock = {
|
|
132
|
+
blockId: nextId,
|
|
133
|
+
topic: opts.topic,
|
|
134
|
+
summary: opts.summary,
|
|
135
|
+
anchorMessageId: opts.anchorMessageId,
|
|
136
|
+
compressMessageId: opts.compressMessageId ?? "",
|
|
137
|
+
coveredMessageIds: opts.coveredIds,
|
|
138
|
+
consumedBlockIds: consumed,
|
|
139
|
+
active: true,
|
|
140
|
+
createdAt: Date.now(),
|
|
141
|
+
summaryTokens: opts.summaryTokens ?? 0,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
blocks.push(block)
|
|
145
|
+
state.compressionBlocks = blocks
|
|
146
|
+
state.nextBlockId = nextId + 1
|
|
147
|
+
|
|
148
|
+
for (const consumedId of consumed) {
|
|
149
|
+
const target = blocks.find((b) => b.blockId === consumedId)
|
|
150
|
+
if (target) {
|
|
151
|
+
target.active = false
|
|
152
|
+
// Inherit the consumed block's covered messages so they stay
|
|
153
|
+
// hidden behind the newer summary (nested compression).
|
|
154
|
+
for (const id of target.coveredMessageIds) {
|
|
155
|
+
if (!block.coveredMessageIds.includes(id)) {
|
|
156
|
+
block.coveredMessageIds.push(id)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return block
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Summary building (with protected content) ─────────────────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Builds the compression summary used as the placeholder. Protected tool
|
|
169
|
+
* outputs (task, skill, todowrite, todoread, ...) are appended verbatim so the
|
|
170
|
+
* most important information survives compression — DCP behaviour.
|
|
171
|
+
*/
|
|
172
|
+
export async function buildCompressionSummary(
|
|
173
|
+
messages: MessageWithParts[],
|
|
174
|
+
focus: string,
|
|
175
|
+
protectedTools: string[],
|
|
176
|
+
protectUserMessages = false,
|
|
177
|
+
): Promise<string> {
|
|
178
|
+
const lines: string[] = []
|
|
179
|
+
lines.push(`## Compression Summary`)
|
|
180
|
+
lines.push(`Focus: ${focus}`)
|
|
181
|
+
lines.push(`Messages compressed: ${messages.length}`)
|
|
182
|
+
lines.push("")
|
|
183
|
+
|
|
184
|
+
const toolCalls: string[] = []
|
|
185
|
+
const errors: string[] = []
|
|
186
|
+
const decisions: string[] = []
|
|
187
|
+
|
|
188
|
+
for (const msg of messages) {
|
|
189
|
+
for (const part of msg.parts) {
|
|
190
|
+
if (part.type === "tool-call") {
|
|
191
|
+
toolCalls.push(
|
|
192
|
+
`${part.name}: ${JSON.stringify(part.input || {}).slice(0, 100)}`,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
if (part.type === "tool-result") {
|
|
196
|
+
if (part.result?.type === "error") {
|
|
197
|
+
errors.push(String(part.result.value).slice(0, 200) || "Unknown error")
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (part.type === "text") {
|
|
201
|
+
const text = part.text || ""
|
|
202
|
+
if (
|
|
203
|
+
text.includes("decided") ||
|
|
204
|
+
text.includes("chose") ||
|
|
205
|
+
text.includes("implemented")
|
|
206
|
+
) {
|
|
207
|
+
decisions.push(text.slice(0, 200))
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (toolCalls.length > 0) {
|
|
214
|
+
lines.push("### Tool Calls")
|
|
215
|
+
toolCalls.slice(0, 10).forEach((tc) => lines.push(`- ${tc}`))
|
|
216
|
+
lines.push("")
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (errors.length > 0) {
|
|
220
|
+
lines.push("### Errors Encountered")
|
|
221
|
+
errors.slice(0, 5).forEach((e) => lines.push(`- ${e}`))
|
|
222
|
+
lines.push("")
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (decisions.length > 0) {
|
|
226
|
+
lines.push("### Key Decisions")
|
|
227
|
+
decisions.slice(0, 5).forEach((d) => lines.push(`- ${d}`))
|
|
228
|
+
lines.push("")
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const protectedContent = collectProtectedToolOutputs(messages, protectedTools)
|
|
232
|
+
if (protectedContent.length > 0) {
|
|
233
|
+
lines.push("### Protected Tool Outputs")
|
|
234
|
+
lines.push(protectedContent)
|
|
235
|
+
lines.push("")
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// DCP protectUserMessages: the user's own instructions survive compression
|
|
239
|
+
// verbatim inside the summary, so nothing the user asked is ever lost to a
|
|
240
|
+
// lossy paraphrase.
|
|
241
|
+
if (protectUserMessages) {
|
|
242
|
+
const userTexts: string[] = []
|
|
243
|
+
for (const msg of messages) {
|
|
244
|
+
if (msg.info.role !== "user") continue
|
|
245
|
+
const text = getMessageText(msg)
|
|
246
|
+
if (text.trim().length > 0) userTexts.push(text.trim())
|
|
247
|
+
}
|
|
248
|
+
if (userTexts.length > 0) {
|
|
249
|
+
lines.push("### User Messages (preserved verbatim)")
|
|
250
|
+
userTexts.forEach((t, i) => lines.push(`- [user ${i + 1}] ${t.slice(0, 2000)}`))
|
|
251
|
+
lines.push("")
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return lines.join("\n")
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function collectProtectedToolOutputs(
|
|
259
|
+
messages: MessageWithParts[],
|
|
260
|
+
protectedTools: string[],
|
|
261
|
+
): string {
|
|
262
|
+
if (protectedTools.length === 0) return ""
|
|
263
|
+
|
|
264
|
+
const resultsByCallId = new Map<string, string>()
|
|
265
|
+
for (const msg of messages) {
|
|
266
|
+
for (const part of msg.parts) {
|
|
267
|
+
if (part.type !== "tool-result") continue
|
|
268
|
+
const callId = part.toolCallID ?? part.callID
|
|
269
|
+
if (!callId) continue
|
|
270
|
+
const val = part.result?.value ?? part.result
|
|
271
|
+
if (val !== undefined && val !== null && part.result?.type !== "error") {
|
|
272
|
+
resultsByCallId.set(String(callId), String(val))
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const output: string[] = []
|
|
278
|
+
for (const msg of messages) {
|
|
279
|
+
for (const part of msg.parts) {
|
|
280
|
+
if (part.type !== "tool-call") continue
|
|
281
|
+
const name = part.name
|
|
282
|
+
if (!name || !protectedTools.includes(name)) continue
|
|
283
|
+
const input = JSON.stringify(part.input ?? {}).slice(0, 1000)
|
|
284
|
+
const callId = part.toolCallID ?? part.callID
|
|
285
|
+
const result = callId ? resultsByCallId.get(String(callId)) : undefined
|
|
286
|
+
output.push(
|
|
287
|
+
result
|
|
288
|
+
? `- [${name}] input: ${input}\n output: ${result.slice(0, 2000)}`
|
|
289
|
+
: `- [${name}] input: ${input}`,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return output.join("\n")
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ─── Pruning: dedup + purge errored tool inputs ────────────────────────────
|
|
297
|
+
|
|
298
|
+
/** Kept for compatibility: pure deduplication over MessageWithParts. */
|
|
6
299
|
export function pruneMessages(
|
|
7
300
|
messages: MessageWithParts[],
|
|
8
301
|
config: SlimConfig,
|
|
@@ -10,7 +303,6 @@ export function pruneMessages(
|
|
|
10
303
|
): MessageWithParts[] {
|
|
11
304
|
let pruned = [...messages]
|
|
12
305
|
|
|
13
|
-
// Apply deduplication
|
|
14
306
|
if (config.strategies.deduplication.enabled) {
|
|
15
307
|
pruned = applyDeduplication(pruned, config.strategies.deduplication.protectedTools)
|
|
16
308
|
}
|
|
@@ -18,32 +310,302 @@ export function pruneMessages(
|
|
|
18
310
|
return pruned
|
|
19
311
|
}
|
|
20
312
|
|
|
21
|
-
function applyDeduplication(
|
|
22
|
-
|
|
313
|
+
export function applyDeduplication(
|
|
314
|
+
messages: MessageWithParts[],
|
|
315
|
+
protectedTools: string[],
|
|
316
|
+
): MessageWithParts[] {
|
|
317
|
+
const seen = new Set<string>()
|
|
23
318
|
const toRemove = new Set<number>()
|
|
24
319
|
|
|
25
320
|
for (let i = 0; i < messages.length; i++) {
|
|
26
321
|
const msg = messages[i]
|
|
27
322
|
const toolName = getToolName(msg)
|
|
28
323
|
|
|
29
|
-
// Skip protected tools
|
|
30
324
|
if (toolName && protectedTools.includes(toolName)) {
|
|
31
325
|
continue
|
|
32
326
|
}
|
|
33
327
|
|
|
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
|
|
328
|
+
// Exact full-content fingerprint: only identical messages are removed.
|
|
329
|
+
const fingerprint = `${msg.info.role}:${JSON.stringify(msg.parts)}`
|
|
330
|
+
if (seen.has(fingerprint)) {
|
|
42
331
|
toRemove.add(i)
|
|
43
332
|
} else {
|
|
44
|
-
seen.
|
|
333
|
+
seen.add(fingerprint)
|
|
45
334
|
}
|
|
46
335
|
}
|
|
47
336
|
|
|
48
337
|
return messages.filter((_, i) => !toRemove.has(i))
|
|
49
338
|
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* DCP purge-errors: for tool calls whose result is an error, remove the large
|
|
342
|
+
* string inputs once the message is at least `turns` positions behind the end
|
|
343
|
+
* of the conversation. Error messages themselves are preserved.
|
|
344
|
+
*/
|
|
345
|
+
export function purgeStaleToolErrors(messages: any[], turns: number): void {
|
|
346
|
+
const n = messages.length
|
|
347
|
+
if (n === 0) return
|
|
348
|
+
|
|
349
|
+
const erroredCallIds = new Set<string>()
|
|
350
|
+
for (const msg of messages) {
|
|
351
|
+
for (const part of msg?.content ?? msg?.parts ?? []) {
|
|
352
|
+
if (part?.type !== "tool-result") continue
|
|
353
|
+
if (part.result?.type !== "error") continue
|
|
354
|
+
const callId = part.toolCallID ?? part.callID
|
|
355
|
+
if (callId) erroredCallIds.add(String(callId))
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (erroredCallIds.size === 0) return
|
|
359
|
+
|
|
360
|
+
const turnsEffective = Math.max(1, Math.floor(turns) || 1)
|
|
361
|
+
for (let i = 0; i < n; i++) {
|
|
362
|
+
if (i > n - turnsEffective - 1) continue // too recent — keep
|
|
363
|
+
const msg = messages[i]
|
|
364
|
+
for (const part of msg?.content ?? msg?.parts ?? []) {
|
|
365
|
+
if (part?.type !== "tool-call") continue
|
|
366
|
+
const callId = part.toolCallID ?? part.callID
|
|
367
|
+
if (!callId || !erroredCallIds.has(String(callId))) continue
|
|
368
|
+
const input = part.input
|
|
369
|
+
if (input && typeof input === "object") {
|
|
370
|
+
for (const key of Object.keys(input)) {
|
|
371
|
+
if (typeof input[key] === "string" && input[key].length > 80) {
|
|
372
|
+
input[key] = "[input removed due to failed tool call]"
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** In-place dedup over raw outgoing messages; returns the keep count. */
|
|
381
|
+
export function pruneInPlace(messages: any[], config: SlimConfig): void {
|
|
382
|
+
if (!config.strategies.deduplication.enabled) return
|
|
383
|
+
|
|
384
|
+
const protectedTools = config.strategies.deduplication.protectedTools
|
|
385
|
+
const seen = new Set<string>()
|
|
386
|
+
const toRemove = new Set<number>()
|
|
387
|
+
|
|
388
|
+
for (let i = 0; i < messages.length; i++) {
|
|
389
|
+
const msg = messages[i] as any
|
|
390
|
+
const content = msg?.content ?? msg?.parts ?? []
|
|
391
|
+
|
|
392
|
+
// Protected tools (and messages carrying them) are never deduplicated.
|
|
393
|
+
let toolName: string | null = null
|
|
394
|
+
for (const part of content) {
|
|
395
|
+
if (part?.type === "tool-call") {
|
|
396
|
+
toolName = toolName ?? part.name ?? null
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (toolName && protectedTools.includes(toolName)) continue
|
|
400
|
+
|
|
401
|
+
// Exact full-content fingerprint — only truly identical messages are
|
|
402
|
+
// removed. Truncated fingerprints would eat distinct messages that share
|
|
403
|
+
// a common prefix.
|
|
404
|
+
const fingerprint = `${msg?.role}:${JSON.stringify(content)}`
|
|
405
|
+
if (seen.has(fingerprint)) {
|
|
406
|
+
toRemove.add(i)
|
|
407
|
+
} else {
|
|
408
|
+
seen.add(fingerprint)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (toRemove.size === 0) return
|
|
413
|
+
const kept = messages.filter((_, i) => !toRemove.has(i))
|
|
414
|
+
messages.splice(0, messages.length, ...kept)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ─── DCP limit rules → anchored nudges ─────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
export function messageHasCompress(msg: any): boolean {
|
|
420
|
+
const content = msg?.content ?? msg?.parts ?? []
|
|
421
|
+
return content.some(
|
|
422
|
+
(part: any) => part?.type === "tool-call" && part?.name === "compress",
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function findLastUserMessage(messages: any[]): any | undefined {
|
|
427
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
428
|
+
if (messages[i]?.role === "user") return messages[i]
|
|
429
|
+
}
|
|
430
|
+
return undefined
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function getNudgeFrequency(config: SlimConfig): number {
|
|
434
|
+
return Math.max(1, Math.floor(config.compress.nudgeFrequency || 1))
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function getIterationThreshold(config: SlimConfig): number {
|
|
438
|
+
return Math.max(1, Math.floor(config.compress.iterationNudgeThreshold || 1))
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function addAnchor(
|
|
442
|
+
anchors: string[],
|
|
443
|
+
messageId: string | undefined,
|
|
444
|
+
index: number,
|
|
445
|
+
messages: any[],
|
|
446
|
+
interval: number,
|
|
447
|
+
): boolean {
|
|
448
|
+
if (!messageId || index < 0) return false
|
|
449
|
+
|
|
450
|
+
let latestAnchorIndex = -1
|
|
451
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
452
|
+
const m = messages[i] as any
|
|
453
|
+
const id = m?.id ?? m?.info?.id
|
|
454
|
+
if (typeof id === "string" && anchors.includes(id)) {
|
|
455
|
+
latestAnchorIndex = i
|
|
456
|
+
break
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const shouldAdd = latestAnchorIndex < 0 || index - latestAnchorIndex >= interval
|
|
461
|
+
if (!shouldAdd) return false
|
|
462
|
+
|
|
463
|
+
if (!anchors.includes(messageId)) {
|
|
464
|
+
anchors.push(messageId)
|
|
465
|
+
return true
|
|
466
|
+
}
|
|
467
|
+
return false
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function addSpecificAnchor(anchors: string[], messageId: string | undefined): void {
|
|
471
|
+
if (messageId && !anchors.includes(messageId)) {
|
|
472
|
+
anchors.push(messageId)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function messageHasNudge(msg: any, marker: string): boolean {
|
|
477
|
+
const content = msg?.content ?? msg?.parts ?? []
|
|
478
|
+
return content.some(
|
|
479
|
+
(part: any) => part?.type === "text" && typeof part.text === "string" && part.text.includes(marker),
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function appendToMessage(msg: any, nudgeText: string): void {
|
|
484
|
+
const content = (msg?.content ?? msg?.parts ?? []) as any[]
|
|
485
|
+
for (const part of content) {
|
|
486
|
+
if (part?.type === "text") {
|
|
487
|
+
part.text = `${part.text}\n\n${nudgeText}`
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
content.push({ type: "text", text: nudgeText })
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* DCP limit rules: compare current usage against maxContextLimit /
|
|
496
|
+
* minContextLimit and anchor nudges so the model is pushed to compress at most
|
|
497
|
+
* once per nudgeFrequency messages. If the last assistant turn already ran the
|
|
498
|
+
* compress tool, all anchors are cleared.
|
|
499
|
+
*/
|
|
500
|
+
export function injectLimitNudges(
|
|
501
|
+
state: SessionState,
|
|
502
|
+
config: SlimConfig,
|
|
503
|
+
messages: any[],
|
|
504
|
+
currentTokens: number,
|
|
505
|
+
limits: { max: number; min: number },
|
|
506
|
+
): void {
|
|
507
|
+
if (config.compress.permission === "deny") return
|
|
508
|
+
if (state.manualMode) return
|
|
509
|
+
if (messages.length === 0) return
|
|
510
|
+
|
|
511
|
+
const nudges = state.nudges ?? {
|
|
512
|
+
contextLimitAnchors: [],
|
|
513
|
+
turnNudgeAnchors: [],
|
|
514
|
+
iterationNudgeAnchors: [],
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const lastAssistant = [...messages].reverse().find((m) => (m as any)?.role === "assistant")
|
|
518
|
+
if (lastAssistant && messageHasCompress(lastAssistant)) {
|
|
519
|
+
nudges.contextLimitAnchors = []
|
|
520
|
+
nudges.turnNudgeAnchors = []
|
|
521
|
+
nudges.iterationNudgeAnchors = []
|
|
522
|
+
state.nudges = nudges
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const overMax = limits.max > 0 && currentTokens > limits.max
|
|
527
|
+
const overMin = limits.min > 0 && currentTokens >= limits.min
|
|
528
|
+
|
|
529
|
+
if (!overMin) {
|
|
530
|
+
if (nudges.turnNudgeAnchors.length > 0 || nudges.iterationNudgeAnchors.length > 0) {
|
|
531
|
+
nudges.turnNudgeAnchors = []
|
|
532
|
+
nudges.iterationNudgeAnchors = []
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const lastIndex = messages.length - 1
|
|
537
|
+
const lastMessage = messages[lastIndex] as any
|
|
538
|
+
const lastMessageId = lastMessage?.id ?? lastMessage?.info?.id
|
|
539
|
+
|
|
540
|
+
if (overMax) {
|
|
541
|
+
addAnchor(
|
|
542
|
+
nudges.contextLimitAnchors,
|
|
543
|
+
lastMessageId,
|
|
544
|
+
lastIndex,
|
|
545
|
+
messages,
|
|
546
|
+
getNudgeFrequency(config),
|
|
547
|
+
)
|
|
548
|
+
} else if (overMin) {
|
|
549
|
+
// Turn nudge: fire at a user/assistant turn boundary.
|
|
550
|
+
if (lastMessage?.role === "user" && lastAssistant) {
|
|
551
|
+
addSpecificAnchor(nudges.turnNudgeAnchors, lastMessageId)
|
|
552
|
+
const lastAssistantId = lastAssistant?.id ?? lastAssistant?.info?.id
|
|
553
|
+
addSpecificAnchor(nudges.turnNudgeAnchors, lastAssistantId)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Iteration nudge: too many messages since the last user request.
|
|
557
|
+
const lastUserIndex = messages.findIndex((m) => (m as any)?.role === "user")
|
|
558
|
+
if (lastUserIndex >= 0 && lastIndex > lastUserIndex) {
|
|
559
|
+
const sinceUser = lastIndex - lastUserIndex
|
|
560
|
+
if (sinceUser >= getIterationThreshold(config)) {
|
|
561
|
+
addAnchor(
|
|
562
|
+
nudges.iterationNudgeAnchors,
|
|
563
|
+
lastMessageId,
|
|
564
|
+
lastIndex,
|
|
565
|
+
messages,
|
|
566
|
+
getNudgeFrequency(config),
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const percent = limits.max > 0 ? Math.round((currentTokens / limits.max) * 100) : 0
|
|
573
|
+
// DCP nudgeForce: "soft" anchors the turn nudge on the assistant message,
|
|
574
|
+
// "strong" on the user message.
|
|
575
|
+
const targetRole = config.compress.nudgeForce === "strong" ? "user" : "assistant"
|
|
576
|
+
|
|
577
|
+
const injectForAnchors = (anchors: string[], marker: string, text: string, roleFilter?: string) => {
|
|
578
|
+
if (!text) return
|
|
579
|
+
for (const anchorId of anchors) {
|
|
580
|
+
const msg = messages.find((m) => {
|
|
581
|
+
const id = (m as any)?.id ?? (m as any)?.info?.id
|
|
582
|
+
return id === anchorId
|
|
583
|
+
})
|
|
584
|
+
if (!msg) continue
|
|
585
|
+
if (roleFilter && (msg as any)?.role !== roleFilter) continue
|
|
586
|
+
// Idempotency via stable marker: the dynamic part of the nudge
|
|
587
|
+
// (percentages) changes every request, so match on the marker only.
|
|
588
|
+
if (messageHasNudge(msg, marker)) continue
|
|
589
|
+
appendToMessage(msg, text)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
injectForAnchors(
|
|
594
|
+
nudges.contextLimitAnchors,
|
|
595
|
+
NUDGE_MARKERS.contextLimit,
|
|
596
|
+
contextLimitNudge(percent, limits.max),
|
|
597
|
+
)
|
|
598
|
+
injectForAnchors(
|
|
599
|
+
nudges.turnNudgeAnchors,
|
|
600
|
+
NUDGE_MARKERS.turn,
|
|
601
|
+
turnNudge(percent),
|
|
602
|
+
targetRole,
|
|
603
|
+
)
|
|
604
|
+
injectForAnchors(
|
|
605
|
+
nudges.iterationNudgeAnchors,
|
|
606
|
+
NUDGE_MARKERS.iteration,
|
|
607
|
+
iterationNudge(percent),
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
state.nudges = nudges
|
|
611
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -6,13 +6,26 @@ export interface SlimConfig {
|
|
|
6
6
|
enabled: boolean
|
|
7
7
|
debug: boolean
|
|
8
8
|
|
|
9
|
-
// Compression settings
|
|
9
|
+
// Compression settings (DCP-compatible semantics)
|
|
10
10
|
compress: {
|
|
11
11
|
enabled: boolean
|
|
12
|
+
/** DCP mode: "range" (contiguous spans) or "message" (surgical, single messages) */
|
|
13
|
+
mode?: "range" | "message"
|
|
12
14
|
permission: "allow" | "ask" | "deny"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
/** Absolute token count or percent string like "80%" (DCP default: 100000) */
|
|
16
|
+
maxContextLimit: number | string
|
|
17
|
+
/** Absolute token count or percent string like "40%" (DCP default: 50000) */
|
|
18
|
+
minContextLimit: number | string
|
|
19
|
+
/** Per-model overrides, keyed "providerId/modelId" (DCP: compress.modelMaxLimits) */
|
|
20
|
+
modelMaxLimits?: Record<string, number | string>
|
|
21
|
+
/** Per-model overrides, keyed "providerId/modelId" (DCP: compress.modelMinLimits) */
|
|
22
|
+
modelMinLimits?: Record<string, number | string>
|
|
23
|
+
/** At most one limit-nudge per this many messages (DCP default: 5) */
|
|
15
24
|
nudgeFrequency: number
|
|
25
|
+
/** Messages since last user message before iteration nudge fires (DCP default: 15) */
|
|
26
|
+
iterationNudgeThreshold?: number
|
|
27
|
+
/** Where the turn nudge is anchored: "strong" -> user, "soft" -> assistant (DCP default: soft) */
|
|
28
|
+
nudgeForce?: "strong" | "soft"
|
|
16
29
|
protectUserMessages: boolean
|
|
17
30
|
protectedTools: string[]
|
|
18
31
|
}
|
|
@@ -67,6 +80,40 @@ export interface SessionState {
|
|
|
67
80
|
|
|
68
81
|
// Tool call tracking
|
|
69
82
|
toolCalls: Map<string, ToolCallInfo>
|
|
83
|
+
|
|
84
|
+
// DCP-style compression blocks (range -> summary placeholders)
|
|
85
|
+
compressionBlocks?: CompressionBlock[]
|
|
86
|
+
nextBlockId?: number
|
|
87
|
+
// DCP-style nudge anchors
|
|
88
|
+
nudges?: NudgeState
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A DCP-style compression block. When active, the covered messages are
|
|
93
|
+
* removed from every outgoing request and replaced by a synthetic summary
|
|
94
|
+
* message injected at the anchor message (the message right after the range).
|
|
95
|
+
*/
|
|
96
|
+
export interface CompressionBlock {
|
|
97
|
+
blockId: number
|
|
98
|
+
topic: string
|
|
99
|
+
summary: string
|
|
100
|
+
/** Message id where the summary is injected (first message after the range, or the last message when the range reaches the end). */
|
|
101
|
+
anchorMessageId: string
|
|
102
|
+
/** The assistant message that executed the compress call; used to deactivate blocks when the source is gone. */
|
|
103
|
+
compressMessageId: string
|
|
104
|
+
/** Original message ids covered (excluded) by this block. */
|
|
105
|
+
coveredMessageIds: string[]
|
|
106
|
+
/** Older blocks swallowed by this block (nested compression). */
|
|
107
|
+
consumedBlockIds: number[]
|
|
108
|
+
active: boolean
|
|
109
|
+
createdAt: number
|
|
110
|
+
summaryTokens: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface NudgeState {
|
|
114
|
+
contextLimitAnchors: string[]
|
|
115
|
+
turnNudgeAnchors: string[]
|
|
116
|
+
iterationNudgeAnchors: string[]
|
|
70
117
|
}
|
|
71
118
|
|
|
72
119
|
export interface CompressionRecord {
|