@serkanalgur/opencodev2-slim 2.0.11 → 2.0.13

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,20 @@ Note: Compression is performed by the AI assistant using the `compress` tool. Th
141
141
 
142
142
  ## Changelog
143
143
 
144
+ ### 2.0.13
145
+
146
+ - Fix `/panel` in the TUI not reflecting real context usage:
147
+ - The TUI command now reads live server measurements (`Session.Info.tokens` + `cost` + `model` + context window) via `context.client.session.get()` and prints them (measured tokens, %, cost, model) at the bottom of the panel, matching what the `panel` tool reports.
148
+ - Resolve the active session from `context.ui.router.current()` instead of a non-existent `context.router`, so the panel targets the focused session rather than always the first one.
149
+ - Call `context.data.session.message.sync()` before reading the transcript so stats aren't computed from an empty/stale cache.
150
+
151
+ ### 2.0.12
152
+
153
+ - Bind panel/nudge to real OpenCode context measurements:
154
+ - Resolve the active model's real context window from `ctx.model.default().data.limit.context` instead of the hard-coded 200k.
155
+ - `/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.
156
+ - The nudge decision prefers the measured token count over the rough 4-char estimation.
157
+
144
158
  ### 2.0.11
145
159
 
146
160
  - Fix CI publish: add `solid-js`, `@opentui/core`, `@opentui/solid` to `devDependencies`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serkanalgur/opencodev2-slim",
3
- "version": "2.0.11",
3
+ "version": "2.0.13",
4
4
  "description": "Smart context management plugin for OpenCode v2 - semantic compression, cost-aware pruning, adaptive thresholds",
5
5
  "keywords": [
6
6
  "opencode",
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
@@ -94,7 +94,14 @@ export function deriveStats(messages: readonly unknown[]): PanelStats {
94
94
  }
95
95
 
96
96
  // Builds a human-readable panel as plain text (injected into the message stream).
97
- function renderPanelText(sessionID: string, stats: PanelStats): string {
97
+ function renderPanelText(
98
+ sessionID: string,
99
+ stats: PanelStats,
100
+ real?: MeasuredReal | null,
101
+ ): string {
102
+ const limit = real?.contextLimit ?? 0
103
+ const pct = real?.usagePercent ?? 0
104
+ const status = real ? (pct >= 90 ? "critical" : pct >= 70 ? "warning" : "healthy") : "n/a"
98
105
  const lines: string[] = []
99
106
  lines.push("┌─────────────────────────────────────────────────────────────┐")
100
107
  lines.push("│ SLIM CONTEXT PANEL │")
@@ -107,22 +114,67 @@ function renderPanelText(sessionID: string, stats: PanelStats): string {
107
114
  lines.push(`│ Tool calls: ${stats.toolCalls} Compactions: ${stats.compactionCount}`)
108
115
  lines.push(`│ Tokens (est): User ${stats.tokensByRole.user} | Assistant ${stats.tokensByRole.assistant} | System ${stats.tokensByRole.system}`)
109
116
  lines.push(`│ Total token estimate: ${stats.totalTokens}`)
117
+ if (real) {
118
+ lines.push("├─────────────────────────────────────────────────────────────┤")
119
+ lines.push(`│ Measured tokens: ${real.tokens} (${pct}% of ${limit}) [${status}]`)
120
+ if (real.cost > 0) lines.push(`│ Cost: $${real.cost.toFixed(6)}`)
121
+ lines.push(`│ Model: ${real.model}`)
122
+ }
110
123
  lines.push("└─────────────────────────────────────────────────────────────┘")
111
124
  return lines.join("\n")
112
125
  }
113
126
 
114
- // Resolves the "current" session: the focused session if any, else the most recent.
127
+ // Resolves the "current" session: the router-focused session if any, else the most recent.
115
128
  function resolveCurrentSession(context: any): string | null {
116
129
  const sessions = context.data.session.list() || []
117
130
  if (sessions.length === 0) return null
118
- // Prefer the focused session if exposed; otherwise fall back to the first.
119
- const focused = context.router?.current?.()
120
- if (focused && typeof focused === "object" && "sessionID" in focused) {
121
- return focused.sessionID as string
131
+ // The TUI host exposes the active route via context.ui.router (not context.router).
132
+ const route = context.ui?.router?.current?.()
133
+ if (route && typeof route === "object" && "sessionID" in route) {
134
+ return route.sessionID as string
122
135
  }
123
136
  return sessions[0].id
124
137
  }
125
138
 
139
+ // Server-measured context numbers for a session (Session.Info.tokens + cost + model),
140
+ // mirroring what the `panel` tool in index.ts reads via ctx.session.get().
141
+ interface MeasuredReal {
142
+ tokens: number
143
+ cost: number
144
+ contextLimit: number
145
+ model: string
146
+ usagePercent: number
147
+ }
148
+
149
+ async function measureSession(context: any, sessionID: string): Promise<MeasuredReal | null> {
150
+ try {
151
+ const info: any = await context.client.session.get({ sessionID })
152
+ if (!info) return null
153
+ const tokens: any = info.tokens ?? {}
154
+ const tokenCount =
155
+ (typeof tokens.input === "number" ? tokens.input : 0) +
156
+ (typeof tokens.output === "number" ? tokens.output : 0) +
157
+ (typeof tokens.reasoning === "number" ? tokens.reasoning : 0) +
158
+ (typeof tokens.cache?.read === "number" ? tokens.cache.read : 0) +
159
+ (typeof tokens.cache?.write === "number" ? tokens.cache.write : 0)
160
+ const contextLimit: number =
161
+ typeof info.model?.limit?.context === "number" && info.model.limit.context > 0
162
+ ? info.model.limit.context
163
+ : 200000
164
+ const usagePercent =
165
+ contextLimit > 0 ? Math.min(100, Math.round((tokenCount / contextLimit) * 100)) : 0
166
+ return {
167
+ tokens: tokenCount,
168
+ cost: typeof info.cost === "number" ? info.cost : 0,
169
+ contextLimit,
170
+ model: info.model?.id || "unknown",
171
+ usagePercent,
172
+ }
173
+ } catch {
174
+ return null
175
+ }
176
+ }
177
+
126
178
  export default Plugin.define({
127
179
  id: "opencodev2-slim.cli",
128
180
  setup(context) {
@@ -160,10 +212,14 @@ export default Plugin.define({
160
212
  }
161
213
 
162
214
  try {
215
+ // Make sure the cached transcript is loaded before reading it.
216
+ await context.data.session.message.sync(sessionID)
163
217
  const messages =
164
218
  context.data.session.message.list(sessionID) || []
219
+ // Prefer live server-measured context numbers when available.
220
+ const real = await measureSession(context, sessionID)
165
221
  const stats = deriveStats(messages)
166
- const text = renderPanelText(sessionID, stats)
222
+ const text = renderPanelText(sessionID, stats, real)
167
223
  // Inject the panel as plain text into the session stream,
168
224
  // so it doesn't take over OpenCode's own panel UI.
169
225
  await context.client.session.synthetic({