@serkanalgur/opencodev2-slim 2.0.9 → 2.0.12
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/README.md +19 -0
- package/package.json +5 -2
- package/src/index.ts +40 -14
- package/src/lib/tui.ts +28 -8
- package/src/tui.tsx +7 -1
package/README.md
CHANGED
|
@@ -141,6 +141,25 @@ Note: Compression is performed by the AI assistant using the `compress` tool. Th
|
|
|
141
141
|
|
|
142
142
|
## Changelog
|
|
143
143
|
|
|
144
|
+
### 2.0.12
|
|
145
|
+
|
|
146
|
+
- Bind panel/nudge to real OpenCode context measurements:
|
|
147
|
+
- Resolve the active model's real context window from `ctx.model.default().data.limit.context` instead of the hard-coded 200k.
|
|
148
|
+
- `/panel` now reads live server measurements (`Session.Info.tokens` + `cost`) via `ctx.session.get()` and feeds them to `buildPanelData`, so the headline tokens/percent/cost match what OpenCode's UI reports.
|
|
149
|
+
- The nudge decision prefers the measured token count over the rough 4-char estimation.
|
|
150
|
+
|
|
151
|
+
### 2.0.11
|
|
152
|
+
|
|
153
|
+
- Fix CI publish: add `solid-js`, `@opentui/core`, `@opentui/solid` to `devDependencies`.
|
|
154
|
+
The workflow runs `npm ci --legacy-peer-deps`, which skips peer deps, so loading
|
|
155
|
+
`@opencode/plugin/tui` failed with `Cannot find package 'solid-js'`.
|
|
156
|
+
|
|
157
|
+
### 2.0.10
|
|
158
|
+
|
|
159
|
+
- Fix `/panel` output showing `User tokens: 0`: user/system messages carry their text
|
|
160
|
+
on a top-level `text` field (not inside `content`), which `deriveStats` now captures.
|
|
161
|
+
- Add regression tests for CLI panel stats.
|
|
162
|
+
|
|
144
163
|
### 2.0.9
|
|
145
164
|
|
|
146
165
|
- `feat(panel-as-message)`: `/panel` and `slim-panel` now print the context stats as plain
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serkanalgur/opencodev2-slim",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.12",
|
|
4
4
|
"description": "Smart context management plugin for OpenCode v2 - semantic compression, cost-aware pruning, adaptive thresholds",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
|
@@ -49,10 +49,13 @@
|
|
|
49
49
|
"jsonc-parser": "^3.3.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@opencode/plugin": "^2.0.0",
|
|
53
52
|
"@opencode-ai/sdk": "^1.18.29",
|
|
53
|
+
"@opencode/plugin": "^2.0.0",
|
|
54
|
+
"@opentui/core": "^0.5.11",
|
|
55
|
+
"@opentui/solid": "^0.5.11",
|
|
54
56
|
"@types/node": "^22.0.0",
|
|
55
57
|
"prettier": "^3.4.0",
|
|
58
|
+
"solid-js": "^1.9.12",
|
|
56
59
|
"tsx": "^4.19.0",
|
|
57
60
|
"typescript": "^5.7.0"
|
|
58
61
|
},
|
package/src/index.ts
CHANGED
|
@@ -36,20 +36,13 @@ function getConfig(sessionId: string): SlimConfig {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Resolve the active model's real context limit instead of hard-coding 200k.
|
|
39
|
+
// ctx.model.default() returns { data: ModelInfo | null }, where ModelInfo.limit.context
|
|
40
|
+
// holds the model's context window. Use it directly and only fall back when missing.
|
|
39
41
|
async function resolveModelContextLimit(ctx: any): Promise<number> {
|
|
40
42
|
try {
|
|
41
|
-
const
|
|
42
|
-
const selected: { providerID?: string; modelID?: string } | undefined =
|
|
43
|
+
const selected: { data?: { limit?: { context?: number } } | null } | undefined =
|
|
43
44
|
await ctx.model.default()
|
|
44
|
-
const
|
|
45
|
-
models.find(
|
|
46
|
-
(m) =>
|
|
47
|
-
(selected?.modelID && m.id === selected.modelID) ||
|
|
48
|
-
(selected?.providerID && m.providerID === selected.providerID),
|
|
49
|
-
) ||
|
|
50
|
-
models.find((m) => m.limit?.context) ||
|
|
51
|
-
undefined
|
|
52
|
-
const limit = match?.limit?.context
|
|
45
|
+
const limit = selected?.data?.limit?.context
|
|
53
46
|
return typeof limit === "number" && limit > 0 ? limit : DEFAULT_MODEL_LIMIT
|
|
54
47
|
} catch {
|
|
55
48
|
return DEFAULT_MODEL_LIMIT
|
|
@@ -300,6 +293,31 @@ export default Plugin.define({
|
|
|
300
293
|
const state = getState(sessionId, config)
|
|
301
294
|
|
|
302
295
|
try {
|
|
296
|
+
// Pull the real, server-measured context usage for this session.
|
|
297
|
+
let measured: import("./lib/tui").MeasuredContext | undefined
|
|
298
|
+
try {
|
|
299
|
+
const info = await ctx.session.get({ sessionID: sessionId })
|
|
300
|
+
const tokens = info.tokens as any
|
|
301
|
+
const tokenCount =
|
|
302
|
+
(tokens?.input ?? 0) +
|
|
303
|
+
(tokens?.output ?? 0) +
|
|
304
|
+
(tokens?.reasoning ?? 0) +
|
|
305
|
+
(tokens?.cache?.read ?? 0) +
|
|
306
|
+
(tokens?.cache?.write ?? 0)
|
|
307
|
+
measured = {
|
|
308
|
+
tokens: tokenCount,
|
|
309
|
+
cost: typeof info.cost === "number" ? info.cost : 0,
|
|
310
|
+
contextLimit:
|
|
311
|
+
state.modelContextLimit || (await resolveModelContextLimit(ctx)),
|
|
312
|
+
model: (info.model && (info.model as any).id) || "unknown",
|
|
313
|
+
}
|
|
314
|
+
// Keep state's headline figure aligned with reality.
|
|
315
|
+
state.modelContextLimit = measured.contextLimit
|
|
316
|
+
state.currentTokenCount = measured.tokens
|
|
317
|
+
} catch {
|
|
318
|
+
// Fall through to estimation if session.get fails.
|
|
319
|
+
}
|
|
320
|
+
|
|
303
321
|
const messages = await ctx.session.context({ sessionID: sessionId })
|
|
304
322
|
|
|
305
323
|
if (!messages || messages.length === 0) {
|
|
@@ -315,9 +333,12 @@ export default Plugin.define({
|
|
|
315
333
|
messageWithParts,
|
|
316
334
|
state,
|
|
317
335
|
config,
|
|
336
|
+
measured?.model,
|
|
337
|
+
measured,
|
|
318
338
|
)
|
|
319
339
|
|
|
320
340
|
const panel = renderPanel(panelData)
|
|
341
|
+
saveSessionState(state, config.persistence.directory)
|
|
321
342
|
return { content: panel }
|
|
322
343
|
} catch (error) {
|
|
323
344
|
return {
|
|
@@ -367,19 +388,24 @@ export default Plugin.define({
|
|
|
367
388
|
}
|
|
368
389
|
}
|
|
369
390
|
|
|
370
|
-
// Quick token estimate (sync, ~4 chars per token)
|
|
371
|
-
|
|
391
|
+
// Quick token estimate (sync, ~4 chars per token). On the first pass
|
|
392
|
+
// (before the panel has written real numbers into state) this is a
|
|
393
|
+
// fallback; afterwards we prefer the server-measured value.
|
|
394
|
+
let estimatedTokens = 0
|
|
372
395
|
for (const msg of event.messages) {
|
|
373
396
|
const content = (msg as any).content
|
|
374
397
|
if (Array.isArray(content)) {
|
|
375
398
|
for (const part of content) {
|
|
376
399
|
if (part.type === "text" && part.text) {
|
|
377
|
-
|
|
400
|
+
estimatedTokens += Math.ceil(part.text.length / 4)
|
|
378
401
|
}
|
|
379
402
|
}
|
|
380
403
|
}
|
|
381
404
|
}
|
|
382
405
|
|
|
406
|
+
// Prefer the real measured token count when available; else the estimate.
|
|
407
|
+
const totalTokens =
|
|
408
|
+
state.currentTokenCount > 0 ? state.currentTokenCount : estimatedTokens
|
|
383
409
|
state.currentTokenCount = totalTokens
|
|
384
410
|
|
|
385
411
|
const maxTokens = resolveTokenLimit(
|
package/src/lib/tui.ts
CHANGED
|
@@ -50,17 +50,30 @@ export interface PanelData {
|
|
|
50
50
|
|
|
51
51
|
// ─── Panel Builder ─────────────────────────────────────────────────────────
|
|
52
52
|
|
|
53
|
+
export interface MeasuredContext {
|
|
54
|
+
/** Real context input tokens for this session (Session.Info.tokens.input). */
|
|
55
|
+
tokens: number
|
|
56
|
+
/** Real total spend for this session (Session.Info.cost). */
|
|
57
|
+
cost: number
|
|
58
|
+
/** Real model context window (Model.Info.limit.context). */
|
|
59
|
+
contextLimit: number
|
|
60
|
+
/** Real model id. */
|
|
61
|
+
model: string
|
|
62
|
+
}
|
|
63
|
+
|
|
53
64
|
export async function buildPanelData(
|
|
54
65
|
sessionId: string,
|
|
55
66
|
messages: MessageWithParts[],
|
|
56
67
|
state: SessionState,
|
|
57
68
|
config: SlimConfig,
|
|
58
69
|
modelId?: string,
|
|
70
|
+
measured?: MeasuredContext,
|
|
59
71
|
): Promise<PanelData> {
|
|
60
|
-
|
|
72
|
+
// Prefer the real model context window from the server; fall back to state/default.
|
|
73
|
+
const modelContextLimit = measured?.contextLimit || state.modelContextLimit || 200000
|
|
61
74
|
const maxTokens = resolveTokenLimit(config.compress.maxContextLimit, modelContextLimit)
|
|
62
75
|
|
|
63
|
-
// Count tokens
|
|
76
|
+
// Count tokens (estimation for role breakdown; real total used for usage %).
|
|
64
77
|
let currentTokens = 0
|
|
65
78
|
const tokensByRole = { user: 0, assistant: 0, tools: 0, system: 0 }
|
|
66
79
|
let userMessages = 0
|
|
@@ -102,8 +115,15 @@ export async function buildPanelData(
|
|
|
102
115
|
tokensByRole.tools = Math.max(tokensByRole.tools, 0)
|
|
103
116
|
tokensByRole.system = Math.max(0, currentTokens - tokensByRole.user - tokensByRole.assistant - tokensByRole.tools)
|
|
104
117
|
|
|
105
|
-
//
|
|
106
|
-
|
|
118
|
+
// Prefer the server-measured real token count for the headline usage figure.
|
|
119
|
+
// Role buckets remain our estimate for breakdown detail.
|
|
120
|
+
const effectiveTokens = measured?.tokens ?? currentTokens
|
|
121
|
+
|
|
122
|
+
// Usage % shown to the user is relative to the real model context window
|
|
123
|
+
// (e.g. 220k / 1M = 22%), matching what OpenCode's own UI displays. The
|
|
124
|
+
// configured maxTokens (a percentage of that same window) drives nudge/compress.
|
|
125
|
+
const usageBase = measured?.contextLimit ? modelContextLimit : modelContextLimit
|
|
126
|
+
const usagePercent = (effectiveTokens / modelContextLimit) * 100
|
|
107
127
|
let status: "healthy" | "warning" | "critical" = "healthy"
|
|
108
128
|
if (usagePercent > 90) status = "critical"
|
|
109
129
|
else if (usagePercent > 70) status = "warning"
|
|
@@ -121,9 +141,9 @@ export async function buildPanelData(
|
|
|
121
141
|
? state.compressionHistory[state.compressionHistory.length - 1]
|
|
122
142
|
: null
|
|
123
143
|
|
|
124
|
-
// Cost estimate
|
|
144
|
+
// Cost: prefer the server-measured real spend; else estimate from tokens.
|
|
125
145
|
const profile = COST_PROFILES[modelId || "default"] || COST_PROFILES.default
|
|
126
|
-
const estimatedCost = (currentTokens / 1000) * profile.inputPricePer1k
|
|
146
|
+
const estimatedCost = measured?.cost ?? (currentTokens / 1000) * profile.inputPricePer1k
|
|
127
147
|
const costSaved = (totalTokensSaved / 1000) * profile.inputPricePer1k
|
|
128
148
|
|
|
129
149
|
// Topic distribution
|
|
@@ -155,7 +175,7 @@ export async function buildPanelData(
|
|
|
155
175
|
return {
|
|
156
176
|
sessionId,
|
|
157
177
|
timestamp: Date.now(),
|
|
158
|
-
currentTokens,
|
|
178
|
+
currentTokens: effectiveTokens,
|
|
159
179
|
maxTokens,
|
|
160
180
|
usagePercent,
|
|
161
181
|
status,
|
|
@@ -171,7 +191,7 @@ export async function buildPanelData(
|
|
|
171
191
|
lastCompression,
|
|
172
192
|
estimatedCost,
|
|
173
193
|
costSaved,
|
|
174
|
-
model: modelId || "unknown",
|
|
194
|
+
model: measured?.model || modelId || "unknown",
|
|
175
195
|
topics,
|
|
176
196
|
recommendations,
|
|
177
197
|
}
|
package/src/tui.tsx
CHANGED
|
@@ -31,7 +31,7 @@ function emptyStats(): PanelStats {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// Derives context-usage stats from the session transcript.
|
|
34
|
-
function deriveStats(messages: readonly unknown[]): PanelStats {
|
|
34
|
+
export function deriveStats(messages: readonly unknown[]): PanelStats {
|
|
35
35
|
const stats = emptyStats()
|
|
36
36
|
for (const raw of messages) {
|
|
37
37
|
const m = raw as {
|
|
@@ -55,6 +55,12 @@ function deriveStats(messages: readonly unknown[]): PanelStats {
|
|
|
55
55
|
text = m.summary || ""
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// User/system messages carry their text on a top-level `text` field
|
|
59
|
+
// (not inside a `content` array). Capture it too so their tokens count.
|
|
60
|
+
if (role !== "assistant" && typeof (m as any).text === "string") {
|
|
61
|
+
text += (m as any).text
|
|
62
|
+
}
|
|
63
|
+
|
|
58
64
|
if (Array.isArray(m.content)) {
|
|
59
65
|
for (const part of m.content) {
|
|
60
66
|
if (part?.type === "text" && typeof part.text === "string") {
|