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 +28 -0
- package/README.md +23 -2
- package/index.js +9 -2
- package/lib/cache.js +22 -0
- package/lib/commands.js +70 -0
- package/lib/estimate.js +221 -0
- package/lib/state.js +22 -0
- package/lib/tui-data.d.ts +14 -0
- package/lib/tui-panel.tsx +23 -0
- package/lib/v2.js +111 -1
- package/package.json +2 -2
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–
|
|
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–
|
|
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
|
-
{
|
|
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.
|
|
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.
|
|
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",
|