@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/src/lib/tui.ts ADDED
@@ -0,0 +1,336 @@
1
+ import type { MessageWithParts, SessionState, SlimConfig, CompressionRecord } from "./types"
2
+ import { getMessageText, getToolResultContent, getToolName, countTokens } from "./compress"
3
+ import { COST_PROFILES } from "./types"
4
+ import { resolveTokenLimit } from "./config"
5
+
6
+ // ─── Panel Data Types ──────────────────────────────────────────────────────
7
+
8
+ export interface PanelData {
9
+ sessionId: string
10
+ timestamp: number
11
+
12
+ // Context usage
13
+ currentTokens: number
14
+ maxTokens: number
15
+ usagePercent: number
16
+ status: "healthy" | "warning" | "critical"
17
+
18
+ // Message breakdown
19
+ messageCount: number
20
+ userMessages: number
21
+ assistantMessages: number
22
+ toolCalls: number
23
+ toolResults: number
24
+
25
+ // Token breakdown
26
+ tokensByRole: {
27
+ user: number
28
+ assistant: number
29
+ tools: number
30
+ system: number
31
+ }
32
+
33
+ // Compression stats
34
+ compressionCount: number
35
+ averageRatio: number
36
+ totalTokensSaved: number
37
+ lastCompression: CompressionRecord | null
38
+
39
+ // Cost estimate
40
+ estimatedCost: number
41
+ costSaved: number
42
+ model: string
43
+
44
+ // Topic distribution
45
+ topics: { topic: string; count: number; tokens: number }[]
46
+
47
+ // Recommendations
48
+ recommendations: string[]
49
+ }
50
+
51
+ // ─── Panel Builder ─────────────────────────────────────────────────────────
52
+
53
+ export async function buildPanelData(
54
+ sessionId: string,
55
+ messages: MessageWithParts[],
56
+ state: SessionState,
57
+ config: SlimConfig,
58
+ modelId?: string,
59
+ ): Promise<PanelData> {
60
+ const modelContextLimit = state.modelContextLimit || 200000
61
+ const maxTokens = resolveTokenLimit(config.compress.maxContextLimit, modelContextLimit)
62
+
63
+ // Count tokens
64
+ let currentTokens = 0
65
+ const tokensByRole = { user: 0, assistant: 0, tools: 0, system: 0 }
66
+ let userMessages = 0
67
+ let assistantMessages = 0
68
+ let toolCalls = 0
69
+ let toolResults = 0
70
+
71
+ for (const msg of messages) {
72
+ const role = msg.info.role
73
+ const text = getMessageText(msg)
74
+ const toolContent = getToolResultContent(msg)
75
+ const msgTokens = await countTokens(text + toolContent)
76
+ currentTokens += msgTokens
77
+
78
+ if (role === "user") {
79
+ tokensByRole.user += msgTokens
80
+ userMessages++
81
+ } else if (role === "assistant") {
82
+ tokensByRole.assistant += msgTokens
83
+ assistantMessages++
84
+ }
85
+
86
+ // Count tool parts
87
+ for (const part of msg.parts) {
88
+ if (part.type === "tool") {
89
+ const toolPart = part as any
90
+ if (toolPart.state?.status === "completed") {
91
+ toolResults++
92
+ } else {
93
+ toolCalls++
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ tokensByRole.tools = tokensByRole.user + tokensByRole.assistant - tokensByRole.user - tokensByRole.assistant
100
+ tokensByRole.system = Math.max(0, currentTokens - tokensByRole.user - tokensByRole.assistant - tokensByRole.tools)
101
+
102
+ // Calculate status
103
+ const usagePercent = (currentTokens / maxTokens) * 100
104
+ let status: "healthy" | "warning" | "critical" = "healthy"
105
+ if (usagePercent > 90) status = "critical"
106
+ else if (usagePercent > 70) status = "warning"
107
+
108
+ // Compression stats
109
+ const compressionCount = state.compressionCount
110
+ const averageRatio = state.averageCompressionRatio
111
+ let totalTokensSaved = 0
112
+ for (const record of state.compressionHistory) {
113
+ if (record.success) {
114
+ totalTokensSaved += record.inputTokens - record.outputTokens
115
+ }
116
+ }
117
+ const lastCompression = state.compressionHistory.length > 0
118
+ ? state.compressionHistory[state.compressionHistory.length - 1]
119
+ : null
120
+
121
+ // Cost estimate
122
+ const profile = COST_PROFILES[modelId || "default"] || COST_PROFILES.default
123
+ const estimatedCost = (currentTokens / 1000) * profile.inputPricePer1k
124
+ const costSaved = (totalTokensSaved / 1000) * profile.inputPricePer1k
125
+
126
+ // Topic distribution
127
+ const topicMap = new Map<string, { count: number; tokens: number }>()
128
+ for (const msg of messages) {
129
+ const text = getMessageText(msg)
130
+ const topics = extractTopics(text)
131
+ for (const topic of topics) {
132
+ const existing = topicMap.get(topic) || { count: 0, tokens: 0 }
133
+ existing.count++
134
+ existing.tokens += await countTokens(text)
135
+ topicMap.set(topic, existing)
136
+ }
137
+ }
138
+ const topics = Array.from(topicMap.entries())
139
+ .map(([topic, data]) => ({ topic, ...data }))
140
+ .sort((a, b) => b.tokens - a.tokens)
141
+ .slice(0, 10)
142
+
143
+ // Recommendations
144
+ const recommendations = generateRecommendations(
145
+ usagePercent,
146
+ compressionCount,
147
+ averageRatio,
148
+ messages.length,
149
+ config,
150
+ )
151
+
152
+ return {
153
+ sessionId,
154
+ timestamp: Date.now(),
155
+ currentTokens,
156
+ maxTokens,
157
+ usagePercent,
158
+ status,
159
+ messageCount: messages.length,
160
+ userMessages,
161
+ assistantMessages,
162
+ toolCalls,
163
+ toolResults,
164
+ tokensByRole,
165
+ compressionCount,
166
+ averageRatio,
167
+ totalTokensSaved,
168
+ lastCompression,
169
+ estimatedCost,
170
+ costSaved,
171
+ model: modelId || "unknown",
172
+ topics,
173
+ recommendations,
174
+ }
175
+ }
176
+
177
+ // ─── Topic Extraction ──────────────────────────────────────────────────────
178
+
179
+ const TOPIC_KEYWORDS: Record<string, string[]> = {
180
+ "authentication": ["auth", "login", "password", "token", "session", "jwt"],
181
+ "database": ["database", "db", "sql", "query", "migration", "schema"],
182
+ "api": ["api", "endpoint", "route", "request", "response", "http"],
183
+ "testing": ["test", "spec", "assert", "expect", "describe", "jest"],
184
+ "configuration": ["config", "settings", "env", "environment", "variable"],
185
+ "deployment": ["deploy", "docker", "kubernetes", "ci", "cd", "pipeline"],
186
+ "ui": ["ui", "component", "render", "display", "style", "css"],
187
+ "error": ["error", "exception", "catch", "throw", "debug", "fix"],
188
+ "performance": ["performance", "optimize", "cache", "speed", "slow"],
189
+ "security": ["security", "encrypt", "decrypt", "hash", "sanitize"],
190
+ }
191
+
192
+ function extractTopics(text: string): string[] {
193
+ const lower = text.toLowerCase()
194
+ const topics: string[] = []
195
+
196
+ for (const [topic, keywords] of Object.entries(TOPIC_KEYWORDS)) {
197
+ if (keywords.some((kw) => lower.includes(kw))) {
198
+ topics.push(topic)
199
+ }
200
+ }
201
+
202
+ return topics.length > 0 ? topics : ["general"]
203
+ }
204
+
205
+ // ─── Recommendations ───────────────────────────────────────────────────────
206
+
207
+ function generateRecommendations(
208
+ usagePercent: number,
209
+ compressionCount: number,
210
+ averageRatio: number,
211
+ messageCount: number,
212
+ config: SlimConfig,
213
+ ): string[] {
214
+ const recs: string[] = []
215
+
216
+ if (usagePercent > 80) {
217
+ recs.push("Context usage is high. Consider compressing older messages.")
218
+ }
219
+
220
+ if (usagePercent > 90) {
221
+ recs.push("Context nearly full! Run compress immediately to avoid truncation.")
222
+ }
223
+
224
+ if (compressionCount === 0 && messageCount > 20) {
225
+ recs.push("No compressions yet with many messages. Consider running compress.")
226
+ }
227
+
228
+ if (averageRatio < 0.3 && compressionCount > 0) {
229
+ recs.push("Compression ratio is low. Summaries may be too verbose.")
230
+ }
231
+
232
+ if (messageCount > 50 && usagePercent < 50) {
233
+ recs.push("Many messages but low usage. Deduplication may help further.")
234
+ }
235
+
236
+ if (recs.length === 0) {
237
+ recs.push("Context is healthy. No action needed.")
238
+ }
239
+
240
+ return recs
241
+ }
242
+
243
+ // ─── Panel Renderer ────────────────────────────────────────────────────────
244
+
245
+ export function renderPanel(data: PanelData): string {
246
+ const lines: string[] = []
247
+
248
+ // Header
249
+ lines.push("┌─────────────────────────────────────────────────────────────┐")
250
+ lines.push("│ SLIM CONTEXT PANEL │")
251
+ lines.push("├─────────────────────────────────────────────────────────────┤")
252
+
253
+ // Status indicator
254
+ const statusIcon = data.status === "healthy" ? "🟢" : data.status === "warning" ? "🟡" : "🔴"
255
+ lines.push(`│ Status: ${statusIcon} ${data.status.toUpperCase().padEnd(10)} │`)
256
+ lines.push("")
257
+
258
+ // Context usage bar
259
+ const barLength = 30
260
+ const filledLength = Math.round((data.usagePercent / 100) * barLength)
261
+ const emptyLength = barLength - filledLength
262
+ const bar = "█".repeat(filledLength) + "░".repeat(emptyLength)
263
+ lines.push(`│ Context: [${bar}] ${data.usagePercent.toFixed(1)}%`)
264
+ lines.push(`│ ${formatTokens(data.currentTokens)} / ${formatTokens(data.maxTokens)} tokens`)
265
+ lines.push("")
266
+
267
+ // Message breakdown
268
+ lines.push("│ Messages:")
269
+ lines.push(`│ User: ${data.userMessages} Assistant: ${data.assistantMessages}`)
270
+ lines.push(`│ Tool calls: ${data.toolCalls} Results: ${data.toolResults}`)
271
+ lines.push("")
272
+
273
+ // Token breakdown
274
+ lines.push("│ Token Distribution:")
275
+ lines.push(`│ User: ${formatTokens(data.tokensByRole.user)}`)
276
+ lines.push(`│ Assistant: ${formatTokens(data.tokensByRole.assistant)}`)
277
+ lines.push("")
278
+
279
+ // Compression stats
280
+ lines.push("│ Compression Stats:")
281
+ lines.push(`│ Count: ${data.compressionCount}`)
282
+ lines.push(`│ Avg ratio: ${(data.averageRatio * 100).toFixed(1)}%`)
283
+ lines.push(`│ Tokens saved: ${formatTokens(data.totalTokensSaved)}`)
284
+ if (data.lastCompression) {
285
+ const ago = Date.now() - data.lastCompression.timestamp
286
+ lines.push(`│ Last: ${formatTimeAgo(ago)} ago`)
287
+ }
288
+ lines.push("")
289
+
290
+ // Cost
291
+ lines.push("│ Cost Estimate:")
292
+ lines.push(`│ Current: $${data.estimatedCost.toFixed(4)}`)
293
+ lines.push(`│ Saved: $${data.costSaved.toFixed(4)}`)
294
+ lines.push(`│ Model: ${data.model}`)
295
+ lines.push("")
296
+
297
+ // Topics
298
+ if (data.topics.length > 0) {
299
+ lines.push("│ Top Topics:")
300
+ for (const topic of data.topics.slice(0, 5)) {
301
+ lines.push(`│ ${topic.topic}: ${topic.count} msgs (${formatTokens(topic.tokens)})`)
302
+ }
303
+ lines.push("")
304
+ }
305
+
306
+ // Recommendations
307
+ lines.push("│ Recommendations:")
308
+ for (const rec of data.recommendations) {
309
+ lines.push(`│ • ${rec}`)
310
+ }
311
+
312
+ lines.push("└─────────────────────────────────────────────────────────────┘")
313
+
314
+ return lines.join("\n")
315
+ }
316
+
317
+ // ─── Helpers ───────────────────────────────────────────────────────────────
318
+
319
+ function formatTokens(tokens: number): string {
320
+ if (tokens >= 1000000) {
321
+ return `${(tokens / 1000000).toFixed(1)}M`
322
+ }
323
+ if (tokens >= 1000) {
324
+ return `${(tokens / 1000).toFixed(1)}K`
325
+ }
326
+ return String(tokens)
327
+ }
328
+
329
+ function formatTimeAgo(ms: number): string {
330
+ const seconds = Math.floor(ms / 1000)
331
+ if (seconds < 60) return `${seconds}s`
332
+ const minutes = Math.floor(seconds / 60)
333
+ if (minutes < 60) return `${minutes}m`
334
+ const hours = Math.floor(minutes / 60)
335
+ return `${hours}h`
336
+ }
@@ -0,0 +1,154 @@
1
+ import type { Message, Part } from "@opencode-ai/sdk/v2"
2
+
3
+ // ─── Config Types ───────────────────────────────────────────────────────────
4
+
5
+ export interface SlimConfig {
6
+ enabled: boolean
7
+ debug: boolean
8
+
9
+ // Compression settings
10
+ compress: {
11
+ enabled: boolean
12
+ permission: "allow" | "ask" | "deny"
13
+ maxContextLimit: number | string // number or "80%"
14
+ minContextLimit: number | string // number or "40%"
15
+ nudgeFrequency: number
16
+ protectUserMessages: boolean
17
+ protectedTools: string[]
18
+ }
19
+
20
+ // Strategy settings
21
+ strategies: {
22
+ deduplication: {
23
+ enabled: boolean
24
+ protectedTools: string[]
25
+ }
26
+ purgeErrors: {
27
+ enabled: boolean
28
+ turns: number
29
+ protectedTools: string[]
30
+ }
31
+ }
32
+
33
+ // Adaptive thresholds
34
+ adaptive: {
35
+ enabled: boolean
36
+ learningRate: number
37
+ minCompressionRatio: number
38
+ }
39
+
40
+ // Cost awareness
41
+ costAware: {
42
+ enabled: boolean
43
+ cacheBoostFactor: number
44
+ }
45
+
46
+ // Persistence
47
+ persistence: {
48
+ enabled: boolean
49
+ directory: string
50
+ }
51
+ }
52
+
53
+ // ─── State Types ────────────────────────────────────────────────────────────
54
+
55
+ export interface SessionState {
56
+ sessionId: string
57
+ modelContextLimit: number
58
+ currentTokenCount: number
59
+ compressionCount: number
60
+ lastCompressionTime: number
61
+ manualMode: boolean
62
+ compressPermission: "allow" | "ask" | "deny" | null
63
+
64
+ // Adaptive learning
65
+ compressionHistory: CompressionRecord[]
66
+ averageCompressionRatio: number
67
+
68
+ // Tool call tracking
69
+ toolCalls: Map<string, ToolCallInfo>
70
+ }
71
+
72
+ export interface CompressionRecord {
73
+ timestamp: number
74
+ inputTokens: number
75
+ outputTokens: number
76
+ ratio: number
77
+ messageCount: number
78
+ success: boolean
79
+ }
80
+
81
+ export interface ToolCallInfo {
82
+ tool: string
83
+ args: unknown
84
+ timestamp: number
85
+ turn: number
86
+ error?: string
87
+ }
88
+
89
+ // ─── Message Types ──────────────────────────────────────────────────────────
90
+
91
+ export interface MessageWithParts {
92
+ info: Message
93
+ parts: Part[]
94
+ }
95
+
96
+ export interface CompressionRange {
97
+ start: number
98
+ end: number
99
+ messages: MessageWithParts[]
100
+ }
101
+
102
+ export interface CompressionResult {
103
+ success: boolean
104
+ summary: string
105
+ inputTokens: number
106
+ outputTokens: number
107
+ ratio: number
108
+ compressedIds: string[]
109
+ }
110
+
111
+ // ─── Cost Types ─────────────────────────────────────────────────────────────
112
+
113
+ export interface CostProfile {
114
+ inputPricePer1k: number
115
+ outputPricePer1k: number
116
+ cacheReadPricePer1k: number
117
+ cacheWritePricePer1k: number
118
+ }
119
+
120
+ export const COST_PROFILES: Record<string, CostProfile> = {
121
+ // Anthropic
122
+ "anthropic/claude-sonnet-4-20250514": {
123
+ inputPricePer1k: 0.003,
124
+ outputPricePer1k: 0.015,
125
+ cacheReadPricePer1k: 0.0003,
126
+ cacheWritePricePer1k: 0.00375,
127
+ },
128
+ "anthropic/claude-3-5-sonnet-20241022": {
129
+ inputPricePer1k: 0.003,
130
+ outputPricePer1k: 0.015,
131
+ cacheReadPricePer1k: 0.0003,
132
+ cacheWritePricePer1k: 0.00375,
133
+ },
134
+ // OpenAI
135
+ "openai/gpt-4o": {
136
+ inputPricePer1k: 0.0025,
137
+ outputPricePer1k: 0.01,
138
+ cacheReadPricePer1k: 0.00125,
139
+ cacheWritePricePer1k: 0.0025,
140
+ },
141
+ "openai/gpt-4o-mini": {
142
+ inputPricePer1k: 0.00015,
143
+ outputPricePer1k: 0.0006,
144
+ cacheReadPricePer1k: 0.000075,
145
+ cacheWritePricePer1k: 0.00015,
146
+ },
147
+ // Default
148
+ default: {
149
+ inputPricePer1k: 0.003,
150
+ outputPricePer1k: 0.015,
151
+ cacheReadPricePer1k: 0.0003,
152
+ cacheWritePricePer1k: 0.00375,
153
+ },
154
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,61 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import type { TuiPluginModule } from "@opencode-ai/plugin/tui"
4
+ import type { SlimConfig } from "./lib/types"
5
+ import { loadConfig } from "./lib/tui/data"
6
+ import { openPanelModal } from "./lib/tui/modals"
7
+
8
+ /**
9
+ * Register slash commands using the v2 keymap API.
10
+ * This plugin is v2-only.
11
+ */
12
+ function registerSlashCommands(api: any, config: SlimConfig): void {
13
+ // v2 way: keymap.registerLayer
14
+ if (api.keymap && typeof api.keymap.registerLayer === "function") {
15
+ api.keymap.registerLayer({
16
+ mode: "global",
17
+ priority: 10,
18
+ commands: [
19
+ {
20
+ name: "slim.panel",
21
+ title: "Open Slim Panel",
22
+ group: "Slim",
23
+ slash: { name: "panel" },
24
+ enabled: () => true,
25
+ suggested: true,
26
+ run: () => {
27
+ openPanelModal(api, config)
28
+ },
29
+ },
30
+ {
31
+ name: "slim.compress",
32
+ title: "Compress Context",
33
+ group: "Slim",
34
+ slash: { name: "compress" },
35
+ enabled: () => true,
36
+ suggested: true,
37
+ run: () => {
38
+ api.ui.toast({
39
+ title: "Slim",
40
+ message:
41
+ "Use the compress tool: compress({ focus: 'your focus' })",
42
+ variant: "info",
43
+ })
44
+ },
45
+ },
46
+ ],
47
+ })
48
+ }
49
+ }
50
+
51
+ const tui: TuiPluginModule["tui"] = async (api) => {
52
+ const config = loadConfig(api as any)
53
+ if (!config.enabled) return
54
+
55
+ registerSlashCommands(api, config)
56
+ }
57
+
58
+ export default {
59
+ id: "opencodev2-slim",
60
+ tui,
61
+ } satisfies TuiPluginModule