opencode-context-indicator 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,61 @@ 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
+
35
+ ## 1.4.1 — 2026-10-14
36
+
37
+ Highlights: the `toolOut` category is now split **by tool name**, and a
38
+ `(stale — compacted)` marker tells you when the numbers predate a compaction.
39
+
40
+ - **Tool results by tool**: `/context-breakdown` (a `### Tool results by tool
41
+ (main)` section) and the `./cx` panel (`tool results by tool` line) split the
42
+ `toolOut` column by tool name (`webfetch`, `read`, `bash`, …) — top-N with a
43
+ `+N more` tail. Aggregated on both the hook and the fallback path from each
44
+ tool part's `name`; names are sanitised and the map is bounded (overflow folds
45
+ into `other tools`). Persisted as `toolOutByTool` in `state.json`.
46
+ - **Compaction staleness marker**: opencode's compaction fires no `context`
47
+ hook, so `/context` right after `/compact` used to show stale pre-compaction
48
+ numbers with no hint. Such an entry is now flagged (`compactStale`) and every
49
+ surface (sidebar, panel, `/context`, `/context-breakdown`) shows
50
+ `(stale — compacted)` until the next model request refreshes the breakdown.
51
+ - Legacy `state.json` entries (no new fields) behave exactly as before.
52
+ - Tests: T19/T19a (staleness mark lifecycle + surfaces), T20–T20c (per-tool
53
+ split on both paths, sanitisation/bounding, persistence + rendering).
54
+ - **Refactor (behaviour-preserving)**: extracted the shared category helpers
55
+ (`sumCategories` / `breakdownCategories`) into `lib/estimate.js`, removing
56
+ four hand-rolled copies of the residual-`other` computation (V2 live log,
57
+ state writer, command report, TUI panel); the TUI now imports the ONE token
58
+ estimator and per-tool split from `lib/estimate.js` instead of keeping byte
59
+ copies; dropped unused module exports; unified the per-tool map cap. New
60
+ guard test T21.
61
+
7
62
  ## 1.4.0 — 2026-10-13
8
63
 
9
64
  Highlights: a new **toolOut** category names what used to drown in `other` —
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
 
@@ -49,12 +51,25 @@ For every served request the plugin estimates how the context window is split:
49
51
  * `other` is the residual (`input − sum of estimates`): what no category
50
52
  explains — images/media, unknown part types.
51
53
  * `toolOut` names the former blind spot: **tool results** fed back into the
52
- context (webfetch pages, file reads, subagent reports). The TUI panel's
53
- *largest contributors* section attributes them **per tool call**; the
54
- category only totals them.
54
+ context (webfetch pages, file reads, subagent reports). `/context-breakdown`
55
+ and the TUI panel split it **by tool name** (`webfetch`, `read`, `bash`, …);
56
+ the TUI panel's *largest contributors* section attributes them **per tool
57
+ call**; the category only totals them.
55
58
  * `ctx` is OpenCode's native overflow count: `tokens.total` when present, else
56
59
  `input + output + cache.read + cache.write`. Percentages are **not clamped**
57
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**.
58
73
  * The denominator is the **opencode v2.0.23 compaction ceiling**: `window =
59
74
  limit.input || limit.context`; `reserve = max(floor(window * 0.1), window >=
60
75
  32_000 ? 16_000 : 0)`; the ceiling is `window - reserve`. When opencode
@@ -160,6 +175,16 @@ reads, subagent reports fed back into context), sys=system prompt, schm=tool
160
175
  schemas, oth=residual input. src: live=context hook, snap=state.json,
161
176
  fallback=session.context (no system/schemas → n/a), no-data=categories
162
177
  unavailable._
178
+
179
+ ### Tool results by tool (main)
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._
163
188
  ```
164
189
 
165
190
  It is delivered with `resume:true` **and** a verbatim instruction prefix, so the
@@ -524,6 +549,13 @@ duplicate final summaries are suppressed with an atomic claim marker
524
549
  (`lib/dedup.js`). Every file / estimate / subagent path is fault-tolerant: a
525
550
  failure is logged and never breaks the main event or toast path.
526
551
 
552
+ **Compaction staleness.** OpenCode's compaction runs a separate flow that fires
553
+ no `context` hook, so the numbers a `session.compacted` write re-persists are
554
+ the *pre-compaction* ones. The plugin flags such an entry
555
+ (`compactStale` in `state.json`) and every surface shows `(stale — compacted)`
556
+ until the next model request refreshes the breakdown — i.e. the data is exactly
557
+ as fresh as the last model request in the session.
558
+
527
559
  This plugin does not touch `opencode-token-monitor`
528
560
  (`token_stats` / `token_history` / `token_export` keep working unchanged).
529
561
 
@@ -542,7 +574,7 @@ caller.
542
574
  ### Running tests
543
575
 
544
576
  ```bash
