opencode-context-indicator 1.4.1 → 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/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ Notable user-facing changes per release. Dates are commit days (UTC).
4
4
  The package also ships a dual V1/V2 plugin — see the README for the surface
5
5
  matrix; everything below concerns the V2 path unless stated otherwise.
6
6
 
7
+ ## 1.5.0 — 2026-10-16
8
+
9
+ Highlights: two new context-health signals — the **provider-reported prompt
10
+ cache** (exact) and a **waste detector** for redundant tool output already in
11
+ the context.
12
+
13
+ - **Prompt cache (provider-reported)**: `session.step.ended` carries
14
+ `tokens.cache.{read,write}` — the provider's EXACT prompt-cache figures. Shown
15
+ as `prompt cache read=X (Y% of input) · write=Z` in `/context-breakdown`
16
+ (a `### Prompt cache` section), `/context`, the `./cx` panel and the live log.
17
+ It is a **subset of `input`**, so it is deliberately **not** added to the
18
+ category sum (no double-count). `n/a` when the provider reports nothing;
19
+ persisted as `cacheRead`/`cacheWrite` and inherited by mirror writes (a
20
+ non-measuring write cannot drop them).
21
+ - **Waste detector**: finds tool output already in the context that is redundant
22
+ — the **same target** invoked repeatedly (the same file read twice, the same
23
+ URL fetched again) or **byte-identical output**. Reported as a `### Potential
24
+ reclaim (redundant tool output)` section in `/context-breakdown` (top findings
25
+ with tool + target) plus a `reclaim ~X redundant tool output (N dup)` panel/log
26
+ line. The number is the non-largest member of each duplicate group — an honest
27
+ *"N copies are in context; trimming/compaction could reclaim ~X"*, never "the
28
+ model wasted effort". Findings are **path-free** in the persisted/exported
29
+ form; the estimate is computed on both the hook and the fallback path.
30
+ - Tests: T23/T23a/T23b (cache persistence, surfaces, sanitisation), T24–T24c
31
+ (reclaim algorithm, target extraction, hook capture via call-id correlation,
32
+ path-free persistence + rendering).
33
+ - Legacy `state.json` entries (without the new fields) behave exactly as before.
34
+
7
35
  ## 1.4.1 — 2026-10-14
8
36
 
9
37
  Highlights: the `toolOut` category is now split **by tool name**, and a
package/README.md CHANGED
@@ -40,6 +40,8 @@ For every served request the plugin estimates how the context window is split:
40
40
  tool schemas 1.4k (est tokens)
41
41
  other 5.6k (= input - sum of estimates)
42
42
  source context-hook
43
+ prompt cache read=12.0k (27% of input) write=1.1k (provider-reported)
44
+ reclaim ~9.8k redundant tool output (3 duplicate(s) — estimate)
43
45
  subagents 2 session(s): in=8.1k out=2.3k r=1.0k worst=12%
44
46
  ```
45
47
 
@@ -56,6 +58,18 @@ For every served request the plugin estimates how the context window is split:
56
58
  * `ctx` is OpenCode's native overflow count: `tokens.total` when present, else
57
59
  `input + output + cache.read + cache.write`. Percentages are **not clamped**
58
60
  at 100% — exceeding the window stays visible.
61
+ * `prompt cache` is **provider-reported and exact** (`tokens.cache.read` /
62
+ `tokens.cache.write`), shown as a share of `input`. It is a *subset* of input,
63
+ so it is deliberately **not** added to the category sum: a cache read means the
64
+ provider served those prompt tokens from its prompt cache, not that they are
65
+ absent from the window. `n/a` when the provider reports nothing.
66
+ * `reclaim` is the **waste detector**: tool output already in the context that is
67
+ redundant — the **same target** invoked repeatedly (the same file read twice,
68
+ the same URL fetched again) or **byte-identical output**. It is an estimate of
69
+ what trimming/compaction could reclaim (the non-largest member of each
70
+ duplicate group), reported honestly as *"N redundant tool outputs ≈ X tokens"*
71
+ — never as "the model wasted effort". Findings are per-session and **path-free
72
+ in the persisted/exported form**.
59
73
  * The denominator is the **opencode v2.0.23 compaction ceiling**: `window =
60
74
  limit.input || limit.context`; `reserve = max(floor(window * 0.1), window >=
61
75
  32_000 ? 16_000 : 0)`; the ceiling is `window - reserve`. When opencode
@@ -164,6 +178,13 @@ unavailable._
164
178
 
165
179
  ### Tool results by tool (main)
166
180
  webfetch 242.1k · read 18.4k · bash 4.9k · +3 more
181
+
182
+ ### Prompt cache (main, provider-reported)
183
+ read 12.0k (27% of input) · write 1.1k
184
+
185
+ ### Potential reclaim (redundant tool output)
186
+ - read src/a.ts x2 — ~9.8k reclaimable
187
+ _~9.8k across 3 redundant tool output(s) — estimate of what trimming/compaction could reclaim._
167
188
  ```
168
189
 
