@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 +21 -0
- package/README.md +146 -0
- package/package.json +80 -0
- package/src/index.ts +407 -0
- package/src/lib/compress.ts +117 -0
- package/src/lib/config.ts +126 -0
- package/src/lib/prompts.ts +48 -0
- package/src/lib/state.ts +122 -0
- package/src/lib/strategies.ts +49 -0
- package/src/lib/tui/data.ts +90 -0
- package/src/lib/tui/dialogs.tsx +467 -0
- package/src/lib/tui/modals.tsx +12 -0
- package/src/lib/tui.ts +336 -0
- package/src/lib/types.ts +154 -0
- package/src/tui.tsx +61 -0
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
|
+
[](https://www.npmjs.com/package/@serkanalgur/opencodev2-slim)
|
|
4
|
+
[](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 }
|