@serkanalgur/opencodev2-slim 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 serkanalgur
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # opencodev2-slim
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@serkanalgur/opencodev2-slim.svg)](https://www.npmjs.com/package/@serkanalgur/opencodev2-slim)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ Smart context management plugin for OpenCode v2. Optimizes token usage through semantic compression, cost-aware pruning, and adaptive thresholds.
7
+
8
+ > **Note:** This plugin requires OpenCode v2 (1.18.29+). For v1 compatibility, use [@serkanalgur/opencode-slim](https://github.com/serkanalgur/opencode-slim).
9
+
10
+ ## Features
11
+
12
+ - **TUI Panel** - Rich context usage visualization with status indicators
13
+ - **Enhanced Compress** - Auto/range/topic modes for flexible compression
14
+ - **Semantic Compression** - Groups related tool calls and compresses them intelligently
15
+ - **Cost-Aware Pruning** - Considers token pricing when deciding what to compress
16
+ - **Adaptive Thresholds** - Learns from compression history to optimize timing
17
+ - **Session Persistence** - Saves state across restarts
18
+ - **Deduplication** - Removes repeated tool calls automatically
19
+ - **Error Purging** - Cleans up failed tool call outputs after configurable turns
20
+ - **Topic Extraction** - Identifies and tracks conversation topics
21
+ - **Smart Recommendations** - Provides actionable suggestions for context optimization
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ opencode plugin @serkanalgur/opencodev2-slim@latest --global
27
+ ```
28
+
29
+ This installs the plugin globally. The TUI features (panel, slash commands) are automatically loaded when OpenCode starts.
30
+
31
+ ### Manual Installation
32
+
33
+ If the CLI command doesn't work, add to your `~/.config/opencode/opencode.json`:
34
+
35
+ ```json
36
+ {
37
+ "plugins": ["@serkanalgur/opencodev2-slim"]
38
+ }
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ### Slash Commands
44
+
45
+ After installation, these slash commands are available in the TUI:
46
+
47
+ - `/panel` — Opens the rich TUI panel with context usage, stats, and help
48
+ - `/compress` — Shows instructions for using the compress tool
49
+
50
+ ### TUI Panel
51
+
52
+ The panel provides a real-time overview of your context usage, including:
53
+ - Token usage vs model limit with visual progress bar
54
+ - Message breakdown by role (user/assistant/tools)
55
+ - Compression history and savings
56
+ - Cost estimation based on your model
57
+ - Smart recommendations for optimization
58
+
59
+ ### Compress Tool
60
+
61
+ The enhanced compress tool supports multiple modes:
62
+
63
+ ```typescript
64
+ // Auto mode (default) - intelligently selects what to compress
65
+ compress({ focus: "old exploration" })
66
+
67
+ // Range mode - compress specific message range
68
+ compress({ focus: "completed tasks", mode: "range", start: 0, end: 50 })
69
+
70
+ // Topic mode - compress messages matching a topic
71
+ compress({ focus: "database work", mode: "topic", topic: "database" })
72
+ ```
73
+
74
+ ## Configuration
75
+
76
+ Create `~/.config/opencode/slim.jsonc`:
77
+
78
+ ```jsonc
79
+ {
80
+ "enabled": true,
81
+ "compress": {
82
+ "enabled": true,
83
+ "permission": "allow",
84
+ "maxContextLimit": "80%",
85
+ "minContextLimit": "40%",
86
+ "nudgeFrequency": 5,
87
+ "protectUserMessages": false,
88
+ "protectedTools": ["task", "skill", "todowrite", "todoread"]
89
+ },
90
+ "strategies": {
91
+ "deduplication": {
92
+ "enabled": true,
93
+ "protectedTools": []
94
+ },
95
+ "purgeErrors": {
96
+ "enabled": true,
97
+ "turns": 4,
98
+ "protectedTools": []
99
+ }
100
+ },
101
+ "adaptive": {
102
+ "enabled": true,
103
+ "learningRate": 0.1,
104
+ "minCompressionRatio": 0.3
105
+ },
106
+ "costAware": {
107
+ "enabled": true,
108
+ "cacheBoostFactor": 0.5
109
+ },
110
+ "persistence": {
111
+ "enabled": true,
112
+ "directory": "~/.config/opencode/slim"
113
+ }
114
+ }
115
+ ```
116
+
117
+ ## How It Works
118
+
119
+ ### Semantic Compression
120
+
121
+ Unlike simple text truncation, slim analyzes the semantic content of messages and groups related tool calls together. This preserves context while removing redundancy.
122
+
123
+ ### Cost-Aware Pruning
124
+
125
+ Slim considers the cost of tokens when deciding what to compress. It prioritizes compressing expensive operations (like large file reads) while preserving cheap but important context.
126
+
127
+ ### Adaptive Thresholds
128
+
129
+ The plugin learns from your compression patterns and adjusts thresholds over time. If you tend to need more context, it will compress less aggressively. If you're efficient, it will compress more.
130
+
131
+ ### Session Persistence
132
+
133
+ State is saved to disk, so compression history and learning persist across restarts.
134
+
135
+ ## Commands
136
+
137
+ | Command | Description |
138
+ |---------|-------------|
139
+ | `/panel` | Open the Slim TUI panel with context usage, stats, and help |
140
+ | `/compress` | Show instructions for using the compress tool |
141
+
142
+ Note: Compression is performed by the AI assistant using the `compress` tool. The slash command provides guidance on usage.
143
+
144
+ ## License
145
+
146
+ MIT
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@serkanalgur/opencodev2-slim",
3
+ "version": "1.0.0",
4
+ "description": "Smart context management plugin for OpenCode v2 - semantic compression, cost-aware pruning, adaptive thresholds",
5
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "context",
9
+ "compression",
10
+ "pruning",
11
+ "tokens",
12
+ "semantic"
13
+ ],
14
+ "homepage": "https://github.com/serkanalgur/opencodev2-slim#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/serkanalgur/opencodev2-slim/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/serkanalgur/opencodev2-slim.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "serkanalgur",
24
+ "type": "module",
25
+ "exports": {
26
+ ".": "./src/index.ts",
27
+ "./tui": "./src/tui.tsx"
28
+ },
29
+ "main": "index.js",
30
+ "directories": {
31
+ "test": "tests"
32
+ },
33
+ "files": [
34
+ "src/",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "node --import tsx --test tests/*.ts",
41
+ "format": "prettier --write .",
42
+ "format:check": "prettier --check ."
43
+ },
44
+ "dependencies": {
45
+ "@anthropic-ai/tokenizer": "^0.0.4",
46
+ "jsonc-parser": "^3.3.1"
47
+ },
48
+ "devDependencies": {
49
+ "@opencode-ai/plugin": "^1.18.29",
50
+ "@opentui/core": "^0.4.5",
51
+ "@opentui/keymap": "^0.4.5",
52
+ "@opentui/solid": "^0.4.5",
53
+ "@types/node": "^22.0.0",
54
+ "prettier": "^3.4.0",
55
+ "solid-js": "1.9.12",
56
+ "tsx": "^4.19.0",
57
+ "typescript": "^5.7.0"
58
+ },
59
+ "peerDependencies": {
60
+ "@opencode-ai/plugin": ">=1.18.29",
61
+ "@opentui/core": ">=0.4.5",
62
+ "@opentui/keymap": ">=0.4.5",
63
+ "@opentui/solid": ">=0.4.5",
64
+ "solid-js": ">=1.0.0"
65
+ },
66
+ "peerDependenciesMeta": {
67
+ "@opentui/core": {
68
+ "optional": true
69
+ },
70
+ "@opentui/keymap": {
71
+ "optional": true
72
+ },
73
+ "@opentui/solid": {
74
+ "optional": true
75
+ },
76
+ "solid-js": {
77
+ "optional": true
78
+ }
79
+ }
80
+ }
package/src/index.ts ADDED
@@ -0,0 +1,407 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { tool } from "@opencode-ai/plugin"
3
+ import { z } from "zod"
4
+ import { loadConfig, createDefaultConfig, resolveTokenLimit } from "./lib/config"
5
+ import {
6
+ loadSessionState,
7
+ saveSessionState,
8
+ addCompressionRecord,
9
+ } from "./lib/state"
10
+ import { countTokens, shouldCompress, getMessageText, getToolResultContent } from "./lib/compress"
11
+ import { pruneMessages } from "./lib/strategies"
12
+ import { getSystemPrompt, getCompressToolDescription, getNudgeMessage } from "./lib/prompts"
13
+ import { buildPanelData, renderPanel } from "./lib/tui"
14
+ import type { SlimConfig, SessionState, MessageWithParts } from "./lib/types"
15
+
16
+ // ─── State Management ───────────────────────────────────────────────────────
17
+
18
+ const sessionStates = new Map<string, SessionState>()
19
+ const sessionConfigs = new Map<string, SlimConfig>()
20
+
21
+ function getState(sessionId: string, config: SlimConfig): SessionState {
22
+ if (!sessionStates.has(sessionId)) {
23
+ const state = loadSessionState(sessionId, config.persistence.directory)
24
+ sessionStates.set(sessionId, state)
25
+ }
26
+ return sessionStates.get(sessionId)!
27
+ }
28
+
29
+ function getConfig(sessionId: string): SlimConfig {
30
+ return sessionConfigs.get(sessionId) || loadConfig()
31
+ }
32
+
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
+ // ─── Helpers ────────────────────────────────────────────────────────────────
349
+
350
+ function buildCompressionSummary(messages: MessageWithParts[], focus: string): string {
351
+ const lines: string[] = []
352
+ lines.push(`## Compression Summary`)
353
+ lines.push(`Focus: ${focus}`)
354
+ lines.push(`Messages compressed: ${messages.length}`)
355
+ lines.push("")
356
+
357
+ // Extract key information
358
+ const toolCalls: string[] = []
359
+ const errors: string[] = []
360
+ const decisions: string[] = []
361
+
362
+ for (const msg of messages) {
363
+ for (const part of msg.parts) {
364
+ if (part.type === "tool") {
365
+ const toolPart = part as any
366
+ toolCalls.push(
367
+ `${toolPart.tool}: ${JSON.stringify(toolPart.state?.input || {}).slice(0, 100)}`,
368
+ )
369
+ if (toolPart.state?.status === "error") {
370
+ errors.push(toolPart.state.error?.slice(0, 200) || "Unknown error")
371
+ }
372
+ }
373
+ if (part.type === "text") {
374
+ const text = (part as any).text
375
+ if (
376
+ text.includes("decided") ||
377
+ text.includes("chose") ||
378
+ text.includes("implemented")
379
+ ) {
380
+ decisions.push(text.slice(0, 200))
381
+ }
382
+ }
383
+ }
384
+ }
385
+
386
+ if (toolCalls.length > 0) {
387
+ lines.push("### Tool Calls")
388
+ toolCalls.slice(0, 10).forEach((tc) => lines.push(`- ${tc}`))
389
+ lines.push("")
390
+ }
391
+
392
+ if (errors.length > 0) {
393
+ lines.push("### Errors Encountered")
394
+ errors.slice(0, 5).forEach((e) => lines.push(`- ${e}`))
395
+ lines.push("")
396
+ }
397
+
398
+ if (decisions.length > 0) {
399
+ lines.push("### Key Decisions")
400
+ decisions.slice(0, 5).forEach((d) => lines.push(`- ${d}`))
401
+ lines.push("")
402
+ }
403
+
404
+ return lines.join("\n")
405
+ }
406
+
407
+ export default { id: "opencodev2-slim", server }