169
190
  It is delivered with `resume:true` **and** a verbatim instruction prefix, so the
@@ -553,7 +574,7 @@ caller.
553
574
  ### Running tests
554
575
 
555
576
  ```bash
556
- npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T22 unit checks
577
+ npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T24 unit checks
557
578
  npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
558
579
  npm run test:e2e:quick # E2E load-only, no LLM call
559
580
  npm run test:drift # OpenTUI pin vs the latest RELEASED opencode (also in CI)
@@ -574,7 +595,7 @@ STEP 80 s, ~3 min total.
574
595
  | (a) tarball composition | `package.json` `files[]` expands to the base files plus every `lib/*` module (count asserted dynamically) | no test/node_modules/tsconfig in the tarball |
575
596
  | (b) pragma regression | esbuild `--jsx=automatic` → `@opentui/solid/jsx-runtime` present, `react/jsx-runtime` absent | a removed/broken pragma kills the sidebar on npm install |
576
597
  | (c) resolve chain | replica install: host-bridged + peer packages resolve; local tui.tsx relative imports resolve via node from the entry (resolve-bridge.cjs) | broken peer/optional dep or a bad local import silently breaks the sidebar |
577
- | (d) unit harness T1–T22 | limit/agent/state fix assertions plus measureContributors, redacted-export, shared category-helper and sanitizer/unicode checks (51 total) | regressions in modelLimits, writeStateFile, hydrate, contributors, export |
598
+ | (d) unit harness T1–T24 | limit/agent/state fix assertions plus measureContributors, redacted-export, shared category-helper, sanitizer/unicode, prompt-cache and waste-detector checks (58 total) | regressions in modelLimits, writeStateFile, hydrate, contributors, export |
578
599
  | (e) corporate-identifier scan | no forbidden identifiers in the published surface + test tree | compliance |
579
600
  | (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in any repo file | English-only source rule |
580
601
 
package/index.js CHANGED
@@ -151,7 +151,7 @@
151
151
 
152
152
  import { makeV1 } from "./lib/v1.js";
153
153
  import { handleV2Event, onV2Context, estimateBreakdownFromContext, estimateBreakdownFromClientMessages } from "./lib/v2.js";
154
- import { breakdownCategories, estimateTokens, isSafeSessionKey, safeCell, safeToolOutByTool, sumCategories, toolOutEntries } from "./lib/estimate.js";
154
+ import { breakdownCategories, computeReclaim, contentFingerprint, estimateTokens, extractToolTarget, isSafeSessionKey, safeCell, safeReclaim, safeSaneOrNull, safeToolOutByTool, sumCategories, toolOutEntries } from "./lib/estimate.js";
155
155
  import {
156
156
  commandContextBreakdown,
157
157
  commandContextSummary,
@@ -170,7 +170,7 @@ import {
170
170
  modelLimits,
171
171
  reserveFor,
172
172
  } from "./lib/limits.js";
173
- import { sessionAgentCache, sessionParentCache } from "./lib/cache.js";
173
+ import { lastKnownCacheRead, lastKnownCacheWrite, sessionAgentCache, sessionParentCache } from "./lib/cache.js";
174
174
  import {
175
175
  logErr,
176
176
  readStateSnapshot,
@@ -211,6 +211,13 @@ export {
211
211
  safeCell,
212
212
  isSafeSessionKey,
213
213
  estimateTokens,
214
+ extractToolTarget,
215
+ contentFingerprint,
216
+ computeReclaim,
217
+ safeReclaim,
218
+ safeSaneOrNull,
219
+ lastKnownCacheRead,
220
+ lastKnownCacheWrite,
214
221
  };
215
222
 
216
223
  // ---------------------------------------------------------------------------
package/lib/cache.js CHANGED
@@ -53,6 +53,12 @@ const lastKnownReasoningCache = new Map();
53
53
  // sessionID -> last native opencode overflow count (tokens.total, else
54
54
  // input + output + cache.read + cache.write)
55
55
  const lastKnownCtxCache = new Map();
56
+ // sessionID -> last provider-reported prompt-cache figures from
57
+ // session.step.ended (`tokens.cache.read` / `tokens.cache.write`). These are
58
+ // EXACT provider numbers (not estimates) and are a subset of `input`, so they
59
+ // are shown as a separate metric and never folded into the category sum.
60
+ const lastKnownCacheReadCache = new Map();
61
+ const lastKnownCacheWriteCache = new Map();
56
62
 
57
63
  // sessionID -> last time we wrote a live breakdown snapshot
58
64
  const lastBdWrite = new Map();
@@ -92,6 +98,16 @@ function lastKnownReasoning(sessionID) {
92
98
  function lastKnownCtx(sessionID) {
93
99
  return lastKnownCtxCache.get(sessionID) || 0;
94
100
  }
101
+ // Provider-reported prompt-cache figures; null when the provider never reported
102
+ // them (a plain "n/a" rather than a misleading 0).
103
+ function lastKnownCacheRead(sessionID) {
104
+ const v = lastKnownCacheReadCache.get(sessionID);
105
+ return typeof v === "number" ? v : null;
106
+ }
107
+ function lastKnownCacheWrite(sessionID) {
108
+ const v = lastKnownCacheWriteCache.get(sessionID);
109
+ return typeof v === "number" ? v : null;
110
+ }
95
111
 
96
112
  // ---------------------------------------------------------------------------
97
113
  // Clear per-session tracking. Full wipe on session.idle / session.deleted;
@@ -118,6 +134,8 @@ function clearSessionTracking(sessionID, keepToastDedup = false) {
118
134
  lastKnownInputCache.delete(sessionID);
119
135
  lastKnownReasoningCache.delete(sessionID);
120
136
  lastKnownCtxCache.delete(sessionID);
137
+ lastKnownCacheReadCache.delete(sessionID);
138
+ lastKnownCacheWriteCache.delete(sessionID);
121
139
  lastBdWrite.delete(sessionID);
122
140
  // V2 subagent accumulation for this session.
123
141
  sessionTotals.delete(sessionID);
@@ -145,6 +163,8 @@ export {
145
163
  lastKnownInputCache,
146
164
  lastKnownReasoningCache,
147
165
  lastKnownCtxCache,
166
+ lastKnownCacheReadCache,
167
+ lastKnownCacheWriteCache,
148
168
  lastBdWrite,
149
169
  lastErrorToast,
150
170
  lastCompactedToast,
@@ -157,5 +177,7 @@ export {
157
177
  lastKnownInput,
158
178
  lastKnownReasoning,
159
179
  lastKnownCtx,
180
+ lastKnownCacheRead,
181
+ lastKnownCacheWrite,
160
182
  clearSessionTracking,
161
183
  };
package/lib/commands.js CHANGED
@@ -14,6 +14,8 @@ import {
14
14
  safeCategories,
15
15
  safeCell,
16
16
  safeCount,
17
+ safeReclaim,
18
+ safeSaneOrNull,
17
19
  safeToolOutByTool,
18
20
  sumCategories,
19
21
  toolOutEntries,
@@ -21,6 +23,8 @@ import {
21
23
  } from "./estimate.js";
22
24
  import {
23
25
  breakdownCache,
26
+ lastKnownCacheRead,
27
+ lastKnownCacheWrite,
24
28
  lastKnownCtx,
25
29
  lastKnownInput,
26
30
  lastKnownModel,
@@ -272,6 +276,19 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
272
276
  : safeToolOutByTool(entry?.toolOutByTool) ||
273
277
  safeToolOutByTool(fallbackParts?.toolOutByTool);
274
278
  const compactStale = entry?.compactStale === true;
279
+ // Provider-reported prompt cache (exact; subset of input). Live cache first,
280
+ // then the persisted snapshot.
281
+ const cacheRead =
282
+ safeSaneOrNull(lastKnownCacheRead(sessionID)) ??
283
+ safeSaneOrNull(entry?.cacheRead);
284
+ const cacheWrite =
285
+ safeSaneOrNull(lastKnownCacheWrite(sessionID)) ??
286
+ safeSaneOrNull(entry?.cacheWrite);
287
+ // Redundant tool output. The live breakdown owns targets; the persisted entry
288
+ // has them stripped (path-free) — sanitise both.
289
+ const reclaim = bd
290
+ ? safeReclaim(bd.reclaim, true)
291
+ : safeReclaim(entry?.reclaim, false);
275
292
  return {
276
293
  sessionID,
277
294
  parentID,
@@ -279,10 +296,14 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
279
296
  modelID,
280
297
  providerID,
281
298
  ctxTokens,
299
+ inputTokens: lastKnownInput(sessionID) || entry?.input || 0,
282
300
  usable,
283
301
  reserve,
284
302
  categories,
285
303
  toolOutByTool,
304
+ reclaim,
305
+ cacheRead,
306
+ cacheWrite,
286
307
  compactStale,
287
308
  source,
288
309
  updatedAt,
@@ -321,6 +342,41 @@ function renderToolOutLine(toolOutByTool, limit) {
321
342
  return parts.join(" · ");
322
343
  }
323
344
 
345
+ // Provider-reported prompt cache for the main session — EXACT numbers, a
346
+ // SUBSET of input (so the percentage is "of input", never a category). Empty
347
+ // when the provider never reported cache tokens.
348
+ function renderCacheLine(r) {
349
+ if (!r || (r.cacheRead == null && r.cacheWrite == null)) return "";
350
+ const read = r.cacheRead != null ? fmt(r.cacheRead) : "0";
351
+ const write = r.cacheWrite != null ? fmt(r.cacheWrite) : "0";
352
+ const pct =
353
+ r.cacheRead != null && r.inputTokens > 0
354
+ ? ` (${((r.cacheRead / r.inputTokens) * 100).toFixed(0)}% of input)`
355
+ : "";
356
+ return `read ${read}${pct} · write ${write}`;
357
+ }
358
+
359
+ // Redundant tool output already in context (repeated target / identical output).
360
+ // Targets are shown only when present (the live breakdown has them; the
361
+ // persisted/redacted path strips them).
362
+ function renderReclaimSection(r) {
363
+ const rc = r?.reclaim;
364
+ if (!rc || rc.tokens <= 0) return "";
365
+ const lines = ["", "### Potential reclaim (redundant tool output)"];
366
+ for (const f of rc.findings || []) {
367
+ const label = f.target
368
+ ? `${safeCell(f.name)} ${safeCell(f.target)}`
369
+ : f.kind === "content"
370
+ ? `${safeCell(f.name)} (identical output)`
371
+ : safeCell(f.name);
372
+ lines.push(`- ${label} x${f.n} — ~${fmt(f.tokens)} reclaimable`);
373
+ }
374
+ lines.push(
375
+ `_~${fmt(rc.tokens)} across ${rc.count} redundant tool output(s) — estimate of what trimming/compaction could reclaim._`,
376
+ );
377
+ return lines.join("\n");
378
+ }
379
+
324
380
  function renderBreakdownTable(rows, childCount, mainReport) {
325
381
  const lines = [];
326
382
  lines.push("### Context breakdown");
@@ -346,6 +402,16 @@ function renderBreakdownTable(rows, childCount, mainReport) {
346
402
  lines.push("### Tool results by tool (main)");
347
403
  lines.push(byTool);
348
404
  }
405
+ // Provider-reported prompt cache (exact; a subset of input).
406
+ const cache = renderCacheLine(mainReport);
407
+ if (cache) {
408
+ lines.push("");
409
+ lines.push("### Prompt cache (main, provider-reported)");
410
+ lines.push(cache);
411
+ }
412
+ // Redundant tool output (waste detector).
413
+ const reclaim = renderReclaimSection(mainReport);
414
+ if (reclaim) lines.push(reclaim);
349
415
  return lines.join("\n");
350
416
  }
351
417
 
@@ -416,6 +482,10 @@ function shortSummaryText(r) {
416
482
  }
417
483
  lines.push(compact);
418
484
  }
485
+ // Provider-reported prompt cache (exact; subset of input) — shown when the
486
+ // provider reported it.
487
+ const cache = renderCacheLine(r);
488
+ if (cache) lines.push(`prompt cache ${cache}`);
419
489
  lines.push(`updated ${timeOnly(r.updatedAt)} (${sourceTag(r.source)})`);
420
490
  if (r.compactStale) {
421
491
  lines.push(
package/lib/estimate.js CHANGED
@@ -81,6 +81,18 @@ function safeCountOrNull(v) {
81
81
  return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
82
82
  }
83
83
 
84
+ // A finite, non-negative, SANE number or null — for persisted provider-reported
85
+ // figures (cache read/write). Like safeCountOrNull but also rejects absurd
86
+ // magnitudes, so a poisoned state.json cannot render garbage (fmt(NaN), 1e15).
87
+ function safeSaneOrNull(v) {
88
+ return typeof v === "number" &&
89
+ Number.isFinite(v) &&
90
+ v >= 0 &&
91
+ v <= MAX_SANE_LIMIT
92
+ ? v
93
+ : null;
94
+ }
95
+
84
96
  // Normalise a categories object from untrusted input (state.json) into the 8
85
97
  // known numeric fields — never trust its shape. `toolOut` is 0 for legacy
86
98
  // entries written before the category existed (their tool results stay folded
@@ -198,6 +210,210 @@ function toolOutEntries(map, limit = 8) {
198
210
  .slice(0, Math.max(0, limit));
199
211
  }
200
212
 
213
+ // ---------------------------------------------------------------------------
214
+ // Waste detection: find redundant tool output already sitting in the context.
215
+ // Pure transforms — the caller supplies per-call instances captured from the
216
+ // request payload; nothing here touches the fs or the caches.
217
+ // ---------------------------------------------------------------------------
218
+
219
+ // Best-effort TARGET of a tool call, from the per-call input. A short, stable
220
+ // string used only to group repeated calls (the same file read twice) and to
221
+ // label a finding. Returns null when no suitable string field exists. The value
222
+ // is untrusted (tool args); it is whitespace-collapsed and bounded here, and
223
+ // display layers sanitise it further.
224
+ const TARGET_FIELD_MAP = {
225
+ webfetch: ["url", "uri"],
226
+ fetch: ["url", "uri"],
227
+ bash: ["command", "cmd"],
228
+ shell: ["command", "cmd"],
229
+ grep: ["pattern", "query", "regex", "path"],
230
+ search: ["query", "pattern"],
231
+ glob: ["pattern", "path"],
232
+ read: ["filePath", "file", "path", "filename"],
233
+ read_file: ["filePath", "file", "path", "filename"],
234
+ write: ["filePath", "file", "path", "filename"],
235
+ edit: ["filePath", "file", "path", "filename"],
236
+ list: ["path", "dir"],
237
+ ls: ["path", "dir"],
238
+ task: ["description", "prompt"],
239
+ agent: ["description", "prompt"],
240
+ };
241
+ function extractToolTarget(name, input) {
242
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
243
+ const n = typeof name === "string" ? name.toLowerCase() : "";
244
+ const fields = TARGET_FIELD_MAP[n];
245
+ const pick = (keys) => {
246
+ for (const k of keys) {
247
+ const v = input[k];
248
+ if (typeof v === "string" && v.length > 0) return v;
249
+ }
250
+ return null;
251
+ };
252
+ let t = fields ? pick(fields) : null;
253
+ if (t == null) {
254
+ // Generic fallback: the first non-empty string-valued field. Bounded scan
255
+ // (the input object is untrusted) so a crafted huge input cannot stall us.
256
+ let scanned = 0;
257
+ for (const k of Object.keys(input)) {
258
+ if (++scanned > 32) break;
259
+ const v = input[k];
260
+ if (typeof v === "string" && v.length > 0) {
261
+ t = v;
262
+ break;
263
+ }
264
+ }
265
+ }
266
+ if (typeof t !== "string" || t.length === 0) return null;
267
+ // Slice BEFORE the whitespace pass: normalising an untrusted multi-MB string
268
+ // is the expensive part, so cap the work first.
269
+ return t.slice(0, 400).replace(/\s+/g, " ").trim().slice(0, 200);
270
+ }
271
+
272
+ // Cheap content fingerprint of a tool RESULT (length + FNV-1a over the first
273
+ // 4KB). Used to detect byte-identical outputs already in context. Not
274
+ // cryptographic — deliberately cheap (runs on every model request).
275
+ function contentFingerprint(text) {
276
+ if (typeof text !== "string" || text.length === 0) return "";
277
+ const s = text.length > 4096 ? text.slice(0, 4096) : text;
278
+ let h = 2166136261;
279
+ for (let i = 0; i < s.length; i++) {
280
+ h ^= s.charCodeAt(i);
281
+ h = Math.imul(h, 16777619);
282
+ }
283
+ return `${text.length}:${(h >>> 0).toString(36)}`;
284
+ }
285
+
286
+ // Group per-call instances and estimate the tool-output tokens that are
287
+ // redundant (the same target invoked repeatedly, or byte-identical outputs).
288
+ // `instances`: [{ name, target, tokens, fp }]. Reclaim = the sum of the
289
+ // NON-largest member of every group of size >= 2, unioned across target- and
290
+ // content-groups so an instance is counted once. This is a LOWER bound of what
291
+ // compaction/trim could reclaim — labelled as an estimate everywhere.
292
+ function computeReclaim(instances, maxFindings = 8) {
293
+ const list = Array.isArray(instances) ? instances : [];
294
+ const byTarget = new Map();
295
+ const byContent = new Map();
296
+ const push = (map, key, idx) => {
297
+ const a = map.get(key);
298
+ if (a) a.push(idx);
299
+ else map.set(key, [idx]);
300
+ };
301
+ for (let i = 0; i < list.length; i++) {
302
+ const it = list[i];
303
+ if (safeCount(it?.tokens) <= 0) continue;
304
+ const name = typeof it?.name === "string" && it.name ? it.name : "?";
305
+ const target =
306
+ typeof it?.target === "string" && it.target ? it.target : null;
307
+ if (target) push(byTarget, `${name}\u0000${target}`, i);
308
+ const fp = typeof it?.fp === "string" && it.fp ? it.fp : "";
309
+ if (fp) push(byContent, fp, i);
310
+ }
311
+ // Resolve each group's keeper (largest member) once.
312
+ const buildGroups = (map) => {
313
+ const out = [];
314
+ for (const idxs of map.values()) {
315
+ if (idxs.length < 2) continue;
316
+ let maxI = idxs[0];
317
+ for (const i of idxs) {
318
+ if (safeCount(list[i]?.tokens) > safeCount(list[maxI]?.tokens)) maxI = i;
319
+ }
320
+ let groupTokens = 0;
321
+ for (const i of idxs) {
322
+ if (i !== maxI) groupTokens += safeCount(list[i]?.tokens);
323
+ }
324
+ out.push({ idxs, maxI, groupTokens });
325
+ }
326
+ out.sort((a, b) => b.groupTokens - a.groupTokens);
327
+ return out;
328
+ };
329
+ const targetGroups = buildGroups(byTarget);
330
+ const contentGroups = buildGroups(byContent);
331
+
332
+ // The authoritative total: every instance that is NOT the largest member of
333
+ // at least one duplicate group, counted ONCE (union across group kinds).
334
+ const removable = new Set();
335
+ for (const g of targetGroups) {
336
+ for (const i of g.idxs) if (i !== g.maxI) removable.add(i);
337
+ }
338
+ for (const g of contentGroups) {
339
+ for (const i of g.idxs) if (i !== g.maxI) removable.add(i);
340
+ }
341
+ let tokens = 0;
342
+ for (const i of removable) tokens += safeCount(list[i]?.tokens);
343
+
344
+ // Findings: target groups first (they carry the target label), then content
345
+ // groups only for instances not already covered — so the rendered findings
346
+ // never sum to more than the headline total.
347
+ const findings = [];
348
+ const covered = new Set();
349
+ for (const g of targetGroups) {
350
+ const rep = list[g.maxI] || {};
351
+ findings.push({
352
+ kind: "target",
353
+ name: typeof rep.name === "string" ? rep.name : "?",
354
+ target: typeof rep.target === "string" ? rep.target : null,
355
+ n: g.idxs.length,
356
+ tokens: g.groupTokens,
357
+ });
358
+ for (const i of g.idxs) covered.add(i);
359
+ }
360
+ for (const g of contentGroups) {
361
+ let t = 0;
362
+ for (const i of g.idxs) {
363
+ if (i === g.maxI || covered.has(i)) continue;
364
+ t += safeCount(list[i]?.tokens);
365
+ }
366
+ if (t > 0) {
367
+ const rep = list[g.maxI] || {};
368
+ findings.push({
369
+ kind: "content",
370
+ name: typeof rep.name === "string" ? rep.name : "?",
371
+ target: null,
372
+ n: g.idxs.length,
373
+ tokens: t,
374
+ });
375
+ }
376
+ }
377
+ findings.sort((a, b) => b.tokens - a.tokens);
378
+ return {
379
+ tokens,
380
+ count: removable.size,
381
+ findings: findings.slice(0, Math.max(0, maxFindings)),
382
+ };
383
+ }
384
+
385
+ // Sanitise a reclaim summary from a live estimate OR persisted state.json
386
+ // (untrusted). `keepTarget=false` drops the per-finding target so a persisted /
387
+ // exported payload stays free of file paths and URLs. Returns null when there
388
+ // is nothing worth showing.
389
+ function safeReclaim(raw, keepTarget = true, maxFindings = 8) {
390
+ if (!raw || typeof raw !== "object") return null;
391
+ const tokens = safeCount(raw.tokens);
392
+ const count = safeCount(raw.count);
393
+ if (tokens <= 0 || count <= 0) return null;
394
+ const findings = [];
395
+ const src = Array.isArray(raw.findings) ? raw.findings : [];
396
+ for (const f of src) {
397
+ if (!f || typeof f !== "object") continue;
398
+ const name = isSafeSessionKey(f.name) && f.name.length <= 64 ? f.name : "?";
399
+ const ft = safeCount(f.tokens);
400
+ const fn = safeCount(f.n);
401
+ if (ft <= 0 || fn < 2) continue;
402
+ const item = {
403
+ kind: f.kind === "content" ? "content" : "target",
404
+ name,
405
+ n: fn,
406
+ tokens: ft,
407
+ };
408
+ if (keepTarget && typeof f.target === "string" && f.target.length > 0) {
409
+ item.target = f.target.slice(0, 200);
410
+ }
411
+ findings.push(item);
412
+ if (findings.length >= Math.max(0, maxFindings)) break;
413
+ }
414
+ return { tokens, count, findings };
415
+ }
416
+
201
417
  function fmt(n) {
202
418
  return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
203
419
  }
@@ -307,6 +523,7 @@ export {
307
523
  finitePos,
308
524
  safeCount,
309
525
  safeCountOrNull,
526
+ safeSaneOrNull,
310
527
  safeCategories,
311
528
  sumCategories,
312
529
  breakdownCategories,
@@ -314,6 +531,10 @@ export {
314
531
  safeToolOutByTool,
315
532
  toolOutEntries,
316
533
  TOOL_OUT_MAX_KEYS,
534
+ extractToolTarget,
535
+ contentFingerprint,
536
+ computeReclaim,
537
+ safeReclaim,
317
538
  fmt,
318
539
  estimateTokens,
319
540
  estJson,
package/lib/state.js CHANGED
@@ -27,6 +27,8 @@ import {
27
27
  oneLine,
28
28
  safeCount,
29
29
  safeCountOrNull,
30
+ safeReclaim,
31
+ safeSaneOrNull,
30
32
  safeToolOutByTool,
31
33
  sumCategories,
32
34
  } from "./estimate.js";
@@ -434,6 +436,23 @@ function writeStateFile(
434
436
  ? safeToolOutByTool(bd.toolOutByTool)
435
437
  : safeToolOutByTool(prevEntry?.toolOutByTool);
436
438
 
439
+ // Redundant tool output (waste detector). Persisted WITHOUT targets
440
+ // (keepTarget=false): state.json is a local temp file, but keeping it
441
+ // path-free matches the redacted-export policy. A present breakdown owns
442
+ // the value; otherwise inherit the persisted one.
443
+ const reclaim = bd
444
+ ? safeReclaim(bd.reclaim, false)
445
+ : safeReclaim(prevEntry?.reclaim, false);
446
+
447
+ // Provider-reported prompt cache (EXACT; a subset of input — never added
448
+ // to the category sum). Inherit when this write carries no fresh figure
449
+ // (idle / compaction mirror writes must not drop it).
450
+ const cacheRead =
451
+ safeSaneOrNull(opts?.cacheRead) ?? safeSaneOrNull(prevEntry?.cacheRead);
452
+ const cacheWrite =
453
+ safeSaneOrNull(opts?.cacheWrite) ??
454
+ safeSaneOrNull(prevEntry?.cacheWrite);
455
+
437
456
  // Compaction staleness: a `session.compacted` write re-persists the
438
457
  // pre-compaction numbers (the compaction flow fires no context hook), so
439
458
  // the entry is stale BY CONSTRUCTION — even when this instance has no live
@@ -526,6 +545,9 @@ function writeStateFile(
526
545
  other,
527
546
  },
528
547
  toolOutByTool,
548
+ reclaim,
549
+ cacheRead,
550
+ cacheWrite,
529
551
  compactedAt,
530
552
  compactStale,
531
553
  updatedAt: new Date().toISOString(),
package/lib/tui-data.d.ts CHANGED
@@ -31,6 +31,20 @@ export interface StateEntry {
31
31
  categories?: Categories
32
32
  /** Per-tool tool-result split: tool name -> estimated tokens. */