545
- npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T16i unit checks
577
+ npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T24 unit checks
546
578
  npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
547
579
  npm run test:e2e:quick # E2E load-only, no LLM call
548
580
  npm run test:drift # OpenTUI pin vs the latest RELEASED opencode (also in CI)
@@ -563,7 +595,7 @@ STEP 80 s, ~3 min total.
563
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 |
564
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 |
565
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 |
566
- | (d) unit harness T1–T16i | limit/agent/state fix assertions plus measureContributors and redacted-export checks (34 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 |
567
599
  | (e) corporate-identifier scan | no forbidden identifiers in the published surface + test tree | compliance |
568
600
  | (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in any repo file | English-only source rule |
569
601
 
package/index.js CHANGED
@@ -151,12 +151,14 @@
151
151
 
152
152
  import { makeV1 } from "./lib/v1.js";
153
153
  import { handleV2Event, onV2Context, estimateBreakdownFromContext, estimateBreakdownFromClientMessages } from "./lib/v2.js";
154
+ import { breakdownCategories, computeReclaim, contentFingerprint, estimateTokens, extractToolTarget, isSafeSessionKey, safeCell, safeReclaim, safeSaneOrNull, safeToolOutByTool, sumCategories, toolOutEntries } from "./lib/estimate.js";
154
155
  import {
155
156
  commandContextBreakdown,
156
157
  commandContextSummary,
157
158
  CONTEXT_BREAKDOWN_RESUME,
158
159
  collectSessionReport,
159
160
  renderSessionRow,
161
+ renderBreakdownTable,
160
162
  shortSummaryText,
161
163
  shortSummaryDescription,
162
164
  } from "./lib/commands.js";
@@ -168,7 +170,7 @@ import {
168
170
  modelLimits,
169
171
  reserveFor,
170
172
  } from "./lib/limits.js";
171
- import { sessionAgentCache, sessionParentCache } from "./lib/cache.js";
173
+ import { lastKnownCacheRead, lastKnownCacheWrite, sessionAgentCache, sessionParentCache } from "./lib/cache.js";
172
174
  import {
173
175
  logErr,
174
176
  readStateSnapshot,
@@ -194,6 +196,7 @@ export {
194
196
  reserveFor,
195
197
  collectSessionReport,
196
198
  renderSessionRow,
199
+ renderBreakdownTable,
197
200
  shortSummaryText,
198
201
  shortSummaryDescription,
199
202
  ensureModelLimits,
@@ -201,6 +204,20 @@ export {
201
204
  estimateBreakdownFromContext,
202
205
  estimateBreakdownFromClientMessages,
203
206
  onV2Context,
207
+ safeToolOutByTool,
208
+ toolOutEntries,
209
+ sumCategories,
210
+ breakdownCategories,
211
+ safeCell,
212
+ isSafeSessionKey,
213
+ estimateTokens,
214
+ extractToolTarget,
215
+ contentFingerprint,
216
+ computeReclaim,
217
+ safeReclaim,
218
+ safeSaneOrNull,
219
+ lastKnownCacheRead,
220
+ lastKnownCacheWrite,
204
221
  };
205
222
 
206
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
@@ -6,16 +6,25 @@
6
6
  // from index.js.
7
7
 
8
8
  import {
9
+ breakdownCategories,
9
10
  fmt,
10
11
  fmtCell,
11
12
  isSafeSessionKey,
12
13
  oneLine,
13
14
  safeCategories,
14
15
  safeCell,
16
+ safeCount,
17
+ safeReclaim,
18
+ safeSaneOrNull,
19
+ safeToolOutByTool,
20
+ sumCategories,
21
+ toolOutEntries,
15
22
  withTimeout,
16
23
  } from "./estimate.js";
17
24
  import {
18
25
  breakdownCache,
26
+ lastKnownCacheRead,
27
+ lastKnownCacheWrite,
19
28
  lastKnownCtx,
20
29
  lastKnownInput,
21
30
  lastKnownModel,
@@ -73,16 +82,7 @@ const CONTEXT_BREAKDOWN_RESUME = true;
73
82
  // Note: `system` / `toolSchemas` may legitimately be null (hook-only), so they
74
83
  // are coerced with `|| 0`.
75
84
  function isEmptyStateEntry(entry) {
76
- const c = entry?.categories || {};
77
- const sum =
78
- (c.user || 0) +
79
- (c.assistant || 0) +
80
- (c.reasoning || 0) +
81
- (c.toolArgs || 0) +
82
- (c.toolOut || 0) +
83
- (c.system || 0) +
84
- (c.toolSchemas || 0) +
85
- (c.other || 0);
85
+ const sum = sumCategories(entry?.categories);
86
86
  return (
87
87
  sum === 0 &&
88
88
  (!entry?.ctx || entry.ctx === 0) &&
@@ -135,21 +135,8 @@ function collectDescendantSessionIDs(rootID, state) {
135
135
  }
136
136
 
137
137
  // Normalise a breakdown record (cache or state entry) into the 8 categories.
138
- // `inputTokens` is used only for the residual "other".
139
- function breakdownCategories(rec, inputTokens) {
140
- const user = rec?.userChars ?? rec?.user ?? 0;
141
- const assistant = rec?.assistantChars ?? rec?.assistant ?? 0;
142
- const reasoning = rec?.reasoningChars ?? rec?.reasoning ?? 0;
143
- const toolArgs = rec?.toolArgsChars ?? rec?.toolArgs ?? 0;
144
- const toolOut = rec?.toolOutChars ?? rec?.toolOut ?? 0;
145
- const system = rec?.systemChars !== undefined ? rec.systemChars : rec?.system ?? null;
146
- const toolSchemas =
147
- rec?.toolSchemasChars !== undefined ? rec.toolSchemasChars : rec?.toolSchemas ?? null;
148
- const sum =
149
- user + assistant + reasoning + toolArgs + toolOut + (system || 0) + (toolSchemas || 0);
150
- const other = Math.max(0, (inputTokens || 0) - sum);
151
- return { user, assistant, reasoning, toolArgs, toolOut, system, toolSchemas, other };
152
- }
138
+ // Moved to lib/estimate.js (shared with the V2 live log); re-used here so the
139
+ // residual computation has exactly one implementation.
153
140
 
154
141
  // Short source tag for the table (kept tiny on purpose).
155
142
  function sourceTag(source) {
@@ -238,6 +225,7 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
238
225
 
239
226
  let categories = null;
240
227
  let source = "none";
228
+ let fallbackParts = null;
241
229
  if (bd) {
242
230
  // Sanitise: every value reaches a markdown/log sink and must be a number.
243
231
  categories = safeCategories(
@@ -249,9 +237,8 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
249
237
  categories = safeCategories(entry.categories);
250
238
  source = "snapshot";
251
239
  } else if (Array.isArray(contextMsgs)) {
252
- categories = safeCategories(
253
- breakdownCategories(estimateBreakdownFromClientMessages(contextMsgs), 0),
254
- );
240
+ fallbackParts = estimateBreakdownFromClientMessages(contextMsgs);
241
+ categories = safeCategories(breakdownCategories(fallbackParts, 0));
255
242
  source = "client-context";
256
243
  } else if (needFallback) {
257
244
  // No live cache, no state.json categories, and the bounded session.context
@@ -284,6 +271,24 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
284
271
  const updatedAt =
285
272
  entry?.updatedAt ||
286
273
  (bd?.capturedAt ? new Date(bd.capturedAt).toISOString() : null);
274
+ const toolOutByTool = bd
275
+ ? safeToolOutByTool(bd.toolOutByTool)
276
+ : safeToolOutByTool(entry?.toolOutByTool) ||
277
+ safeToolOutByTool(fallbackParts?.toolOutByTool);
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);
287
292
  return {
288
293
  sessionID,
289
294
  parentID,
@@ -291,9 +296,15 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
291
296
  modelID,
292
297
  providerID,
293
298
  ctxTokens,
299
+ inputTokens: lastKnownInput(sessionID) || entry?.input || 0,
294
300
  usable,
295
301
  reserve,
296
302
  categories,
303
+ toolOutByTool,
304
+ reclaim,
305
+ cacheRead,
306
+ cacheWrite,
307
+ compactStale,
297
308
  source,
298
309
  updatedAt,
299
310
  };
@@ -311,11 +322,62 @@ function renderSessionRow(r, isMain) {
311
322
  ? ` (${((r.ctxTokens / r.usable) * 100).toFixed(0)}%)`
312
323
  : "";
313
324
  const ctx = `${fmt(r.ctxTokens)}${pct}`;
314
- const upd = `${timeOnly(r.updatedAt)} ${sourceTag(r.source)}`;
325
+ const upd = `${timeOnly(r.updatedAt)} ${sourceTag(r.source)}${r.compactStale ? " (stale)" : ""}`;
315
326
  return `| ${role} | ${safeCell(r.modelID)} | ${ctx} | ${fmtCell(c.user)} | ${fmtCell(c.assistant)} | ${fmtCell(c.reasoning)} | ${fmtCell(c.toolArgs)} | ${fmtCell(c.toolOut)} | ${fmtCell(c.system)} | ${fmtCell(c.toolSchemas)} | ${fmtCell(c.other)} | ${upd} |`;
316
327
  }
317
328
 
318
- function renderBreakdownTable(rows, childCount) {
329
+ // A compact one-line "tool results by tool" split for the main session, built
330
+ // from the server-side aggregate (sanitised names, top-N). Empty when the map
331
+ // carries nothing.
332
+ function renderToolOutLine(toolOutByTool, limit) {
333
+ const entries = toolOutEntries(toolOutByTool, limit || 8);
334
+ if (entries.length === 0) return "";
335
+ const parts = entries.map(
336
+ ([name, val]) => `${safeCell(name)} ${fmt(val)}`,
337
+ );
338
+ const total = Object.values(toolOutByTool || {}).filter(
339
+ (v) => safeCount(v) > 0,
340
+ ).length;
341
+ if (total > entries.length) parts.push(`+${total - entries.length} more`);
342
+ return parts.join(" · ");
343
+ }
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
+
380
+ function renderBreakdownTable(rows, childCount, mainReport) {
319
381
  const lines = [];
320
382
  lines.push("### Context breakdown");
321
383
  lines.push("");
@@ -332,6 +394,24 @@ function renderBreakdownTable(rows, childCount) {
332
394
  `schm=tool schemas, oth=residual input. src: live=context hook, snap=state.json, ` +
333
395
  `fallback=session.context (no system/schemas → n/a), no-data=categories unavailable._`,
334
396
  );
397
+ // Per-tool split of the main session's tool results (which tool ate the
398
+ // "out" column). Server-side aggregate; names sanitised, top-N only.
399
+ const byTool = renderToolOutLine(mainReport?.toolOutByTool, 8);
400
+ if (byTool) {
401
+ lines.push("");
402
+ lines.push("### Tool results by tool (main)");
403
+ lines.push(byTool);
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);
335
415
  return lines.join("\n");
336
416
  }
337
417
 
@@ -402,7 +482,16 @@ function shortSummaryText(r) {
402
482
  }
403
483
  lines.push(compact);
404
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}`);
405
489
  lines.push(`updated ${timeOnly(r.updatedAt)} (${sourceTag(r.source)})`);
490
+ if (r.compactStale) {
491
+ lines.push(
492
+ "stale — compacted: these are pre-compaction numbers; they refresh after the next message",
493
+ );
494
+ }
406
495
  return lines.join("\n");
407
496
  }
408
497
 
@@ -484,7 +573,7 @@ async function commandContextBreakdown(ctx, invocation, opts) {
484
573
  ]);
485
574
  const rows = [renderSessionRow(root, true)];
486
575
  for (const rep of children) rows.push(renderSessionRow(rep, false));
487
- const table = renderBreakdownTable(rows, childIDs.length);
576
+ const table = renderBreakdownTable(rows, childIDs.length, root);
488
577
  const payload = resume
489
578
  ? `${BREAKDOWN_MODEL_INSTRUCTION}\n\n<context-breakdown>\n${table}\n</context-breakdown>`
490
579
  : table;
@@ -510,6 +599,7 @@ export {
510
599
  CONTEXT_BREAKDOWN_RESUME,
511
600
  collectSessionReport,
512
601
  renderSessionRow,
602
+ renderBreakdownTable,
513
603
  shortSummaryText,
514
604
  shortSummaryDescription,
515
605
  };
@@ -3,48 +3,19 @@
3
3
  // Pure contributor-measurement helpers extracted from tui.tsx for unit testing.
4
4
  // All functions are stateless transforms of their arguments.
5
5
  //
6
- // Extracted 1:1 (behaviour + WHY comments, stripped of TS syntax):
7
- // estimateTokensLocal, asRecord, safeText, measureContributors,
8
- // contributorLine, exportFileName, buildRedactedPayload, panelDenom, panelRole.
6
+ // Functions: asRecord, safeText, measureContributors, contributorLine,
7
+ // exportFileName, buildRedactedPayload, panelDenom, panelRole. Token estimation
8
+ // is imported from lib/estimate.js (single source of truth).
9
+
10
+ import { estimateTokens } from "./estimate.js";
9
11
 
10
12
  // ---------------------------------------------------------------------------
11
- // Token estimation (mirrors lib/estimate.js but self-contained — tui.tsx
12
- // loads standalone and must NOT import lib/*).
13
+ // Token estimation — the SHARED lib/estimate.js heuristic (the TUI panel and
14
+ // the server must price the same text identically; the earlier self-contained
15
+ // copy had drifted risk). estimate.js is pure and dependency-free, so it loads
16
+ // in the TUI host exactly like the rest of lib/.
13
17
  // ---------------------------------------------------------------------------
14
18
 
15
- // Unicode heuristic — estimate, not a tokenizer. Cyrillic ~2.5, CJK ~1.5,
16
- // latin/ASCII ~4 chars/token.
17
- function estimateTokensLocal(s) {
18
- let other = 0;
19
- let cyr = 0;
20
- let cjk = 0;
21
- for (let i = 0; i < s.length; i++) {
22
- const cp = s.codePointAt(i);
23
- if (cp > 0xffff) i++; // astral pair — consume the low surrogate
24
- if (cp < 0x80) other++;
25
- else if (
26
- (cp >= 0x0400 && cp <= 0x04ff) || // Cyrillic
27
- (cp >= 0x0500 && cp <= 0x052f) || // Cyrillic Supplement
28
- (cp >= 0x2de0 && cp <= 0x2dff) || // Cyrillic Extended-A
29
- (cp >= 0xa640 && cp <= 0xa69f) || // Cyrillic Extended-B
30
- (cp >= 0x1c80 && cp <= 0x1c8f) // Cyrillic Extended-C
31
- )
32
- cyr++;
33
- else if (
34
- (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
35
- (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
36
- (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
37
- (cp >= 0x3040 && cp <= 0x30ff) || // Hiragana + Katakana
38
- (cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
39
- (cp >= 0x3000 && cp <= 0x303f) || // CJK Symbols and Punctuation
40
- (cp >= 0xff00 && cp <= 0xffef) // Halfwidth/Fullwidth Forms
41
- )
42
- cjk++;
43
- else other++;
44
- }
45
- return Math.round(other / 4 + cyr / 2.5 + cjk / 1.5);
46
- }
47
-
48
19
  // Live message data is untrusted: coerce every field defensively, never throw.
49
20
  function asRecord(v) {
50
21
  return v && typeof v === "object" ? v : null;
@@ -96,7 +67,7 @@ function measureContributors(messages) {
96
67
  kind,
97
68
  ref,
98
69
  chars: sample.length,
99
- tokensEstimate: estimateTokensLocal(sample),
70
+ tokensEstimate: estimateTokens(sample),
100
71
  tool: tool || "",
101
72
  preview: previewOverride !== undefined ? previewOverride : previewOf(sample),
102
73
  });
@@ -276,8 +247,6 @@ function panelRole(e, isMain) {
276
247
  }
277
248
 
278
249
  export {
279
- estimateTokensLocal,
280
- asRecord,
281
250
  safeText,
282
251
  measureContributors,
283
252
  contributorLine,
package/lib/dedup.js CHANGED
@@ -17,9 +17,9 @@
17
17
  //
18
18
  // EXPORTS
19
19
  // claimOnce(key, dedupDir) atomic cross-instance / cross-process claim
20
- // pruneDedup(dedupDir) delete claim markers older than DEDUP_TTL_MS
21
20
  // pruneMap(map, maxSize) FIFO-trim an in-process Map (insertion order)
22
- // DEDUP_TTL_MS marker lifetime (24h)
21
+ // (pruneDedup / DEDUP_TTL_MS are module-internal: pruning is driven from
22
+ // claimOnce, never from outside this file)
23
23
  //
24
24
  // The claim mechanism is the exact one proven in token-usage-logger.js:
25
25
  // OpenCode loads one plugin instance per loaded location and mirrors the public
@@ -40,7 +40,7 @@ import {
40
40
  import { createHash } from "node:crypto";
41
41
  import { join } from "node:path";
42
42
 
43
- export const DEDUP_TTL_MS = 24 * 60 * 60 * 1000; // prune claim markers after a day
43
+ const DEDUP_TTL_MS = 24 * 60 * 60 * 1000; // prune claim markers after a day
44
44
  // Time-throttled pruning. A claim directory can hold tens of thousands of
45
45
  // markers (mostly legacy per-delta markers), and pruneDedup() does a
46
46
  // synchronous readdirSync+statSync over the whole directory — running that in
@@ -103,7 +103,7 @@ export function claimOnce(key, dedupDir) {
103
103
  }
104
104
 
105
105
  // Delete claim markers older than DEDUP_TTL_MS. Never throws.
106
- export function pruneDedup(dedupDir) {
106
+ function pruneDedup(dedupDir) {
107
107
  try {
108
108
  const now = Date.now();
109
109
  for (const f of readdirSync(dedupDir)) {