opencode-context-indicator 1.4.0 → 1.5.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/lib/tui-panel.tsx CHANGED
@@ -11,7 +11,7 @@ import { mkdirSync, writeFileSync } from "node:fs"
11
11
  import { tmpdir } from "node:os"
12
12
  import { basename, join } from "node:path"
13
13
  import type { StateEntry } from "./tui-data.js"
14
- import { collectFamily, fit, fmt, num, updatedAt } from "./tui-data.js"
14
+ import { collectFamily, fit, fmt, num, toolOutEntries, updatedAt } from "./tui-data.js"
15
15
  import { token } from "./tui-theme.js"
16
16
  import {
17
17
  buildRedactedPayload,
@@ -475,11 +475,13 @@ export function BreakdownPanel(props: {
475
475
  const model = safeText(r.model && r.model !== "unknown" ? r.model : "model?")
476
476
  const ctx = fmt(num(r.ctx))
477
477
  const d = panelDenom(r)
478
- return d > 0
479
- ? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
480
- (num(r.ctx) / d) * 100,
481
- )}%)`
482
- : `Context breakdown \u2014 ${model} ${ctx}`
478
+ const base =
479
+ d > 0
480
+ ? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
481
+ (num(r.ctx) / d) * 100,
482
+ )}%)`
483
+ : `Context breakdown \u2014 ${model} ${ctx}`
484
+ return r.compactStale === true ? `${base} (stale — compacted)` : base
483
485
  }
484
486
 