33
33
  toolOutByTool?: Record<string, number>
34
+ /** Provider-reported prompt-cache tokens (exact; a subset of input). */
35
+ cacheRead?: number | null
36
+ cacheWrite?: number | null
37
+ /** Redundant tool output (waste detector). Findings here are path-free. */
38
+ reclaim?: {
39
+ tokens: number
40
+ count: number
41
+ findings?: Array<{
42
+ kind?: string
43
+ name?: string
44
+ n?: number
45
+ tokens?: number
46
+ }>
47
+ } | null
34
48
  /** ISO time of the last session.compacted (marker only). */
35
49
  compactedAt?: string | null
36
50
  /** True while the entry still shows pre-compaction numbers. */
package/lib/tui-panel.tsx CHANGED
@@ -570,6 +570,29 @@ export function BreakdownPanel(props: {
570
570
  : "")}
571
571
  </text>
572
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>
573
596
  <Show when={messages().available}>
574
597
  <text fg={base()}>{"largest contributors (measured)"}</text>
575
598
  <Show
package/lib/v2.js CHANGED
@@ -8,11 +8,15 @@
8
8
 
9
9
  import {
10
10
  breakdownCategories,
11
+ computeReclaim,
12
+ contentFingerprint,
11
13
  estJson,
12
14
  estimateTokens,
15
+ extractToolTarget,
13
16
  fmt,
14
17
  isSafeSessionKey,
15
18
  oneLine,
19
+ safeSaneOrNull,
16
20
  tokenCountOf,
17
21
  TOOL_OUT_MAX_KEYS,
18
22
  withTimeout,
@@ -25,6 +29,10 @@ import {
25
29
  lastBdWrite,
26
30
  lastKnownCtx,
27
31
  lastKnownCtxCache,
32
+ lastKnownCacheRead,
33
+ lastKnownCacheReadCache,
34
+ lastKnownCacheWrite,
35
+ lastKnownCacheWriteCache,
28
36
  lastKnownInput,
29
37
  lastKnownInputCache,
30
38
  lastKnownModel,
@@ -144,6 +152,34 @@ function toolResultChars(p) {
144
152
  return estJson(r.value); // json | error | unexpected shapes
145
153
  }
146
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
+
147
183
  // Accumulate the per-tool tool-result estimate. Tool names are untrusted
148
184
  // (host tool ids, plugin-defined tools): unsafe names and any name beyond the
149
185
  // cap fold into "other tools" so the bucket never becomes a prototype vector
@@ -174,6 +210,11 @@ function estimateBreakdownFromContext(context, sessionID) {
174
210
  const toolOutByTool = {};
175
211
 
176
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 = [];
177
218
  for (const msg of messages) {
178
219
  const parts = Array.isArray(msg?.content) ? msg.content : [];
179
220
  for (const p of parts) {
@@ -184,6 +225,16 @@ function estimateBreakdownFromContext(context, sessionID) {
184
225
  const chars = toolResultChars(p);
185
226
  toolOutChars += chars;
186
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
+ }
187
238
  continue;
188
239
  }
189
240
  if (msg?.role === "user") {
@@ -195,10 +246,15 @@ function estimateBreakdownFromContext(context, sessionID) {
195
246
  reasoningChars += estimateTokens(p.text);
196
247
  } else if (p?.type === "tool-call") {
197
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
+ }
198
252
  }
199
253
  }
200
254
  }
201
255
  }
256
+ const reclaimRaw = instances.length > 0 ? computeReclaim(instances) : null;
257
+ const reclaim = reclaimRaw && reclaimRaw.tokens > 0 ? reclaimRaw : null;
202
258
 
203
259
  let systemChars = 0;
204
260
  const system = Array.isArray(context?.system) ? context.system : [];
@@ -239,6 +295,7 @@ function estimateBreakdownFromContext(context, sessionID) {
239
295
  toolOutByTool,
240
296
  systemChars,
241
297
  toolSchemasChars,
298
+ reclaim,
242
299
  };
243
300
  }
