opencode-context-indicator 1.4.1 → 1.5.1

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,48 @@ 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.1 — 2026-10-19
8
+
9
+ - **Compaction now handled by the real `compaction` hook.** opencode dispatches
10
+ a `compaction` hook at the start of a compaction request with the same
11
+ pre-compaction payload as the `context` hook. The plugin registers it and
12
+ persists the *true* pre-compaction breakdown at that moment — previously the
13
+ trailing `session.compacted` event re-persisted whatever the last model
14
+ request had left behind. The event is kept as a fallback (hosts/paths where
15
+ the hook does not fire), and a no-op compaction ("Nothing to compact yet")
16
+ writes no stale entry. Same `(stale — compacted)` UX, more accurate numbers.
17
+ - Dev: pinned the `@opencode/plugin` devDependency to `2.0.32`.
18
+ - Tests: T25 / T25a / T25b / T25c (compaction-hook snapshot, no-op guard,
19
+ event fallback, cold-cache clobber guard) — 62 unit checks total.
20
+
21
+ ## 1.5.0 — 2026-10-16
22
+
23
+ Highlights: two new context-health signals — the **provider-reported prompt
24
+ cache** (exact) and a **waste detector** for redundant tool output already in
25
+ the context.
26
+
27
+ - **Prompt cache (provider-reported)**: `session.step.ended` carries
28
+ `tokens.cache.{read,write}` — the provider's EXACT prompt-cache figures. Shown
29
+ as `prompt cache read=X (Y% of input) · write=Z` in `/context-breakdown`
30
+ (a `### Prompt cache` section), `/context`, the `./cx` panel and the live log.
31
+ It is a **subset of `input`**, so it is deliberately **not** added to the
32
+ category sum (no double-count). `n/a` when the provider reports nothing;
33
+ persisted as `cacheRead`/`cacheWrite` and inherited by mirror writes (a
34
+ non-measuring write cannot drop them).
35
+ - **Waste detector**: finds tool output already in the context that is redundant
36
+ — the **same target** invoked repeatedly (the same file read twice, the same
37
+ URL fetched again) or **byte-identical output**. Reported as a `### Potential
38
+ reclaim (redundant tool output)` section in `/context-breakdown` (top findings
39
+ with tool + target) plus a `reclaim ~X redundant tool output (N dup)` panel/log
40
+ line. The number is the non-largest member of each duplicate group — an honest
41
+ *"N copies are in context; trimming/compaction could reclaim ~X"*, never "the
42
+ model wasted effort". Findings are **path-free** in the persisted/exported
43
+ form; the estimate is computed on both the hook and the fallback path.
44
+ - Tests: T23/T23a/T23b (cache persistence, surfaces, sanitisation), T24–T24c
45
+ (reclaim algorithm, target extraction, hook capture via call-id correlation,
46
+ path-free persistence + rendering).
47
+ - Legacy `state.json` entries (without the new fields) behave exactly as before.
48
+
7
49
  ## 1.4.1 — 2026-10-14
8
50
 
9
51
  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
@@ -508,9 +529,10 @@ V2 path). It does not register configurable keys of its own.
508
529
 
509
530
  The package default export is a dual plugin:
510
531
 
