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 +42 -0
- package/README.md +36 -11
- package/index.js +28 -3
- package/lib/cache.js +32 -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 +224 -13
- package/package.json +2 -2
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
|
|
512
|
-
to capture the final messages / system
|
|
513
|
-
|
|
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
|
|
532
|
-
|
|
533
|
-
the
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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–
|
|
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–
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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.
|
|
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.
|
|
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",
|