244
301
 
@@ -291,6 +348,7 @@ function estimateBreakdownFromClientMessages(messages) {
291
348
  let toolArgsChars = 0;
292
349
  let toolOutChars = 0;
293
350
  const toolOutByTool = {};
351
+ const instances = [];
294
352
  for (const m of messages || []) {
295
353
  if (!m || typeof m !== "object") continue;
296
354
  if (m.type === "user") {
@@ -315,20 +373,32 @@ function estimateBreakdownFromClientMessages(messages) {
315
373
  const content = p?.state?.content;
316
374
  if (Array.isArray(content)) {
317
375
  let outChars = 0;
376
+ let text = "";
318
377
  for (const item of content) {
319
378
  if (item?.type === "text" && typeof item.text === "string") {
320
379
  outChars += estimateTokens(item.text);
380
+ if (text.length <= 4096) text += item.text;
321
381
  }
322
382
  }
323
383
  toolOutChars += outChars;
324
384
  // The persisted tool part carries its name on `p.name`
325
385
  // (SessionMessageAssistantTool.name) — same field as the hook path.
326
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
+ }
327
395
  }
328
396
  }
329
397
  }
330
398
  }
331
399
  }
400
+ const reclaimRaw = instances.length > 0 ? computeReclaim(instances) : null;
401
+ const reclaim = reclaimRaw && reclaimRaw.tokens > 0 ? reclaimRaw : null;
332
402
  return {
333
403
  userChars,
334
404
  assistantChars,
@@ -338,6 +408,7 @@ function estimateBreakdownFromClientMessages(messages) {
338
408
  toolOutByTool,
339
409
  systemChars: null,
340
410
  toolSchemasChars: null,
411
+ reclaim,
341
412
  };
342
413
  }
