@serkanalgur/opencodev2-slim 1.0.1 → 2.0.1
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 +14 -7
- package/src/index.ts +322 -320
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serkanalgur/opencodev2-slim",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Smart context management plugin for OpenCode v2 - semantic compression, cost-aware pruning, adaptive thresholds",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
|
@@ -46,10 +46,12 @@
|
|
|
46
46
|
"jsonc-parser": "^3.3.1"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
+
"@opencode/plugin": "^2.0.0",
|
|
50
|
+
"@opencode-ai/sdk": "^1.18.29",
|
|
49
51
|
"@opencode-ai/plugin": "^1.18.29",
|
|
50
|
-
"@opentui/core": "^0.
|
|
51
|
-
"@opentui/keymap": "^0.
|
|
52
|
-
"@opentui/solid": "^0.
|
|
52
|
+
"@opentui/core": "^0.5.10",
|
|
53
|
+
"@opentui/keymap": "^0.5.10",
|
|
54
|
+
"@opentui/solid": "^0.5.10",
|
|
53
55
|
"@types/node": "^22.0.0",
|
|
54
56
|
"prettier": "^3.4.0",
|
|
55
57
|
"solid-js": "1.9.12",
|
|
@@ -57,13 +59,18 @@
|
|
|
57
59
|
"typescript": "^5.7.0"
|
|
58
60
|
},
|
|
59
61
|
"peerDependencies": {
|
|
62
|
+
"@opencode/plugin": ">=2.0.0",
|
|
63
|
+
"@opencode-ai/sdk": ">=1.18.29",
|
|
60
64
|
"@opencode-ai/plugin": ">=1.18.29",
|
|
61
|
-
"@opentui/core": ">=0.
|
|
62
|
-
"@opentui/keymap": ">=0.
|
|
63
|
-
"@opentui/solid": ">=0.
|
|
65
|
+
"@opentui/core": ">=0.5.10",
|
|
66
|
+
"@opentui/keymap": ">=0.5.10",
|
|
67
|
+
"@opentui/solid": ">=0.5.10",
|
|
64
68
|
"solid-js": ">=1.0.0"
|
|
65
69
|
},
|
|
66
70
|
"peerDependenciesMeta": {
|
|
71
|
+
"@opencode-ai/plugin": {
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
67
74
|
"@opentui/core": {
|
|
68
75
|
"optional": true
|
|
69
76
|
},
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { tool } from "@opencode-ai/plugin"
|
|
3
|
-
import { z } from "zod"
|
|
1
|
+
import { Plugin } from "@opencode/plugin"
|
|
4
2
|
import { loadConfig, createDefaultConfig, resolveTokenLimit } from "./lib/config"
|
|
5
3
|
import {
|
|
6
4
|
loadSessionState,
|
|
@@ -30,321 +28,6 @@ function getConfig(sessionId: string): SlimConfig {
|
|
|
30
28
|
return sessionConfigs.get(sessionId) || loadConfig()
|
|
31
29
|
}
|
|
32
30
|
|
|
33
|
-
// ─── Plugin Entry ───────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
const server: Plugin = async (ctx) => {
|
|
36
|
-
// Load and create default config if needed
|
|
37
|
-
createDefaultConfig()
|
|
38
|
-
const globalConfig = loadConfig()
|
|
39
|
-
|
|
40
|
-
// ─── Compress Tool ─────────────────────────────────────────────────────
|
|
41
|
-
const compressTool = tool({
|
|
42
|
-
description: getCompressToolDescription(),
|
|
43
|
-
args: {
|
|
44
|
-
focus: z
|
|
45
|
-
.string()
|
|
46
|
-
.describe("What to compress (e.g., 'old exploration', 'completed tasks')"),
|
|
47
|
-
mode: z
|
|
48
|
-
.enum(["auto", "range", "topic"])
|
|
49
|
-
.default("auto")
|
|
50
|
-
.describe("Compression mode"),
|
|
51
|
-
start: z.number().optional().describe("Start message index (for range mode)"),
|
|
52
|
-
end: z.number().optional().describe("End message index (for range mode)"),
|
|
53
|
-
topic: z.string().optional().describe("Topic to compress (for topic mode)"),
|
|
54
|
-
keepRecent: z.number().default(5).describe("Number of recent messages to always keep"),
|
|
55
|
-
},
|
|
56
|
-
async execute(args, context) {
|
|
57
|
-
const config = getConfig(context.sessionID)
|
|
58
|
-
const state = getState(context.sessionID, config)
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
const response = await ctx.client.session.messages({
|
|
62
|
-
path: { id: context.sessionID },
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
if (!response.data || response.error) {
|
|
66
|
-
return "Failed to fetch messages"
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const messageList = response.data
|
|
70
|
-
const messageWithParts: MessageWithParts[] = messageList.map((m) => ({
|
|
71
|
-
info: m.info,
|
|
72
|
-
parts: m.parts,
|
|
73
|
-
}))
|
|
74
|
-
|
|
75
|
-
// Determine what to compress
|
|
76
|
-
let targetIndices: number[] = []
|
|
77
|
-
let inputTokens = 0
|
|
78
|
-
|
|
79
|
-
if (args.mode === "range" && args.start !== undefined && args.end !== undefined) {
|
|
80
|
-
// Range mode: compress specific range
|
|
81
|
-
const start = Math.max(0, args.start)
|
|
82
|
-
const end = Math.min(messageWithParts.length, args.end)
|
|
83
|
-
for (let i = start; i < end; i++) {
|
|
84
|
-
targetIndices.push(i)
|
|
85
|
-
const text =
|
|
86
|
-
getMessageText(messageWithParts[i]) +
|
|
87
|
-
getToolResultContent(messageWithParts[i])
|
|
88
|
-
inputTokens += await countTokens(text)
|
|
89
|
-
}
|
|
90
|
-
} else if (args.mode === "topic" && args.topic) {
|
|
91
|
-
// Topic mode: compress messages matching topic
|
|
92
|
-
const topicLower = args.topic.toLowerCase()
|
|
93
|
-
for (let i = 0; i < messageWithParts.length - args.keepRecent; i++) {
|
|
94
|
-
const msg = messageWithParts[i]
|
|
95
|
-
const text = getMessageText(msg) + getToolResultContent(msg)
|
|
96
|
-
if (text.toLowerCase().includes(topicLower)) {
|
|
97
|
-
targetIndices.push(i)
|
|
98
|
-
inputTokens += await countTokens(text)
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
} else {
|
|
102
|
-
// Auto mode: smart selection
|
|
103
|
-
const keepRecent = args.keepRecent
|
|
104
|
-
for (let i = 0; i < messageWithParts.length - keepRecent; i++) {
|
|
105
|
-
const msg = messageWithParts[i]
|
|
106
|
-
const text = getMessageText(msg) + getToolResultContent(msg)
|
|
107
|
-
const tokens = await countTokens(text)
|
|
108
|
-
|
|
109
|
-
// Skip if too small to compress
|
|
110
|
-
if (tokens < 100) continue
|
|
111
|
-
|
|
112
|
-
targetIndices.push(i)
|
|
113
|
-
inputTokens += tokens
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (targetIndices.length === 0) {
|
|
118
|
-
return "Nothing to compress - context is already efficient"
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Build summary
|
|
122
|
-
const targetMessages = targetIndices.map((i) => messageWithParts[i])
|
|
123
|
-
const summary = buildCompressionSummary(targetMessages, args.focus)
|
|
124
|
-
|
|
125
|
-
// Count output tokens
|
|
126
|
-
const outputTokens = await countTokens(summary)
|
|
127
|
-
const ratio = inputTokens > 0 ? 1 - outputTokens / inputTokens : 0
|
|
128
|
-
|
|
129
|
-
// Record compression
|
|
130
|
-
addCompressionRecord(
|
|
131
|
-
state,
|
|
132
|
-
{
|
|
133
|
-
timestamp: Date.now(),
|
|
134
|
-
inputTokens,
|
|
135
|
-
outputTokens,
|
|
136
|
-
ratio,
|
|
137
|
-
messageCount: targetMessages.length,
|
|
138
|
-
success: true,
|
|
139
|
-
},
|
|
140
|
-
config.adaptive.learningRate,
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
saveSessionState(state, config.persistence.directory)
|
|
144
|
-
|
|
145
|
-
return {
|
|
146
|
-
title: `Compressed ${targetMessages.length} messages`,
|
|
147
|
-
output: summary,
|
|
148
|
-
metadata: {
|
|
149
|
-
inputTokens,
|
|
150
|
-
outputTokens,
|
|
151
|
-
ratio: Math.round(ratio * 100) + "%",
|
|
152
|
-
mode: args.mode,
|
|
153
|
-
focus: args.focus,
|
|
154
|
-
},
|
|
155
|
-
}
|
|
156
|
-
} catch (error) {
|
|
157
|
-
return `Error compressing: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
// ─── Panel Tool ────────────────────────────────────────────────────────
|
|
163
|
-
const panelTool = tool({
|
|
164
|
-
description: `Display a rich context usage panel showing:
|
|
165
|
-
- Current token usage vs model limit
|
|
166
|
-
- Message breakdown (user/assistant/tools)
|
|
167
|
-
- Token distribution by role
|
|
168
|
-
- Compression history and savings
|
|
169
|
-
- Cost estimate
|
|
170
|
-
- Topic distribution
|
|
171
|
-
- Smart recommendations`,
|
|
172
|
-
args: {},
|
|
173
|
-
async execute(_args, context) {
|
|
174
|
-
const config = getConfig(context.sessionID)
|
|
175
|
-
const state = getState(context.sessionID, config)
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
const response = await ctx.client.session.messages({
|
|
179
|
-
path: { id: context.sessionID },
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
if (!response.data || response.error) {
|
|
183
|
-
return "Failed to fetch messages"
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const messageList = response.data
|
|
187
|
-
const messageWithParts: MessageWithParts[] = messageList.map((m) => ({
|
|
188
|
-
info: m.info,
|
|
189
|
-
parts: m.parts,
|
|
190
|
-
}))
|
|
191
|
-
|
|
192
|
-
// Get model ID from context if available
|
|
193
|
-
const modelId = (context as any).model?.id || "unknown"
|
|
194
|
-
|
|
195
|
-
// Build panel data
|
|
196
|
-
const panelData = await buildPanelData(
|
|
197
|
-
context.sessionID,
|
|
198
|
-
messageWithParts,
|
|
199
|
-
state,
|
|
200
|
-
config,
|
|
201
|
-
modelId,
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
// Render panel
|
|
205
|
-
const panel = renderPanel(panelData)
|
|
206
|
-
|
|
207
|
-
return {
|
|
208
|
-
title: "Context Panel",
|
|
209
|
-
output: panel,
|
|
210
|
-
metadata: {
|
|
211
|
-
usagePercent: panelData.usagePercent,
|
|
212
|
-
status: panelData.status,
|
|
213
|
-
currentTokens: panelData.currentTokens,
|
|
214
|
-
maxTokens: panelData.maxTokens,
|
|
215
|
-
},
|
|
216
|
-
}
|
|
217
|
-
} catch (error) {
|
|
218
|
-
return `Error generating panel: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
219
|
-
}
|
|
220
|
-
},
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
// ─── Return Hooks ──────────────────────────────────────────────────────
|
|
224
|
-
return {
|
|
225
|
-
config: async (opencodeConfig) => {
|
|
226
|
-
// Add tool permissions
|
|
227
|
-
if (!opencodeConfig.permission) {
|
|
228
|
-
opencodeConfig.permission = {} as any
|
|
229
|
-
}
|
|
230
|
-
;(opencodeConfig.permission as any).compress = globalConfig.compress.permission
|
|
231
|
-
;(opencodeConfig.permission as any).panel = "allow"
|
|
232
|
-
},
|
|
233
|
-
|
|
234
|
-
tool: {
|
|
235
|
-
compress: compressTool,
|
|
236
|
-
panel: panelTool,
|
|
237
|
-
},
|
|
238
|
-
|
|
239
|
-
"experimental.chat.system.transform": async (input, output) => {
|
|
240
|
-
const config = getConfig(input.sessionID || "")
|
|
241
|
-
if (!config.enabled || !config.compress.enabled) {
|
|
242
|
-
return
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const state = getState(input.sessionID || "", config)
|
|
246
|
-
|
|
247
|
-
// Track model context limit
|
|
248
|
-
if (input.model?.limit?.context) {
|
|
249
|
-
state.modelContextLimit = input.model.limit.context
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Add system prompt
|
|
253
|
-
const systemPrompt = getSystemPrompt()
|
|
254
|
-
if (output.system.length > 0) {
|
|
255
|
-
output.system[output.system.length - 1] += "\n\n" + systemPrompt
|
|
256
|
-
} else {
|
|
257
|
-
output.system.push(systemPrompt)
|
|
258
|
-
}
|
|
259
|
-
},
|
|
260
|
-
|
|
261
|
-
"experimental.chat.messages.transform": async (input, output) => {
|
|
262
|
-
const config = getConfig("")
|
|
263
|
-
if (!config.enabled) {
|
|
264
|
-
return
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Get session ID from first message if available
|
|
268
|
-
const sessionId = output.messages[0]?.info.sessionID || ""
|
|
269
|
-
const state = getState(sessionId, config)
|
|
270
|
-
|
|
271
|
-
// Apply pruning strategies
|
|
272
|
-
const prunedMessages = pruneMessages(
|
|
273
|
-
output.messages as any,
|
|
274
|
-
config,
|
|
275
|
-
output.messages.length,
|
|
276
|
-
)
|
|
277
|
-
|
|
278
|
-
// Replace messages
|
|
279
|
-
output.messages.length = 0
|
|
280
|
-
output.messages.push(...(prunedMessages as any))
|
|
281
|
-
|
|
282
|
-
// Check if compression nudge is needed
|
|
283
|
-
let totalTokens = 0
|
|
284
|
-
for (const msg of output.messages) {
|
|
285
|
-
const text = getMessageText(msg as any) + getToolResultContent(msg as any)
|
|
286
|
-
totalTokens += await countTokens(text)
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
state.currentTokenCount = totalTokens
|
|
290
|
-
|
|
291
|
-
const maxTokens = resolveTokenLimit(
|
|
292
|
-
config.compress.maxContextLimit,
|
|
293
|
-
state.modelContextLimit,
|
|
294
|
-
)
|
|
295
|
-
const minTokens = resolveTokenLimit(
|
|
296
|
-
config.compress.minContextLimit,
|
|
297
|
-
state.modelContextLimit,
|
|
298
|
-
)
|
|
299
|
-
|
|
300
|
-
const shouldComp = shouldCompress(
|
|
301
|
-
totalTokens,
|
|
302
|
-
maxTokens,
|
|
303
|
-
minTokens,
|
|
304
|
-
state.lastCompressionTime,
|
|
305
|
-
config.compress.nudgeFrequency,
|
|
306
|
-
output.messages.length,
|
|
307
|
-
)
|
|
308
|
-
|
|
309
|
-
if (shouldComp.compress && !state.manualMode) {
|
|
310
|
-
// Inject nudge as a system message
|
|
311
|
-
const nudgeMessage = getNudgeMessage(
|
|
312
|
-
shouldComp.reason,
|
|
313
|
-
totalTokens,
|
|
314
|
-
maxTokens,
|
|
315
|
-
)
|
|
316
|
-
output.messages.push({
|
|
317
|
-
info: {
|
|
318
|
-
role: "assistant",
|
|
319
|
-
sessionID: sessionId,
|
|
320
|
-
} as any,
|
|
321
|
-
parts: [{ type: "text", text: nudgeMessage }],
|
|
322
|
-
} as any)
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
saveSessionState(state, config.persistence.directory)
|
|
326
|
-
},
|
|
327
|
-
|
|
328
|
-
event: async (input) => {
|
|
329
|
-
const event = input.event
|
|
330
|
-
if (event.type === "session.created") {
|
|
331
|
-
const sessionId = (event as any).properties?.sessionID || ""
|
|
332
|
-
const config = getConfig(sessionId)
|
|
333
|
-
sessionConfigs.set(sessionId, config)
|
|
334
|
-
getState(sessionId, config)
|
|
335
|
-
}
|
|
336
|
-
},
|
|
337
|
-
|
|
338
|
-
dispose: async () => {
|
|
339
|
-
// Save all states on dispose
|
|
340
|
-
for (const [sessionId, state] of sessionStates.entries()) {
|
|
341
|
-
const config = getConfig(sessionId)
|
|
342
|
-
saveSessionState(state, config.persistence.directory)
|
|
343
|
-
}
|
|
344
|
-
},
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
31
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
349
32
|
|
|
350
33
|
function buildCompressionSummary(messages: MessageWithParts[], focus: string): string {
|
|
@@ -354,7 +37,6 @@ function buildCompressionSummary(messages: MessageWithParts[], focus: string): s
|
|
|
354
37
|
lines.push(`Messages compressed: ${messages.length}`)
|
|
355
38
|
lines.push("")
|
|
356
39
|
|
|
357
|
-
// Extract key information
|
|
358
40
|
const toolCalls: string[] = []
|
|
359
41
|
const errors: string[] = []
|
|
360
42
|
const decisions: string[] = []
|
|
@@ -404,4 +86,324 @@ function buildCompressionSummary(messages: MessageWithParts[], focus: string): s
|
|
|
404
86
|
return lines.join("\n")
|
|
405
87
|
}
|
|
406
88
|
|
|
407
|
-
|
|
89
|
+
// ─── Plugin Entry ───────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export default Plugin.define({
|
|
92
|
+
id: "opencodev2-slim",
|
|
93
|
+
async setup(ctx) {
|
|
94
|
+
// Load and create default config if needed
|
|
95
|
+
createDefaultConfig()
|
|
96
|
+
const globalConfig = loadConfig()
|
|
97
|
+
|
|
98
|
+
// ─── Register Compress Tool ───────────────────────────────────────
|
|
99
|
+
await ctx.tool.transform((editor) => {
|
|
100
|
+
editor.namespace({
|
|
101
|
+
name: "slim",
|
|
102
|
+
description: "Smart context management tools",
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
editor.add({
|
|
106
|
+
name: "compress",
|
|
107
|
+
description: getCompressToolDescription(),
|
|
108
|
+
input: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {
|
|
111
|
+
focus: {
|
|
112
|
+
type: "string",
|
|
113
|
+
description: "What to compress (e.g., 'old exploration', 'completed tasks')",
|
|
114
|
+
},
|
|
115
|
+
mode: {
|
|
116
|
+
type: "string",
|
|
117
|
+
enum: ["auto", "range", "topic"],
|
|
118
|
+
default: "auto",
|
|
119
|
+
description: "Compression mode",
|
|
120
|
+
},
|
|
121
|
+
start: {
|
|
122
|
+
type: "number",
|
|
123
|
+
description: "Start message index (for range mode)",
|
|
124
|
+
},
|
|
125
|
+
end: {
|
|
126
|
+
type: "number",
|
|
127
|
+
description: "End message index (for range mode)",
|
|
128
|
+
},
|
|
129
|
+
topic: {
|
|
130
|
+
type: "string",
|
|
131
|
+
description: "Topic to compress (for topic mode)",
|
|
132
|
+
},
|
|
133
|
+
keepRecent: {
|
|
134
|
+
type: "number",
|
|
135
|
+
default: 5,
|
|
136
|
+
description: "Number of recent messages to always keep",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
required: ["focus"],
|
|
140
|
+
additionalProperties: false,
|
|
141
|
+
},
|
|
142
|
+
execute: async (input, context) => {
|
|
143
|
+
const args = input as {
|
|
144
|
+
focus: string
|
|
145
|
+
mode?: string
|
|
146
|
+
start?: number
|
|
147
|
+
end?: number
|
|
148
|
+
topic?: string
|
|
149
|
+
keepRecent?: number
|
|
150
|
+
}
|
|
151
|
+
const mode = args.mode || "auto"
|
|
152
|
+
const keepRecent = args.keepRecent || 5
|
|
153
|
+
|
|
154
|
+
// Get session ID from context or fallback
|
|
155
|
+
const sessionId = (context as any).sessionID || ""
|
|
156
|
+
const config = getConfig(sessionId)
|
|
157
|
+
const state = getState(sessionId, config)
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const messages = await ctx.session.context({ sessionID: sessionId })
|
|
161
|
+
|
|
162
|
+
if (!messages || messages.length === 0) {
|
|
163
|
+
return { content: "No messages found in session" }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const messageWithParts: MessageWithParts[] = messages.map((m: any) => ({
|
|
167
|
+
info: m.info || m,
|
|
168
|
+
parts: m.parts || [],
|
|
169
|
+
}))
|
|
170
|
+
|
|
171
|
+
// Determine what to compress
|
|
172
|
+
let targetIndices: number[] = []
|
|
173
|
+
let inputTokens = 0
|
|
174
|
+
|
|
175
|
+
if (mode === "range" && args.start !== undefined && args.end !== undefined) {
|
|
176
|
+
const start = Math.max(0, args.start)
|
|
177
|
+
const end = Math.min(messageWithParts.length, args.end)
|
|
178
|
+
for (let i = start; i < end; i++) {
|
|
179
|
+
targetIndices.push(i)
|
|
180
|
+
const text =
|
|
181
|
+
getMessageText(messageWithParts[i]) +
|
|
182
|
+
getToolResultContent(messageWithParts[i])
|
|
183
|
+
inputTokens += await countTokens(text)
|
|
184
|
+
}
|
|
185
|
+
} else if (mode === "topic" && args.topic) {
|
|
186
|
+
const topicLower = args.topic.toLowerCase()
|
|
187
|
+
for (let i = 0; i < messageWithParts.length - keepRecent; i++) {
|
|
188
|
+
const msg = messageWithParts[i]
|
|
189
|
+
const text = getMessageText(msg) + getToolResultContent(msg)
|
|
190
|
+
if (text.toLowerCase().includes(topicLower)) {
|
|
191
|
+
targetIndices.push(i)
|
|
192
|
+
inputTokens += await countTokens(text)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
// Auto mode
|
|
197
|
+
for (let i = 0; i < messageWithParts.length - keepRecent; i++) {
|
|
198
|
+
const msg = messageWithParts[i]
|
|
199
|
+
const text = getMessageText(msg) + getToolResultContent(msg)
|
|
200
|
+
const tokens = await countTokens(text)
|
|
201
|
+
if (tokens < 100) continue
|
|
202
|
+
targetIndices.push(i)
|
|
203
|
+
inputTokens += tokens
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (targetIndices.length === 0) {
|
|
208
|
+
return { content: "Nothing to compress - context is already efficient" }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const targetMessages = targetIndices.map((i) => messageWithParts[i])
|
|
212
|
+
const summary = buildCompressionSummary(targetMessages, args.focus)
|
|
213
|
+
|
|
214
|
+
const outputTokens = await countTokens(summary)
|
|
215
|
+
const ratio = inputTokens > 0 ? 1 - outputTokens / inputTokens : 0
|
|
216
|
+
|
|
217
|
+
addCompressionRecord(
|
|
218
|
+
state,
|
|
219
|
+
{
|
|
220
|
+
timestamp: Date.now(),
|
|
221
|
+
inputTokens,
|
|
222
|
+
outputTokens,
|
|
223
|
+
ratio,
|
|
224
|
+
messageCount: targetMessages.length,
|
|
225
|
+
success: true,
|
|
226
|
+
},
|
|
227
|
+
config.adaptive.learningRate,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
saveSessionState(state, config.persistence.directory)
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
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}`,
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return {
|
|
237
|
+
content: `Error compressing: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
editor.add({
|
|
244
|
+
name: "panel",
|
|
245
|
+
description: `Display a rich context usage panel showing:
|
|
246
|
+
- Current token usage vs model limit
|
|
247
|
+
- Message breakdown (user/assistant/tools)
|
|
248
|
+
- Token distribution by role
|
|
249
|
+
- Compression history and savings
|
|
250
|
+
- Cost estimate
|
|
251
|
+
- Topic distribution
|
|
252
|
+
- Smart recommendations`,
|
|
253
|
+
input: {
|
|
254
|
+
type: "object",
|
|
255
|
+
properties: {},
|
|
256
|
+
additionalProperties: false,
|
|
257
|
+
},
|
|
258
|
+
execute: async (_input, context) => {
|
|
259
|
+
const sessionId = (context as any).sessionID || ""
|
|
260
|
+
const config = getConfig(sessionId)
|
|
261
|
+
const state = getState(sessionId, config)
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const messages = await ctx.session.context({ sessionID: sessionId })
|
|
265
|
+
|
|
266
|
+
if (!messages || messages.length === 0) {
|
|
267
|
+
return { content: "No messages found in session" }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const messageWithParts: MessageWithParts[] = messages.map((m: any) => ({
|
|
271
|
+
info: m.info || m,
|
|
272
|
+
parts: m.parts || [],
|
|
273
|
+
}))
|
|
274
|
+
|
|
275
|
+
const modelId = (context as any).model?.id || "unknown"
|
|
276
|
+
|
|
277
|
+
const panelData = await buildPanelData(
|
|
278
|
+
sessionId,
|
|
279
|
+
messageWithParts,
|
|
280
|
+
state,
|
|
281
|
+
config,
|
|
282
|
+
modelId,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
const panel = renderPanel(panelData)
|
|
286
|
+
|
|
287
|
+
return { content: panel }
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return {
|
|
290
|
+
content: `Error generating panel: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// ─── System Prompt Hook ──────────────────────────────────────────
|
|
298
|
+
await ctx.session.hook("context", (event) => {
|
|
299
|
+
const sessionId = (event as any).sessionID || ""
|
|
300
|
+
const config = getConfig(sessionId)
|
|
301
|
+
if (!config.enabled || !config.compress.enabled) {
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const state = getState(sessionId, config)
|
|
306
|
+
|
|
307
|
+
// Track model context limit
|
|
308
|
+
if ((event as any).model?.limit?.context) {
|
|
309
|
+
state.modelContextLimit = (event as any).model.limit.context
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Add system prompt
|
|
313
|
+
const systemPrompt = getSystemPrompt()
|
|
314
|
+
event.system.push({ type: "text", text: systemPrompt })
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
// ─── Messages Transform Hook ─────────────────────────────────────
|
|
318
|
+
await ctx.session.hook("context", async (event) => {
|
|
319
|
+
const config = getConfig("")
|
|
320
|
+
if (!config.enabled) {
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const sessionId = (event as any).sessionID || ""
|
|
325
|
+
const state = getState(sessionId, config)
|
|
326
|
+
|
|
327
|
+
// Apply pruning strategies to messages
|
|
328
|
+
if (event.messages && Array.isArray(event.messages)) {
|
|
329
|
+
const prunedMessages = pruneMessages(
|
|
330
|
+
event.messages as any,
|
|
331
|
+
config,
|
|
332
|
+
event.messages.length,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
// Replace messages
|
|
336
|
+
event.messages.length = 0
|
|
337
|
+
event.messages.push(...(prunedMessages as any))
|
|
338
|
+
|
|
339
|
+
// Check if compression nudge is needed
|
|
340
|
+
let totalTokens = 0
|
|
341
|
+
for (const msg of event.messages) {
|
|
342
|
+
const text = getMessageText(msg as any) + getToolResultContent(msg as any)
|
|
343
|
+
totalTokens += await countTokens(text)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
state.currentTokenCount = totalTokens
|
|
347
|
+
|
|
348
|
+
const maxTokens = resolveTokenLimit(
|
|
349
|
+
config.compress.maxContextLimit,
|
|
350
|
+
state.modelContextLimit,
|
|
351
|
+
)
|
|
352
|
+
const minTokens = resolveTokenLimit(
|
|
353
|
+
config.compress.minContextLimit,
|
|
354
|
+
state.modelContextLimit,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
const shouldComp = shouldCompress(
|
|
358
|
+
totalTokens,
|
|
359
|
+
maxTokens,
|
|
360
|
+
minTokens,
|
|
361
|
+
state.lastCompressionTime,
|
|
362
|
+
config.compress.nudgeFrequency,
|
|
363
|
+
event.messages.length,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
if (shouldComp.compress && !state.manualMode) {
|
|
367
|
+
const nudgeMessage = getNudgeMessage(
|
|
368
|
+
shouldComp.reason,
|
|
369
|
+
totalTokens,
|
|
370
|
+
maxTokens,
|
|
371
|
+
)
|
|
372
|
+
event.messages.push({
|
|
373
|
+
info: {
|
|
374
|
+
role: "assistant",
|
|
375
|
+
sessionID: sessionId,
|
|
376
|
+
} as any,
|
|
377
|
+
parts: [{ type: "text", text: nudgeMessage }],
|
|
378
|
+
} as any)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
saveSessionState(state, config.persistence.directory)
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
// ─── Event Subscription ──────────────────────────────────────────
|
|
386
|
+
const eventController = new AbortController()
|
|
387
|
+
void (async () => {
|
|
388
|
+
for await (const event of ctx.event.subscribe({ signal: eventController.signal })) {
|
|
389
|
+
if (event.type === "session.created") {
|
|
390
|
+
const properties = (event as any).properties || {}
|
|
391
|
+
const sessionId = properties.sessionID || ""
|
|
392
|
+
const config = getConfig(sessionId)
|
|
393
|
+
sessionConfigs.set(sessionId, config)
|
|
394
|
+
getState(sessionId, config)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
})()
|
|
398
|
+
|
|
399
|
+
// ─── Cleanup ─────────────────────────────────────────────────────
|
|
400
|
+
return () => {
|
|
401
|
+
eventController.abort()
|
|
402
|
+
// Save all states on dispose
|
|
403
|
+
for (const [sessionId, state] of sessionStates.entries()) {
|
|
404
|
+
const config = getConfig(sessionId)
|
|
405
|
+
saveSessionState(state, config.persistence.directory)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
})
|