@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 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.9",
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 models: any[] = await ctx.model.list()
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 match =
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
- let totalTokens = 0
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
- totalTokens += Math.ceil(part.text.length / 4)
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
- const modelContextLimit = state.modelContextLimit || 200000
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
- // Calculate status
106
- const usagePercent = (currentTokens / maxTokens) * 100
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") {