511
- * `setup(ctx)` — V2 entry (OpenCode ≥ 2.0.16). Registers a `session.hook("context", …)`
512
- to capture the final messages / system prompt / tool schemas right before each
513
- model request, subscribes to the event stream (`session.created`,
532
+ * `setup(ctx)` — V2 entry (OpenCode ≥ 2.0.16). Registers `session.hook("context", …)`
533
+ and `session.hook("compaction", …)` to capture the final messages / system
534
+ prompt / tool schemas right before each model request (and each compaction
535
+ request), subscribes to the event stream (`session.created`,
514
536
  `session.step.ended`, `session.idle`, `session.compacted`, failure events),
515
537
  records limits from the model registry and registers the
516
538
  `/context` + `/context-breakdown` slash commands (via
@@ -528,12 +550,15 @@ duplicate final summaries are suppressed with an atomic claim marker
528
550
  (`lib/dedup.js`). Every file / estimate / subagent path is fault-tolerant: a
529
551
  failure is logged and never breaks the main event or toast path.
530
552
 
531
- **Compaction staleness.** OpenCode's compaction runs a separate flow that fires
532
- no `context` hook, so the numbers a `session.compacted` write re-persists are
533
- the *pre-compaction* ones. The plugin flags such an entry
534
- (`compactStale` in `state.json`) and every surface shows `(stale — compacted)`
535
- until the next model request refreshes the breakdown — i.e. the data is exactly
536
- as fresh as the last model request in the session.
553
+ **Compaction staleness.** OpenCode dispatches a dedicated `compaction` hook at
554
+ the start of a compaction request, carrying the same pre-compaction payload as
555
+ the `context` hook. The plugin registers it and persists the *true*
556
+ pre-compaction breakdown at that moment, flagging the entry stale
557
+ (`compactStale` in `state.json`) so every surface shows `(stale — compacted)`
558
+ until the next model request refreshes it. The trailing `session.compacted`
559
+ event is kept as a fallback (for hosts or paths where the hook does not fire);
560
+ a no-op compaction ("Nothing to compact yet") writes no stale entry. The data
561
+ is always exactly as fresh as the last model request in the session.
537
562
 
538
563
  This plugin does not touch `opencode-token-monitor`
539
564
  (`token_stats` / `token_history` / `token_export` keep working unchanged).
@@ -553,7 +578,7 @@ caller.
553
578
  ### Running tests
554
579
 
555
580
  ```bash
556
- npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T22 unit checks
581
+ npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T24 unit checks
557
582
  npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
558
583
  npm run test:e2e:quick # E2E load-only, no LLM call
559
584
  npm run test:drift # OpenTUI pin vs the latest RELEASED opencode (also in CI)
@@ -574,7 +599,7 @@ STEP 80 s, ~3 min total.
574
599
  | (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
600
  | (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
601
  | (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 |
602
+ | (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
603
  | (e) corporate-identifier scan | no forbidden identifiers in the published surface + test tree | compliance |
579
604
  | (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in any repo file | English-only source rule |
580
605
 
package/index.js CHANGED
@@ -150,8 +150,8 @@
150
150
  */
151
151
 
152
152
  import { makeV1 } from "./lib/v1.js";
153
- import { handleV2Event, onV2Context, estimateBreakdownFromContext, estimateBreakdownFromClientMessages } from "./lib/v2.js";
154
- import { breakdownCategories, estimateTokens, isSafeSessionKey, safeCell, safeToolOutByTool, sumCategories, toolOutEntries } from "./lib/estimate.js";
153
+ import { handleV2Event, onV2Context, onV2Compaction, estimateBreakdownFromContext, estimateBreakdownFromClientMessages } from "./lib/v2.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, lastKnownCtxCache, lastKnownInputCache, lastKnownReasoningCache, sessionAgentCache, sessionParentCache } from "./lib/cache.js";
174
174
  import {
175
175
  logErr,
176
176
  readStateSnapshot,
@@ -204,6 +204,7 @@ export {
204
204
  estimateBreakdownFromContext,
205
205
  estimateBreakdownFromClientMessages,
206
206
  onV2Context,
207
+ onV2Compaction,
207
208
  safeToolOutByTool,
208
209
  toolOutEntries,
209
210
  sumCategories,
@@ -211,6 +212,17 @@ export {
211
212
  safeCell,
212
213
  isSafeSessionKey,
213
214
  estimateTokens,
215
+ extractToolTarget,
216
+ contentFingerprint,
217
+ computeReclaim,
218
+ safeReclaim,
219
+ safeSaneOrNull,
220
+ lastKnownCacheRead,
221
+ lastKnownCacheWrite,
222
+ lastKnownCtxCache,
223
+ lastKnownInputCache,
224
+ lastKnownReasoningCache,
225
+ handleV2Event,
214
226
  };
215
227
 
216
228
  // ---------------------------------------------------------------------------
@@ -231,6 +243,19 @@ export default {
231
243
  logErr("v2 context hook registration failed", err);
232
244
  }
233
245
 
246
+ // The compaction request carries the same pre-compaction context payload as
247
+ // the context hook. Registering it lets us persist the TRUE pre-compaction
248
+ // breakdown (see onV2Compaction); the `session.compacted` event then only
249
+ // acts as a fallback. A registration failure is non-fatal — the event path
250
+ // still marks the entry stale.
251
+ try {
252
+ registrations.push(
253
+ await ctx.session.hook("compaction", (context) => onV2Compaction(context)),
254
+ );
255
+ } catch (err) {
256
+ logErr("v2 compaction hook registration failed", err);
257
+ }
258
+
234
259
  // The model registry is captured asynchronously through ensureModelLimits
235
260
  // (ctx.model.list(), rank 3) — the authoritative source that carries the
236
261
  // user's merged config overrides. ctx.model.transform fires synchronously
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();
@@ -63,6 +69,13 @@ const lastErrorToast = new Map();
63
69
  // sessionID -> timestamp — dedup for session.compacted toasts
64
70
  const lastCompactedToast = new Map();
65
71
 
72
+ // sessionID -> timestamp — when the `compaction` HOOK last fired for a session.
73
+ // The hook is dispatched at the START of a compaction request (before the model
74
+ // summarises), carrying the exact pre-compaction context, so it lets us persist
75
+ // the TRUE pre-compaction breakdown. It also tells the later `session.compacted`
76
+ // EVENT that the hook already marked the entry (the event stays a fallback).
77
+ const lastCompactionHookAt = new Map();
78
+
66
79
  // sessionID -> parentID (null = main session) — [main]/[sub] attribution cache
67
80
  const sessionParentCache = new Map();
68
81
 
@@ -92,6 +105,16 @@ function lastKnownReasoning(sessionID) {
92
105
  function lastKnownCtx(sessionID) {
93
106
  return lastKnownCtxCache.get(sessionID) || 0;
94
107
  }
108
+ // Provider-reported prompt-cache figures; null when the provider never reported
109
+ // them (a plain "n/a" rather than a misleading 0).
110
+ function lastKnownCacheRead(sessionID) {
111
+ const v = lastKnownCacheReadCache.get(sessionID);
112
+ return typeof v === "number" ? v : null;
113
+ }
114
+ function lastKnownCacheWrite(sessionID) {
115
+ const v = lastKnownCacheWriteCache.get(sessionID);
116
+ return typeof v === "number" ? v : null;
117
+ }
95
118
 
96
119
  // ---------------------------------------------------------------------------
97
120
  // Clear per-session tracking. Full wipe on session.idle / session.deleted;
@@ -118,6 +141,8 @@ function clearSessionTracking(sessionID, keepToastDedup = false) {
118
141
  lastKnownInputCache.delete(sessionID);
119
142
  lastKnownReasoningCache.delete(sessionID);
120
143
  lastKnownCtxCache.delete(sessionID);
144
+ lastKnownCacheReadCache.delete(sessionID);
145
+ lastKnownCacheWriteCache.delete(sessionID);
121
146
  lastBdWrite.delete(sessionID);
122
147
  // V2 subagent accumulation for this session.
123
148
  sessionTotals.delete(sessionID);
@@ -126,6 +151,8 @@ function clearSessionTracking(sessionID, keepToastDedup = false) {
126
151
  v2ContextFetchTimes.delete(sessionID);
127
152
  // Per-session tool-schema TTL cache (see estimateBreakdownFromContext).
128
153
  toolSchemasBySession.delete(sessionID);
154
+ // Compaction-hook marker (re-armed by the next compaction on this session).
155
+ lastCompactionHookAt.delete(sessionID);
129
156
  }
130
157
 
131
158
  export {
@@ -145,9 +172,12 @@ export {
145
172
  lastKnownInputCache,
146
173
  lastKnownReasoningCache,
147
174
  lastKnownCtxCache,
175
+ lastKnownCacheReadCache,
176
+ lastKnownCacheWriteCache,
148
177
  lastBdWrite,
149
178
  lastErrorToast,
150
179
  lastCompactedToast,
180
+ lastCompactionHookAt,
151
181
  sessionParentCache,
152
182
  sessionTotals,
153
183
  sessionAgentCache,
@@ -157,5 +187,7 @@ export {
157
187
  lastKnownInput,
158
188
  lastKnownReasoning,
159
189
  lastKnownCtx,
190
+ lastKnownCacheRead,
191
+ lastKnownCacheWrite,
160
192
  clearSessionTracking,
161
193
  };
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,11 @@ import {
25
29
  lastBdWrite,
26
30
  lastKnownCtx,
27
31
  lastKnownCtxCache,
32
+ lastCompactionHookAt,
33
+ lastKnownCacheRead,
34
+ lastKnownCacheReadCache,
35
+ lastKnownCacheWrite,
36
+ lastKnownCacheWriteCache,
28
37
  lastKnownInput,
29
38
  lastKnownInputCache,
30
39
  lastKnownModel,
@@ -60,6 +69,11 @@ const V2_CONTEXT_FETCH_THROTTLE_MS = 5000; // min gap between fallback fetches
60
69
  // event handlers): cap each at 3s so a slow/hung client cannot pin resources.
61
70
  const V2_FALLBACK_TIMEOUT_MS = 3000;
62
71
 
72
+ // Window within which a `session.compacted` EVENT is considered already handled
73
+ // by the preceding `compaction` HOOK: if the hook marked the entry recently, the
74
+ // event only logs + clears tracking instead of re-persisting the snapshot.
75
+ const COMPACTION_HOOK_FALLBACK_MS = 60000;
76
+
63
77
  // Estimate the per-category breakdown directly from a V2 SessionContext.
64
78
  // `context.messages` are @opencode/ai Message objects:
65
79
  // { role, content: [ {type:"text",text} | {type:"media"} |
@@ -144,6 +158,34 @@ function toolResultChars(p) {
144
158
  return estJson(r.value); // json | error | unexpected shapes
145
159
  }
146
160
 
161
+ // Concatenated text of a ToolResultPart payload, for content FINGERPRINTING
162
+ // (bounded — the first ~4KB is enough to spot byte-identical outputs). Mirrors
163
+ // the shapes handled by toolResultChars; file items are URI references, skipped.
164
+ function toolResultText(p) {
165
+ const r = p?.result;
166
+ if (!r || typeof r !== "object") return "";
167
+ if (r.type === "content" && Array.isArray(r.value)) {
168
+ let out = "";
169
+ for (const item of r.value) {
170
+ if (item?.type === "text" && typeof item.text === "string") {
171
+ out += item.text;
172
+ if (out.length > 4096) break;
173
+ }
174
+ }
175
+ return out;
176
+ }
177
+ if (r.type === "text" && typeof r.value === "string") return r.value;
178
+ try {
179
+ return JSON.stringify(r.value);
180
+ } catch {
181
+ return "";
182
+ }
183
+ }
184
+
185
+ // Bound on tool-call instances tracked per request for waste detection (the
186
+ // message history is untrusted and a runaway session could be huge).
187
+ const MAX_WASTE_INSTANCES = 256;
188
+
147
189
  // Accumulate the per-tool tool-result estimate. Tool names are untrusted
148
190
  // (host tool ids, plugin-defined tools): unsafe names and any name beyond the
149
191
  // cap fold into "other tools" so the bucket never becomes a prototype vector
@@ -174,6 +216,11 @@ function estimateBreakdownFromContext(context, sessionID) {
174
216
  const toolOutByTool = {};
175
217
 
176
218
  const messages = Array.isArray(context?.messages) ? context.messages : [];
219
+ // Waste detection: correlate tool-call (input -> target) with its tool-result
220
+ // (-> tokens + content fingerprint) by call id, over the calls still present
221
+ // in the assembled context.
222
+ const callById = new Map();
223
+ const instances = [];
177
224
  for (const msg of messages) {
178
225
  const parts = Array.isArray(msg?.content) ? msg.content : [];
179
226
  for (const p of parts) {
@@ -184,6 +231,16 @@ function estimateBreakdownFromContext(context, sessionID) {
184
231
  const chars = toolResultChars(p);
185
232
  toolOutChars += chars;
186
233
  addToolOut(toolOutByTool, p?.name, chars);
234
+ if (chars > 0 && instances.length < MAX_WASTE_INSTANCES) {
235
+ const call = p?.id != null ? callById.get(p.id) : null;
236
+ const name = p?.name || call?.name || null;
237
+ instances.push({
238
+ name,
239
+ target: extractToolTarget(name, call?.input),
240
+ tokens: chars,
241
+ fp: contentFingerprint(toolResultText(p)),
242
+ });
243
+ }
187
244
  continue;
188
245
  }
189
246
  if (msg?.role === "user") {
@@ -195,10 +252,15 @@ function estimateBreakdownFromContext(context, sessionID) {
195
252
  reasoningChars += estimateTokens(p.text);
196
253
  } else if (p?.type === "tool-call") {
197
254
  toolArgsChars += estJson(p.input);
255
+ if (p?.id != null && callById.size < MAX_WASTE_INSTANCES && !callById.has(p.id)) {
256
+ callById.set(p.id, { name: p.name, input: p.input });
257
+ }
198
258
  }
199
259
  }
200
260
  }
201
261
  }
262
+ const reclaimRaw = instances.length > 0 ? computeReclaim(instances) : null;
263
+ const reclaim = reclaimRaw && reclaimRaw.tokens > 0 ? reclaimRaw : null;
202
264
 
203
265
  let systemChars = 0;
204
266
  const system = Array.isArray(context?.system) ? context.system : [];
@@ -239,6 +301,7 @@ function estimateBreakdownFromContext(context, sessionID) {
239
301
  toolOutByTool,
240
302
  systemChars,
241
303
  toolSchemasChars,
304
+ reclaim,
242
305
  };
243
306
  }
244
307
 
@@ -272,6 +335,93 @@ function onV2Context(context) {
272
335
  }
273
336
  }
274
337
 
338
+ // V2 compaction hook handler: opencode dispatches `compaction` at the START of a
339
+ // compaction request with the SAME pre-compaction payload as the context hook
340
+ // (system / messages / tools / model). We use it to persist the TRUE
341
+ // pre-compaction breakdown instead of relying on the `session.compacted` event
342
+ // to re-persist whatever a previous request left behind.
343
+ //
344
+ // The entry is marked stale BY CONSTRUCTION (the context is about to be
345
+ // compacted, so the numbers change once the summary lands and the next context
346
+ // hook fires). Guard: the hook also fires for a NO-OP compaction ("Nothing to
347
+ // compact yet") on a session with no completed request — there is no measured
348
+ // context to mark, so we only cache and skip the state write (no fake stale
349
+ // badge).
350
+ function onV2Compaction(context) {
351
+ try {
352
+ if (!context || typeof context !== "object") return;
353
+ const sid = context.sessionID;
354
+ if (!sid) return;
355
+
356
+ const parts = estimateBreakdownFromContext(context, sid);
357
+ breakdownCache.set(sid, {
358
+ ...parts,
359
+ capturedAt: Date.now(),
360
+ source: "compaction-hook",
361
+ });
362
+ pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
363
+
364
+ const model = context.model;
365
+ if (model && typeof model === "object") {
366
+ if (typeof model.id === "string") lastKnownModelCache.set(sid, model.id);
367
+ if (typeof model.providerID === "string") {
368
+ lastKnownProviderCache.set(sid, model.providerID);
369
+ }
370
+ pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
371
+ pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
372
+ }
373
+
374
+ // Arm the fallback guard for the trailing `session.compacted` event.
375
+ lastCompactionHookAt.set(sid, Date.now());
376
+ pruneMap(lastCompactionHookAt, MAX_TRACKED_SESSIONS);
377
+
378
+ const ctxTokens = lastKnownCtx(sid);
379
+ if (ctxTokens > 0) {
380
+ const lines = buildLiveLinesV2(
381
+ sid,
382
+ lastKnownModel(sid),
383
+ lastKnownProvider(sid),
384
+ lastKnownInput(sid),
385
+ ctxTokens,
386
+ lastKnownReasoning(sid),
387
+ );
388
+ writeStateFile(
389
+ sid,
390
+ lastKnownModel(sid),
391
+ lastKnownProvider(sid),
392
+ lastKnownInput(sid),
393
+ ctxTokens,
394
+ lastKnownReasoning(sid),
395
+ {
396
+ compacted: true,
397
+ cacheRead: lastKnownCacheRead(sid),
398
+ cacheWrite: lastKnownCacheWrite(sid),
399
+ },
400
+ );
401
+ // Claim key derived from the PAYLOAD shape (message count + total
402
+ // estimated chars): identical across plugin instances for the SAME
403
+ // compaction (cross-instance dedup preserved) yet distinct per compaction
404
+ // — a session-only key would log just the first compaction on a session.
405
+ const messages = Array.isArray(context.messages) ? context.messages : [];
406
+ const sig =
407
+ `${messages.length}:` +
408
+ (parts.userChars +
409
+ parts.assistantChars +
410
+ parts.reasoningChars +
411
+ parts.toolArgsChars +
412
+ parts.toolOutChars +
413
+ (parts.systemChars || 0) +
414
+ (parts.toolSchemasChars || 0));
415
+ pushFinalSummary(`compaction-hook:${sid}:${sig}`, [
416
+ `[${new Date().toISOString()}] COMPACTION (hook) session=${sid} — pre-compaction snapshot captured`,
417
+ ...lines.slice(1),
418
+ ]);
419
+ }
420
+ } catch (err) {
421
+ logErr("v2 compaction hook failed", err);
422
+ }
423
+ }
424
+
275
425
  // ---------------------------------------------------------------------------
276
426
  // V2 fallback: derive the message-based categories from the session's persisted
277
427
  // message list when the context hook did NOT populate the cache for this
@@ -291,6 +441,7 @@ function estimateBreakdownFromClientMessages(messages) {
291
441
  let toolArgsChars = 0;
292
442
  let toolOutChars = 0;
293
443
  const toolOutByTool = {};
444
+ const instances = [];
294
445
  for (const m of messages || []) {
295
446
  if (!m || typeof m !== "object") continue;
296
447
  if (m.type === "user") {
@@ -315,20 +466,32 @@ function estimateBreakdownFromClientMessages(messages) {
315
466
  const content = p?.state?.content;
316
467
  if (Array.isArray(content)) {
317
468
  let outChars = 0;
469
+ let text = "";
318
470
  for (const item of content) {
319
471
  if (item?.type === "text" && typeof item.text === "string") {
320
472
  outChars += estimateTokens(item.text);
473
+ if (text.length <= 4096) text += item.text;
321
474
  }
322
475
  }
323
476
  toolOutChars += outChars;
324
477
  // The persisted tool part carries its name on `p.name`
325
478
  // (SessionMessageAssistantTool.name) — same field as the hook path.
326
479
  addToolOut(toolOutByTool, p?.name, outChars);
480
+ if (outChars > 0 && instances.length < MAX_WASTE_INSTANCES) {
481
+ instances.push({
482
+ name: p?.name || null,
483
+ target: extractToolTarget(p?.name, input),
484
+ tokens: outChars,
485
+ fp: contentFingerprint(text),
486
+ });
487
+ }
327
488
  }
328
489
  }
329
490
  }
330
491
  }
331
492
  }
493
+ const reclaimRaw = instances.length > 0 ? computeReclaim(instances) : null;
494
+ const reclaim = reclaimRaw && reclaimRaw.tokens > 0 ? reclaimRaw : null;
332
495
  return {
333
496
  userChars,
334
497
  assistantChars,
@@ -338,6 +501,7 @@ function estimateBreakdownFromClientMessages(messages) {
338
501
  toolOutByTool,
339
502
  systemChars: null,
340
503
  toolSchemasChars: null,
504
+ reclaim,
341
505
  };
342
506
  }
343
507
 
@@ -496,6 +660,25 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
496
660
  lines.push(` other ${fmt(other)} (= input - sum of estimates)`);
497
661
  lines.push(` source ${source}`);
498
662
 
663
+ // Provider-reported prompt cache (EXACT, not an estimate; a subset of input —
664
+ // never folded into the category sum). Absent when the provider stays silent.
665
+ const cr = lastKnownCacheRead(sessionID);
666
+ const cw = lastKnownCacheWrite(sessionID);
667
+ lines.push(
668
+ cr == null && cw == null
669
+ ? ` prompt cache n/a (provider reported no cache tokens)`
670
+ : ` prompt cache read=${fmt(cr || 0)} write=${fmt(cw || 0)} (provider-reported; subset of input)`,
671
+ );
672
+
673
+ // Redundant tool output already in context (repeated target / identical
674
+ // output). An estimate of what trimming/compaction could reclaim.
675
+ const rcl = bd?.reclaim || null;
676
+ if (rcl && rcl.tokens > 0) {
677
+ lines.push(
678
+ ` reclaim ~${fmt(rcl.tokens)} redundant tool output (${rcl.count} duplicate(s) — estimate)`,
679
+ );
680
+ }
681
+
499
682
  try {
500
683
  const sub = aggregateSubagentsV2(sessionID);
501
684
  if (sub.count > 0) {
@@ -595,19 +778,30 @@ async function handleV2Event(ctx, event) {
595
778
  lastKnownCtx(sid),
596
779
  lastKnownReasoning(sid),
597
780
  );
598
- writeStateFile(
599
- sid,
600
- lastKnownModel(sid),
601
- lastKnownProvider(sid),
602
- lastKnownInput(sid),
603
- lastKnownCtx(sid),
604
- lastKnownReasoning(sid),
605
- // This write re-persists the PRE-compaction snapshot (the compaction
606
- // flow fires no context hook — see README): mark the entry stale so the
607
- // UI shows the numbers as pre-compaction until the next model request
608
- // refreshes them.
609
- { compacted: true },
610
- );
781
+ // The `compaction` HOOK already captured + persisted the exact
782
+ // pre-compaction snapshot and marked the entry stale. This event is now a
783
+ // FALLBACK: it re-persists only when the hook did NOT fire recently (host
784
+ // gap, mirror instance, or a compaction path that bypassed the hook).
785
+ // Either way the write re-persists the PRE-compaction numbers and marks
786
+ // the entry stale, so the UI shows them as pre-compaction until the next
787
+ // model request refreshes them.
788
+ const hookAt = lastCompactionHookAt.get(sid) || 0;
789
+ const handledByHook = Date.now() - hookAt < COMPACTION_HOOK_FALLBACK_MS;
790
+ if (!handledByHook) {
791
+ writeStateFile(
792
+ sid,
793
+ lastKnownModel(sid),
794
+ lastKnownProvider(sid),
795
+ lastKnownInput(sid),
796
+ lastKnownCtx(sid),
797
+ lastKnownReasoning(sid),
798
+ {
799
+ compacted: true,
800
+ cacheRead: lastKnownCacheRead(sid),
801
+ cacheWrite: lastKnownCacheWrite(sid),
802
+ },
803
+ );
804
+ }
611
805
  pushFinalSummary(
612
806
  `compacted:${event.id ?? sid}`,
613
807
  [
@@ -646,6 +840,10 @@ async function handleV2Event(ctx, event) {
646
840
  lastKnownInput(sid),
647
841
  lastKnownCtx(sid),
648
842
  lastKnownReasoning(sid),
843
+ {
844
+ cacheRead: lastKnownCacheRead(sid),
845
+ cacheWrite: lastKnownCacheWrite(sid),
846
+ },
649
847
  );
650
848
  pushFinalSummary(`final:${event.id ?? sid}`, [
651
849
  `[${new Date().toISOString()}] FINAL session=${sid}`,
@@ -668,10 +866,16 @@ async function handleV2Event(ctx, event) {
668
866
  const output = typeof t.output === "number" ? t.output : 0;
669
867
  const reasoning = typeof t.reasoning === "number" ? t.reasoning : 0;
670
868
  const contextTokens = tokenCountOf(t);
869
+ // Provider-reported prompt-cache figures (exact): a subset of `input`.
870
+ // Sanitised — a NaN/negative/absurd provider value must never be persisted.
871
+ const cacheRead = safeSaneOrNull(t.cache?.read ?? t.cacheRead);
872
+ const cacheWrite = safeSaneOrNull(t.cache?.write ?? t.cacheWrite);
671
873
 
672
874
  lastKnownInputCache.set(sid, input);
673
875
  lastKnownCtxCache.set(sid, contextTokens);
674
876
  lastKnownReasoningCache.set(sid, reasoning);
877
+ if (cacheRead != null) lastKnownCacheReadCache.set(sid, cacheRead);
878
+ if (cacheWrite != null) lastKnownCacheWriteCache.set(sid, cacheWrite);
675
879
  if (!lastKnownModelCache.has(sid) && data.model) {
676
880
  // Some builds put the model on the step payload; harmless when absent.
677
881
  if (typeof data.model.id === "string") lastKnownModelCache.set(sid, data.model.id);
@@ -682,6 +886,8 @@ async function handleV2Event(ctx, event) {
682
886
  pruneMap(lastKnownInputCache, MAX_TRACKED_SESSIONS);
683
887
  pruneMap(lastKnownCtxCache, MAX_TRACKED_SESSIONS);
684
888
  pruneMap(lastKnownReasoningCache, MAX_TRACKED_SESSIONS);
889
+ pruneMap(lastKnownCacheReadCache, MAX_TRACKED_SESSIONS);
890
+ pruneMap(lastKnownCacheWriteCache, MAX_TRACKED_SESSIONS);
685
891
  pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
686
892
  pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
687
893
 
@@ -740,6 +946,10 @@ async function handleV2Event(ctx, event) {
740
946
  input,
741
947
  contextTokens,
742
948
  reasoning,
949
+ {
950
+ cacheRead: lastKnownCacheRead(sid),
951
+ cacheWrite: lastKnownCacheWrite(sid),
952
+ },
743
953
  );
744
954
  writeLog(lines);
745
955
  } catch (err) {
@@ -776,6 +986,7 @@ async function handleV2Event(ctx, event) {
776
986
 
777
987
  export {
778
988
  onV2Context,
989
+ onV2Compaction,
779
990
  handleV2Event,
780
991
  estimateBreakdownFromContext,
781
992
  estimateBreakdownFromClientMessages,
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.1",
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.32",
68
68
  "@opentui/core": "0.5.17",
69
69
  "@types/node": "26.6.3",
70
70
  "esbuild": "^0.28.2",