@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.
Files changed (2) hide show
  1. package/package.json +14 -7
  2. package/src/index.ts +322 -320
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serkanalgur/opencodev2-slim",
3
- "version": "1.0.1",
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.4.5",
51
- "@opentui/keymap": "^0.4.5",
52
- "@opentui/solid": "^0.4.5",
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.4.5",
62
- "@opentui/keymap": ">=0.4.5",
63
- "@opentui/solid": ">=0.4.5",
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 type { Plugin } from "@opencode-ai/plugin"
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
- export default { id: "opencodev2-slim", server }
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
+ })