343
414
 
@@ -496,6 +567,25 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
496
567
  lines.push(` other ${fmt(other)} (= input - sum of estimates)`);
497
568
  lines.push(` source ${source}`);
498
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
+
499
589
  try {
500
590
  const sub = aggregateSubagentsV2(sessionID);
501
591
  if (sub.count > 0) {
@@ -606,7 +696,11 @@ async function handleV2Event(ctx, event) {
606
696
  // flow fires no context hook — see README): mark the entry stale so the
607
697
  // UI shows the numbers as pre-compaction until the next model request
608
698
  // refreshes them.
609
- { compacted: true },
699
+ {
700
+ compacted: true,
701
+ cacheRead: lastKnownCacheRead(sid),
702
+ cacheWrite: lastKnownCacheWrite(sid),
703
+ },
610
704
  );
611
705
  pushFinalSummary(
612
706
  `compacted:${event.id ?? sid}`,
@@ -646,6 +740,10 @@ async function handleV2Event(ctx, event) {
646
740
  lastKnownInput(sid),
647
741
  lastKnownCtx(sid),
648
742
  lastKnownReasoning(sid),
743
+ {
744
+ cacheRead: lastKnownCacheRead(sid),
745
+ cacheWrite: lastKnownCacheWrite(sid),
746
+ },
649
747
  );
650
748
  pushFinalSummary(`final:${event.id ?? sid}`, [
651
749
  `[${new Date().toISOString()}] FINAL session=${sid}`,
@@ -668,10 +766,16 @@ async function handleV2Event(ctx, event) {
668
766
  const output = typeof t.output === "number" ? t.output : 0;
669
767
  const reasoning = typeof t.reasoning === "number" ? t.reasoning : 0;
670
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);
671
773
 
672
774
  lastKnownInputCache.set(sid, input);
673
775
  lastKnownCtxCache.set(sid, contextTokens);
674
776
  lastKnownReasoningCache.set(sid, reasoning);
777
+ if (cacheRead != null) lastKnownCacheReadCache.set(sid, cacheRead);
778
+ if (cacheWrite != null) lastKnownCacheWriteCache.set(sid, cacheWrite);
675
779
  if (!lastKnownModelCache.has(sid) && data.model) {
676
780
  // Some builds put the model on the step payload; harmless when absent.
677
781
  if (typeof data.model.id === "string") lastKnownModelCache.set(sid, data.model.id);
@@ -682,6 +786,8 @@ async function handleV2Event(ctx, event) {
682
786
  pruneMap(lastKnownInputCache, MAX_TRACKED_SESSIONS);
683
787
  pruneMap(lastKnownCtxCache, MAX_TRACKED_SESSIONS);
684
788
  pruneMap(lastKnownReasoningCache, MAX_TRACKED_SESSIONS);
789
+ pruneMap(lastKnownCacheReadCache, MAX_TRACKED_SESSIONS);
790
+ pruneMap(lastKnownCacheWriteCache, MAX_TRACKED_SESSIONS);
685
791
  pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
686
792
  pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
687
793
 
@@ -740,6 +846,10 @@ async function handleV2Event(ctx, event) {
740
846
  input,
741
847
  contextTokens,
742
848
  reasoning,
849
+ {
850
+ cacheRead: lastKnownCacheRead(sid),
851
+ cacheWrite: lastKnownCacheWrite(sid),
852
+ },
743
853
  );
744
854
  writeLog(lines);
745
855
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.4.1",
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",