@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/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
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
|
+
autoCompress,
|
|
20
|
+
} from "./lib/strategies"
|
|
21
|
+
import { getSystemPrompt, getCompressToolDescription } from "./lib/prompts"
|
|
11
22
|
import { buildPanelData, renderPanel } from "./lib/tui"
|
|
12
|
-
import type { SlimConfig, SessionState, MessageWithParts } from "./lib/types"
|
|
23
|
+
import type { SlimConfig, SessionState, MessageWithParts, CompressionBlock } from "./lib/types"
|
|
13
24
|
|
|
14
25
|
// ─── State Management ───────────────────────────────────────────────────────
|
|
15
26
|
|
|
@@ -58,70 +69,59 @@ function stringifyTranscript(v: unknown): string {
|
|
|
58
69
|
|
|
59
70
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
60
71
|
|
|
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
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (toolCalls.length > 0) {
|
|
101
|
-
lines.push("### Tool Calls")
|
|
102
|
-
toolCalls.slice(0, 10).forEach((tc) => lines.push(`- ${tc}`))
|
|
103
|
-
lines.push("")
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (errors.length > 0) {
|
|
107
|
-
lines.push("### Errors Encountered")
|
|
108
|
-
errors.slice(0, 5).forEach((e) => lines.push(`- ${e}`))
|
|
109
|
-
lines.push("")
|
|
72
|
+
// Register a DCP-style compression block for the selected range. The range is
|
|
73
|
+
// covered (removed from future outgoing requests) and the summary is injected
|
|
74
|
+
// at the anchor: the first message after the range, or the latest message when
|
|
75
|
+
// the range reaches the end (the active user turn is never replaced).
|
|
76
|
+
function registerBlockForRange(
|
|
77
|
+
state: SessionState,
|
|
78
|
+
topic: string,
|
|
79
|
+
messageWithParts: MessageWithParts[],
|
|
80
|
+
targetIndices: number[],
|
|
81
|
+
summary: string,
|
|
82
|
+
summaryTokens: number,
|
|
83
|
+
): CompressionBlock | null {
|
|
84
|
+
if (targetIndices.length === 0 || messageWithParts.length === 0) return null
|
|
85
|
+
|
|
86
|
+
const sorted = [...targetIndices].sort((a, b) => a - b)
|
|
87
|
+
const coveredIndices = new Set(sorted)
|
|
88
|
+
|
|
89
|
+
let anchorIndex = sorted[sorted.length - 1] + 1
|
|
90
|
+
if (anchorIndex >= messageWithParts.length) {
|
|
91
|
+
anchorIndex = messageWithParts.length - 1
|
|
92
|
+
coveredIndices.delete(anchorIndex)
|
|
110
93
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
94
|
+
if (anchorIndex < 0 || anchorIndex >= messageWithParts.length) return null
|
|
95
|
+
|
|
96
|
+
const anchorId = messageWithParts[anchorIndex].info?.id
|
|
97
|
+
if (!anchorId) return null
|
|
98
|
+
|
|
99
|
+
const coveredIds = [...coveredIndices]
|
|
100
|
+
.map((i) => messageWithParts[i].info?.id)
|
|
101
|
+
.filter((id): id is string => typeof id === "string" && id.length > 0)
|
|
102
|
+
if (coveredIds.length === 0) return null
|
|
103
|
+
|
|
104
|
+
return registerCompressionBlock(state, {
|
|
105
|
+
coveredIds,
|
|
106
|
+
anchorMessageId: anchorId,
|
|
107
|
+
summary,
|
|
108
|
+
topic,
|
|
109
|
+
summaryTokens,
|
|
110
|
+
})
|
|
119
111
|
}
|
|
120
112
|
|
|
121
113
|
function wrapAsMessageWithParts(msg: any): MessageWithParts {
|
|
114
|
+
const msgInfo = msg.info
|
|
115
|
+
const id = (msg && (msg.id || msgInfo?.id)) || ""
|
|
116
|
+
const role = (msg && (msg.role || msgInfo?.role)) || "user"
|
|
122
117
|
return {
|
|
123
|
-
info:
|
|
124
|
-
|
|
118
|
+
info: {
|
|
119
|
+
id,
|
|
120
|
+
role,
|
|
121
|
+
sessionID: (msg && (msg.sessionID || msgInfo?.sessionID)) || "",
|
|
122
|
+
time: { created: Date.now() },
|
|
123
|
+
} as any,
|
|
124
|
+
parts: (msg && (msg.parts || msg.content)) || [],
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
@@ -241,10 +241,34 @@ export default Plugin.define({
|
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
const targetMessages = targetIndices.map((i) => messageWithParts[i])
|
|
244
|
-
const summary = buildCompressionSummary(
|
|
244
|
+
const summary = await buildCompressionSummary(
|
|
245
|
+
targetMessages,
|
|
246
|
+
args.focus,
|
|
247
|
+
config.compress.protectedTools,
|
|
248
|
+
config.compress.protectUserMessages,
|
|
249
|
+
)
|
|
245
250
|
const outputTokens = await countTokens(summary)
|
|
246
251
|
const ratio = inputTokens > 0 ? 1 - outputTokens / inputTokens : 0
|
|
247
252
|
|
|
253
|
+
// DCP: register a compression block so future outgoing
|
|
254
|
+
// requests replace this range with the summary.
|
|
255
|
+
let blockNote = ""
|
|
256
|
+
try {
|
|
257
|
+
const block = registerBlockForRange(
|
|
258
|
+
state,
|
|
259
|
+
args.focus,
|
|
260
|
+
messageWithParts,
|
|
261
|
+
targetIndices,
|
|
262
|
+
summary,
|
|
263
|
+
outputTokens,
|
|
264
|
+
)
|
|
265
|
+
if (block) {
|
|
266
|
+
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._`
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
// Best-effort: the summary is still returned to the model.
|
|
270
|
+
}
|
|
271
|
+
|
|
248
272
|
addCompressionRecord(
|
|
249
273
|
state,
|
|
250
274
|
{
|
|
@@ -261,7 +285,7 @@ export default Plugin.define({
|
|
|
261
285
|
saveSessionState(state, config.persistence.directory)
|
|
262
286
|
|
|
263
287
|
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}`,
|
|
288
|
+
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
289
|
}
|
|
266
290
|
} catch (error) {
|
|
267
291
|
return {
|
|
@@ -362,8 +386,12 @@ export default Plugin.define({
|
|
|
362
386
|
event.system.push({ type: "text", text: getSystemPrompt() })
|
|
363
387
|
})
|
|
364
388
|
|
|
365
|
-
// ─── Messages Transform Hook (sync)
|
|
366
|
-
|
|
389
|
+
// ─── Messages Transform Hook (sync → async) ─────────────────────────
|
|
390
|
+
// DCP pipeline for every outgoing request: sync compression blocks,
|
|
391
|
+
// replace covered ranges with summary placeholders, prune (dedup +
|
|
392
|
+
// purge errored tool inputs), then apply DCP limit rules as anchored
|
|
393
|
+
// nudges. Session history is never modified — only this request.
|
|
394
|
+
await ctx.session.hook("context", async (event) => {
|
|
367
395
|
const sessionId = event.sessionID
|
|
368
396
|
const config = getConfig(sessionId)
|
|
369
397
|
if (!config.enabled) return
|
|
@@ -371,71 +399,59 @@ export default Plugin.define({
|
|
|
371
399
|
const state = getState(sessionId, config)
|
|
372
400
|
state.modelContextLimit = sessionModelLimits.get(sessionId) || initialModelLimit
|
|
373
401
|
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
402
|
+
// 1) Compression blocks: activate/deactivate and replace ranges.
|
|
403
|
+
const presentIds = new Set<string>()
|
|
404
|
+
for (const msg of event.messages) {
|
|
405
|
+
const id = (msg as any)?.id ?? (msg as any)?.info?.id
|
|
406
|
+
if (typeof id === "string") presentIds.add(id)
|
|
407
|
+
}
|
|
408
|
+
syncCompressionBlocks(state, presentIds)
|
|
409
|
+
const filtered = applyCompressedRanges(state, event.messages)
|
|
410
|
+
event.messages.splice(0, event.messages.length, ...filtered)
|
|
411
|
+
|
|
412
|
+
// 2) Pruning strategies (each request).
|
|
413
|
+
pruneInPlace(event.messages, config)
|
|
414
|
+
if (config.strategies.purgeErrors.enabled) {
|
|
415
|
+
purgeStaleToolErrors(event.messages, config.strategies.purgeErrors.turns)
|
|
389
416
|
}
|
|
390
417
|
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
// fallback; afterwards we prefer the server-measured value.
|
|
418
|
+
// 3) Token accounting: prefer the server-measured count; fall back
|
|
419
|
+
// to a quick estimate (~4 chars per token).
|
|
394
420
|
let estimatedTokens = 0
|
|
395
421
|
for (const msg of event.messages) {
|
|
396
|
-
const content = (msg as any)
|
|
422
|
+
const content = (msg as any)?.content ?? (msg as any)?.parts
|
|
397
423
|
if (Array.isArray(content)) {
|
|
398
424
|
for (const part of content) {
|
|
399
|
-
if (part
|
|
425
|
+
if (part?.type === "text" && part.text) {
|
|
400
426
|
estimatedTokens += Math.ceil(part.text.length / 4)
|
|
401
427
|
}
|
|
402
428
|
}
|
|
403
429
|
}
|
|
404
430
|
}
|
|
405
|
-
|
|
406
|
-
// Prefer the real measured token count when available; else the estimate.
|
|
407
431
|
const totalTokens =
|
|
408
432
|
state.currentTokenCount > 0 ? state.currentTokenCount : estimatedTokens
|
|
409
433
|
state.currentTokenCount = totalTokens
|
|
410
434
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
shouldComp.reason,
|
|
432
|
-
totalTokens,
|
|
433
|
-
maxTokens,
|
|
434
|
-
)
|
|
435
|
-
event.messages.push({
|
|
436
|
-
role: "assistant",
|
|
437
|
-
content: [{ type: "text", text: nudgeMessage }],
|
|
438
|
-
} as any)
|
|
435
|
+
// 4) DCP limit rules → anchored nudges (max 100k / min 50k by
|
|
436
|
+
// default, model overrides supported via modelMax/MinLimits).
|
|
437
|
+
const lastUser = findLastUserMessage(event.messages)
|
|
438
|
+
const providerId =
|
|
439
|
+
lastUser?.model?.providerID ?? lastUser?.model?.id?.split?.("/")[0]
|
|
440
|
+
const modelId =
|
|
441
|
+
lastUser?.model?.modelID ??
|
|
442
|
+
lastUser?.model?.id?.split?.("/").slice(1).join("/")
|
|
443
|
+
const limits = resolveCompressLimits(config, state, providerId, modelId)
|
|
444
|
+
injectLimitNudges(state, config, event.messages, totalTokens, limits)
|
|
445
|
+
|
|
446
|
+
// 5) Auto-compress: when over the max limit, directly compress old
|
|
447
|
+
// messages without waiting for the model to call the compress tool.
|
|
448
|
+
// Registers a compression block so future requests use the summary.
|
|
449
|
+
if (totalTokens > limits.max) {
|
|
450
|
+
try {
|
|
451
|
+
await autoCompress(state, config, event.messages, totalTokens, limits)
|
|
452
|
+
} catch {
|
|
453
|
+
// Best-effort: auto-compress failure should never break the request.
|
|
454
|
+
}
|
|
439
455
|
}
|
|
440
456
|
|
|
441
457
|
saveSessionState(state, config.persistence.directory)
|
package/src/lib/config.ts
CHANGED
|
@@ -2,19 +2,24 @@ 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"],
|
|
22
|
+
keepRecent: 5,
|
|
18
23
|
},
|
|
19
24
|
strategies: {
|
|
20
25
|
deduplication: {
|
|
@@ -46,7 +51,12 @@ function deepMerge(base: SlimConfig, override: Partial<SlimConfig>): SlimConfig
|
|
|
46
51
|
return {
|
|
47
52
|
...base,
|
|
48
53
|
...override,
|
|
49
|
-
compress: {
|
|
54
|
+
compress: {
|
|
55
|
+
...base.compress,
|
|
56
|
+
...override.compress,
|
|
57
|
+
modelMaxLimits: override.compress?.modelMaxLimits ?? base.compress.modelMaxLimits,
|
|
58
|
+
modelMinLimits: override.compress?.modelMinLimits ?? base.compress.modelMinLimits,
|
|
59
|
+
},
|
|
50
60
|
strategies: {
|
|
51
61
|
deduplication: { ...base.strategies.deduplication, ...override.strategies?.deduplication },
|
|
52
62
|
purgeErrors: { ...base.strategies.purgeErrors, ...override.strategies?.purgeErrors },
|
|
@@ -101,14 +111,17 @@ export function createDefaultConfig(): void {
|
|
|
101
111
|
writeFileSync(
|
|
102
112
|
configPath,
|
|
103
113
|
`{
|
|
104
|
-
// Slim Configuration
|
|
114
|
+
// Slim Configuration (DCP-compatible limit rules)
|
|
105
115
|
"enabled": true,
|
|
106
116
|
"compress": {
|
|
107
117
|
"enabled": true,
|
|
118
|
+
"mode": "range",
|
|
108
119
|
"permission": "allow",
|
|
109
|
-
"maxContextLimit":
|
|
110
|
-
"minContextLimit":
|
|
111
|
-
"nudgeFrequency": 5
|
|
120
|
+
"maxContextLimit": 100000,
|
|
121
|
+
"minContextLimit": 50000,
|
|
122
|
+
"nudgeFrequency": 5,
|
|
123
|
+
"iterationNudgeThreshold": 15,
|
|
124
|
+
"nudgeForce": "soft"
|
|
112
125
|
}
|
|
113
126
|
}`,
|
|
114
127
|
"utf-8",
|
|
@@ -124,3 +137,39 @@ export function resolveTokenLimit(value: number | string, contextLimit: number):
|
|
|
124
137
|
const percent = parseFloat(value.replace("%", "")) / 100
|
|
125
138
|
return Math.floor(contextLimit * percent)
|
|
126
139
|
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* DCP limit resolution. Prefers per-model overrides (compress.modelMinLimits /
|
|
143
|
+
* compress.modelMaxLimits keyed by "providerId/modelId"), then falls back to the
|
|
144
|
+
* global max/min limit. Percent strings resolve against the model's context window.
|
|
145
|
+
*/
|
|
146
|
+
export function resolveCompressLimits(
|
|
147
|
+
config: SlimConfig,
|
|
148
|
+
state: SessionState,
|
|
149
|
+
providerId?: string,
|
|
150
|
+
modelId?: string,
|
|
151
|
+
): { max: number; min: number } {
|
|
152
|
+
const parseLimit = (value: number | string | undefined): number => {
|
|
153
|
+
if (value === undefined) {
|
|
154
|
+
return 0
|
|
155
|
+
}
|
|
156
|
+
if (typeof value === "number") {
|
|
157
|
+
return value
|
|
158
|
+
}
|
|
159
|
+
const pct = parseFloat(value.replace("%", ""))
|
|
160
|
+
if (Number.isNaN(pct)) {
|
|
161
|
+
return 0
|
|
162
|
+
}
|
|
163
|
+
return Math.round((Math.max(0, Math.min(100, pct)) / 100) * state.modelContextLimit)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const providerModel = providerId && modelId ? `${providerId}/${modelId}` : undefined
|
|
167
|
+
|
|
168
|
+
const modelMin = providerModel ? config.compress.modelMinLimits?.[providerModel] : undefined
|
|
169
|
+
const modelMax = providerModel ? config.compress.modelMaxLimits?.[providerModel] : undefined
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
max: parseLimit(modelMax ?? config.compress.maxContextLimit),
|
|
173
|
+
min: parseLimit(modelMin ?? config.compress.minContextLimit),
|
|
174
|
+
}
|
|
175
|
+
}
|
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 {
|