485
487
  const layout = (): {
@@ -523,10 +525,27 @@ export function BreakdownPanel(props: {
523
525
  L.gutter +
524
526
  fit(money(costOf(e.sessionID)), L.costW) +
525
527
  L.gutter +
526
- updatedAt(e.updatedAt)
528
+ updatedAt(e.updatedAt) +
529
+ (e.compactStale === true ? " (stale)" : "")
527
530
  )
528
531
  }
529
532
 
533
+ // One compact line: the main session's tool results split by tool name
534
+ // (server-side aggregate; the per-call "largest contributors" block above is
535
+ // the measured, client-side view).
536
+ const byToolLine = (): string => {
537
+ const all = data().root?.toolOutByTool
538
+ const entries = toolOutEntries(all, 5)
539
+ if (entries.length === 0) return ""
540
+ const total =
541
+ all && typeof all === "object"
542
+ ? Object.values(all).filter((v) => num(v) > 0).length
543
+ : 0
544
+ const parts = entries.map(([name, val]) => `${safeText(name)} ${fmt(val)}`)
545
+ if (total > entries.length) parts.push(`+${total - entries.length} more`)
546
+ return parts.join(" \u00b7 ")
547
+ }
548
+
530
549
  return (
531
550
  <box flexDirection="column">
532
551
  <text fg={base()}>{header()}</text>
@@ -551,6 +570,29 @@ export function BreakdownPanel(props: {
551
570
  : "")}
552
571
  </text>
553
572
  </Show>
573
+ <Show
574
+ when={
575
+ num(data().root?.cacheRead) > 0 ||
576
+ num(data().root?.cacheWrite) > 0
577
+ }
578
+ >
579
+ <text fg={muted()}>
580
+ {" prompt cache r " +
581
+ fmt(num(data().root?.cacheRead)) +
582
+ " / w " +
583
+ fmt(num(data().root?.cacheWrite)) +
584
+ " (provider-reported)"}
585
+ </text>
586
+ </Show>
587
+ <Show when={num(data().root?.reclaim?.tokens) > 0}>
588
+ <text fg={muted()}>
589
+ {" reclaim ~" +
590
+ fmt(num(data().root?.reclaim?.tokens)) +
591
+ " redundant tool output (" +
592
+ num(data().root?.reclaim?.count) +
593
+ " dup)"}
594
+ </text>
595
+ </Show>
554
596
  <Show when={messages().available}>
555
597
  <text fg={base()}>{"largest contributors (measured)"}</text>
556
598
  <Show
@@ -569,6 +611,10 @@ export function BreakdownPanel(props: {
569
611
  }
570
612
  </text>
571
613
  </Show>
614
+ <Show when={byToolLine().length > 0}>
615
+ <text fg={base()}>{"tool results by tool"}</text>
616
+ <text fg={muted()}>{byToolLine()}</text>
617
+ </Show>
572
618
  </Show>
573
619
  </box>
574
620
  )
@@ -66,6 +66,9 @@ export function Sidebar(props: { entry: () => StateEntry | null }) {
66
66
  : line("tool schemas", e()?.categories?.toolSchemas)}
67
67
  </text>
68
68
  <text fg={muted()}>{line("other", e()?.categories?.other)}</text>
69
+ <Show when={e()?.compactStale === true}>
70
+ <text fg={muted()}>{" stale — compacted"}</text>
71
+ </Show>
69
72
  <text fg={muted()}>{`updated ${updatedAt(e()?.updatedAt)}`}</text>
70
73
  </Show>
71
74
  </box>
package/lib/v2.js CHANGED
@@ -7,11 +7,18 @@
7
7
  // from index.js.
8
8
 
9
9
  import {
10
+ breakdownCategories,
11
+ computeReclaim,
12
+ contentFingerprint,
10
13
  estJson,
11
14
  estimateTokens,
15
+ extractToolTarget,
12
16
  fmt,
17
+ isSafeSessionKey,
13
18
  oneLine,
19
+ safeSaneOrNull,
14
20
  tokenCountOf,
21
+ TOOL_OUT_MAX_KEYS,
15
22
  withTimeout,
16
23
  } from "./estimate.js";
17
24
  import {
@@ -22,6 +29,10 @@ import {
22
29
  lastBdWrite,
23
30
  lastKnownCtx,
24
31
  lastKnownCtxCache,
32
+ lastKnownCacheRead,
33
+ lastKnownCacheReadCache,
34
+ lastKnownCacheWrite,
35
+ lastKnownCacheWriteCache,
25
36
  lastKnownInput,
26
37
  lastKnownInputCache,
27
38
  lastKnownModel,
@@ -75,10 +86,11 @@ const V2_FALLBACK_TIMEOUT_MS = 3000;
75
86
  // every schema does not run on every single model request.
76
87
  // Cheap structural fingerprint of the tool schema map: count + total description
77
88
  // length + number of schema property keys + two O(n) charCode checksums (mod
78
- // 0xFFFFFF) over every description and over the top-level input keys. The
79
- // checksums widen the fingerprint so two different schemas that happen to share
80
- // the same length/key counts do not collide into a wrong cache hit — without
81
- // paying for full JSON.stringify (the whole point of the cache).
89
+ // 0xFFFFFF) over every description and over the PARAMETER names
90
+ // (input.properties keys). The checksums widen the fingerprint so two different
91
+ // schemas that happen to share the same length/key counts do not collide into a
92
+ // wrong cache hit — without paying for full JSON.stringify (the whole point of
93
+ // the cache).
82
94
  function toolsFingerprint(tools) {
83
95
  if (!tools || typeof tools !== "object") return "none";
84
96
  let n = 0;
@@ -98,14 +110,16 @@ function toolsFingerprint(tools) {
98
110
  const input = t?.input;
99
111
  if (input && typeof input === "object") {
100
112
  const props = input.properties;
101
- propKeys +=
113
+ // Hash the PARAMETER names (input.properties keys) when present — a
114
+ // property rename that keeps the same count/description must change the
115
+ // fingerprint, or the TTL cache would serve a stale schema estimate.
116
+ // Fall back to the top-level schema keys for schemas without `properties`.
117
+ const nameSource =
102
118
  props && typeof props === "object"
103
- ? Object.keys(props).length
104
- : Object.keys(input).length;
105
- // Sum the top-level key names so property RENAMES that keep the same count
106
- // still change the fingerprint.
107
- const topKeys = Object.keys(input);
108
- for (const k of topKeys) {
119
+ ? Object.keys(props)
120
+ : Object.keys(input);
121
+ propKeys += nameSource.length;
122
+ for (const k of nameSource) {
109
123
  for (let i = 0; i < k.length; i++) {
110
124
  keyHash = (keyHash + k.charCodeAt(i)) & 0xffffff;
111
125
  }
@@ -138,14 +152,69 @@ function toolResultChars(p) {
138
152
  return estJson(r.value); // json | error | unexpected shapes
139
153
  }
140
154
 
155
+ // Concatenated text of a ToolResultPart payload, for content FINGERPRINTING
156
+ // (bounded — the first ~4KB is enough to spot byte-identical outputs). Mirrors
157
+ // the shapes handled by toolResultChars; file items are URI references, skipped.
158
+ function toolResultText(p) {
159
+ const r = p?.result;
160
+ if (!r || typeof r !== "object") return "";
161
+ if (r.type === "content" && Array.isArray(r.value)) {
162
+ let out = "";
163
+ for (const item of r.value) {
164
+ if (item?.type === "text" && typeof item.text === "string") {
165
+ out += item.text;
166
+ if (out.length > 4096) break;
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+ if (r.type === "text" && typeof r.value === "string") return r.value;
172
+ try {
173
+ return JSON.stringify(r.value);
174
+ } catch {
175
+ return "";
176
+ }
177
+ }
178
+
179
+ // Bound on tool-call instances tracked per request for waste detection (the
180
+ // message history is untrusted and a runaway session could be huge).
181
+ const MAX_WASTE_INSTANCES = 256;
182
+
183
+ // Accumulate the per-tool tool-result estimate. Tool names are untrusted
184
+ // (host tool ids, plugin-defined tools): unsafe names and any name beyond the
185
+ // cap fold into "other tools" so the bucket never becomes a prototype vector
186
+ // and the map stays bounded. The sum of the map mirrors the toolOut category.
187
+ // The cap is the SHARED TOOL_OUT_MAX_KEYS (lib/estimate.js) so the accumulation
188
+ // bound and the persisted/displayed bound can never drift apart.
189
+ function addToolOut(map, name, chars) {
190
+ if (!(chars > 0)) return;
191
+ const safe =
192
+ isSafeSessionKey(name) && name.length <= 64 ? name : "other tools";
193
+ if (
194
+ safe !== "other tools" &&
195
+ !Object.prototype.hasOwnProperty.call(map, safe) &&
196
+ Object.keys(map).length >= TOOL_OUT_MAX_KEYS
197
+ ) {
198
+ map["other tools"] = (map["other tools"] || 0) + chars;
199
+ return;
200
+ }
201
+ map[safe] = (map[safe] || 0) + chars;
202
+ }
203
+
141
204
  function estimateBreakdownFromContext(context, sessionID) {
142
205
  let userChars = 0;
143
206
  let assistantChars = 0;
144
207
  let reasoningChars = 0;
145
208
  let toolArgsChars = 0;
146
209
  let toolOutChars = 0;
210
+ const toolOutByTool = {};
147
211
 
148
212
  const messages = Array.isArray(context?.messages) ? context.messages : [];
213
+ // Waste detection: correlate tool-call (input -> target) with its tool-result
214
+ // (-> tokens + content fingerprint) by call id, over the calls still present
215
+ // in the assembled context.
216
+ const callById = new Map();
217
+ const instances = [];
149
218
  for (const msg of messages) {
150
219
  const parts = Array.isArray(msg?.content) ? msg.content : [];
151
220
  for (const p of parts) {
@@ -153,7 +222,19 @@ function estimateBreakdownFromContext(context, sessionID) {
153
222
  // carrying them varies by host version (user, assistant, or the dedicated
154
223
  // "tool" role) — count them role-independently.
155
224
  if (p?.type === "tool-result") {
156
- toolOutChars += toolResultChars(p);
225
+ const chars = toolResultChars(p);
226
+ toolOutChars += chars;
227
+ addToolOut(toolOutByTool, p?.name, chars);
228
+ if (chars > 0 && instances.length < MAX_WASTE_INSTANCES) {
229
+ const call = p?.id != null ? callById.get(p.id) : null;
230
+ const name = p?.name || call?.name || null;
231
+ instances.push({
232
+ name,
233
+ target: extractToolTarget(name, call?.input),
234
+ tokens: chars,
235
+ fp: contentFingerprint(toolResultText(p)),
236
+ });
237
+ }
157
238
  continue;
158
239
  }
159
240
  if (msg?.role === "user") {
@@ -165,10 +246,15 @@ function estimateBreakdownFromContext(context, sessionID) {
165
246
  reasoningChars += estimateTokens(p.text);
166
247
  } else if (p?.type === "tool-call") {
167
248
  toolArgsChars += estJson(p.input);
249
+ if (p?.id != null && callById.size < MAX_WASTE_INSTANCES && !callById.has(p.id)) {
250
+ callById.set(p.id, { name: p.name, input: p.input });
251
+ }
168
252
  }
169
253
  }
170
254
  }
171
255
  }
256
+ const reclaimRaw = instances.length > 0 ? computeReclaim(instances) : null;
257
+ const reclaim = reclaimRaw && reclaimRaw.tokens > 0 ? reclaimRaw : null;
172
258
 
173
259
  let systemChars = 0;
174
260
  const system = Array.isArray(context?.system) ? context.system : [];
@@ -206,8 +292,10 @@ function estimateBreakdownFromContext(context, sessionID) {
206
292
  reasoningChars,
207
293
  toolArgsChars,
208
294
  toolOutChars,
295
+ toolOutByTool,
209
296
  systemChars,
210
297
  toolSchemasChars,
298
+ reclaim,
211
299
  };
212
300
  }
213
301
 
@@ -259,6 +347,8 @@ function estimateBreakdownFromClientMessages(messages) {
259
347
  let reasoningChars = 0;
260
348
  let toolArgsChars = 0;
261
349
  let toolOutChars = 0;
350
+ const toolOutByTool = {};
351
+ const instances = [];
262
352
  for (const m of messages || []) {
263
353
  if (!m || typeof m !== "object") continue;
264
354
  if (m.type === "user") {
@@ -282,24 +372,43 @@ function estimateBreakdownFromClientMessages(messages) {
282
372
  // policy as the hook path).
283
373
  const content = p?.state?.content;
284
374
  if (Array.isArray(content)) {
375
+ let outChars = 0;
376
+ let text = "";
285
377
  for (const item of content) {
286
378
  if (item?.type === "text" && typeof item.text === "string") {
287
- toolOutChars += estimateTokens(item.text);
379
+ outChars += estimateTokens(item.text);
380
+ if (text.length <= 4096) text += item.text;
288
381
  }
289
382
  }
383
+ toolOutChars += outChars;
384
+ // The persisted tool part carries its name on `p.name`
385
+ // (SessionMessageAssistantTool.name) — same field as the hook path.
386
+ addToolOut(toolOutByTool, p?.name, outChars);
387
+ if (outChars > 0 && instances.length < MAX_WASTE_INSTANCES) {
388
+ instances.push({
389
+ name: p?.name || null,
390
+ target: extractToolTarget(p?.name, input),
391
+ tokens: outChars,
392
+ fp: contentFingerprint(text),
393
+ });
394
+ }
290
395
  }
291
396
  }
292
397
  }
293
398
  }
294
399
  }
400
+ const reclaimRaw = instances.length > 0 ? computeReclaim(instances) : null;
401
+ const reclaim = reclaimRaw && reclaimRaw.tokens > 0 ? reclaimRaw : null;
295
402
  return {
296
403
  userChars,
297
404
  assistantChars,
298
405
  reasoningChars,
299
406
  toolArgsChars,
300
407
  toolOutChars,
408
+ toolOutByTool,
301
409
  systemChars: null,
302
410
  toolSchemasChars: null,
411
+ reclaim,
303
412
  };
304
413
  }
305
414
 
@@ -423,14 +532,9 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
423
532
  const source = bd?.source || "none";
424
533
  const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
425
534
 
426
- const sumEstimates = (bd?.userChars || 0) +
427
- (bd?.assistantChars || 0) +
428
- (bd?.reasoningChars || 0) +
429
- (bd?.toolArgsChars || 0) +
430
- (bd?.toolOutChars || 0) +
431
- (bd?.systemChars || 0) +
432
- (bd?.toolSchemasChars || 0);
433
- const other = Math.max(0, inputTokens - sumEstimates);
535
+ // Residual = input minus every measured category (shared normaliser; `bd`
536
+ // may be absent -> everything 0 and the residual is the whole input).
537
+ const other = breakdownCategories(bd, inputTokens).other;
434
538
 
435
539
  if (bd) {
436
540
  lines.push(` user ${fmt(bd.userChars || 0)} (est tokens)`);
@@ -463,6 +567,25 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
463
567
  lines.push(` other ${fmt(other)} (= input - sum of estimates)`);
464
568
  lines.push(` source ${source}`);
465
569
 
570
+ // Provider-reported prompt cache (EXACT, not an estimate; a subset of input —
571
+ // never folded into the category sum). Absent when the provider stays silent.
572
+ const cr = lastKnownCacheRead(sessionID);
573
+ const cw = lastKnownCacheWrite(sessionID);
574
+ lines.push(
575
+ cr == null && cw == null
576
+ ? ` prompt cache n/a (provider reported no cache tokens)`
577
+ : ` prompt cache read=${fmt(cr || 0)} write=${fmt(cw || 0)} (provider-reported; subset of input)`,
578
+ );
579
+
580
+ // Redundant tool output already in context (repeated target / identical
581
+ // output). An estimate of what trimming/compaction could reclaim.
582
+ const rcl = bd?.reclaim || null;
583
+ if (rcl && rcl.tokens > 0) {
584
+ lines.push(
585
+ ` reclaim ~${fmt(rcl.tokens)} redundant tool output (${rcl.count} duplicate(s) — estimate)`,
586
+ );
587
+ }
588
+
466
589
  try {
467
590
  const sub = aggregateSubagentsV2(sessionID);
468
591
  if (sub.count > 0) {
@@ -569,6 +692,15 @@ async function handleV2Event(ctx, event) {
569
692
  lastKnownInput(sid),
570
693
  lastKnownCtx(sid),
571
694
  lastKnownReasoning(sid),
695
+ // This write re-persists the PRE-compaction snapshot (the compaction
696
+ // flow fires no context hook — see README): mark the entry stale so the
697
+ // UI shows the numbers as pre-compaction until the next model request
698
+ // refreshes them.
699
+ {
700
+ compacted: true,
701
+ cacheRead: lastKnownCacheRead(sid),
702
+ cacheWrite: lastKnownCacheWrite(sid),
703
+ },
572
704
  );
573
705
  pushFinalSummary(
574
706
  `compacted:${event.id ?? sid}`,
@@ -608,6 +740,10 @@ async function handleV2Event(ctx, event) {
608
740
  lastKnownInput(sid),
609
741
  lastKnownCtx(sid),
610
742
  lastKnownReasoning(sid),
743
+ {
744
+ cacheRead: lastKnownCacheRead(sid),
745
+ cacheWrite: lastKnownCacheWrite(sid),
746
+ },
611
747
  );
612
748
  pushFinalSummary(`final:${event.id ?? sid}`, [
613
749
  `[${new Date().toISOString()}] FINAL session=${sid}`,
@@ -630,10 +766,16 @@ async function handleV2Event(ctx, event) {
630
766
  const output = typeof t.output === "number" ? t.output : 0;
631
767
  const reasoning = typeof t.reasoning === "number" ? t.reasoning : 0;
632
768
  const contextTokens = tokenCountOf(t);
769
+ // Provider-reported prompt-cache figures (exact): a subset of `input`.
770
+ // Sanitised — a NaN/negative/absurd provider value must never be persisted.
771
+ const cacheRead = safeSaneOrNull(t.cache?.read ?? t.cacheRead);
772
+ const cacheWrite = safeSaneOrNull(t.cache?.write ?? t.cacheWrite);
633
773
 
634
774
  lastKnownInputCache.set(sid, input);
635
775
  lastKnownCtxCache.set(sid, contextTokens);
636
776
  lastKnownReasoningCache.set(sid, reasoning);
777
+ if (cacheRead != null) lastKnownCacheReadCache.set(sid, cacheRead);
778
+ if (cacheWrite != null) lastKnownCacheWriteCache.set(sid, cacheWrite);
637
779
  if (!lastKnownModelCache.has(sid) && data.model) {
638
780
  // Some builds put the model on the step payload; harmless when absent.
639
781
  if (typeof data.model.id === "string") lastKnownModelCache.set(sid, data.model.id);
@@ -644,6 +786,8 @@ async function handleV2Event(ctx, event) {
644
786
  pruneMap(lastKnownInputCache, MAX_TRACKED_SESSIONS);
645
787
  pruneMap(lastKnownCtxCache, MAX_TRACKED_SESSIONS);
646
788
  pruneMap(lastKnownReasoningCache, MAX_TRACKED_SESSIONS);
789
+ pruneMap(lastKnownCacheReadCache, MAX_TRACKED_SESSIONS);
790
+ pruneMap(lastKnownCacheWriteCache, MAX_TRACKED_SESSIONS);
647
791
  pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
648
792
  pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
649
793
 
@@ -702,6 +846,10 @@ async function handleV2Event(ctx, event) {
702
846
  input,
703
847
  contextTokens,
704
848
  reasoning,
849
+ {
850
+ cacheRead: lastKnownCacheRead(sid),
851
+ cacheWrite: lastKnownCacheWrite(sid),
852
+ },
705
853
  );
706
854
  writeLog(lines);
707
855
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Real-time context-window usage for OpenCode: per-category token breakdown, a zero-LLM /cx panel with per-session cost and a searchable family-session picker, and a TUI sidebar. Dual V1 + V2 plugin entry.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,7 +64,7 @@
64
64
  "@opentui/solid": "0.5.17"
65
65
  },
66
66
  "devDependencies": {
67
- "@opencode/plugin": "2.0.28",
67
+ "@opencode/plugin": "2.0.30",
68
68
  "@opentui/core": "0.5.17",
69
69
  "@types/node": "26.6.3",
70
70
  "esbuild": "^0.28.2",