opencode-context-indicator 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/README.md +37 -5
- package/index.js +18 -1
- package/lib/cache.js +22 -0
- package/lib/commands.js +121 -31
- package/lib/contributors.js +10 -41
- package/lib/dedup.js +4 -4
- package/lib/estimate.js +313 -4
- package/lib/limits.js +0 -1
- package/lib/state.js +79 -29
- package/lib/tui-data.d.ts +24 -0
- package/lib/tui-data.js +13 -26
- package/lib/tui-panel.tsx +53 -7
- package/lib/tui-sidebar.tsx +3 -0
- package/lib/v2.js +169 -21
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,61 @@ Notable user-facing changes per release. Dates are commit days (UTC).
|
|
|
4
4
|
The package also ships a dual V1/V2 plugin — see the README for the surface
|
|
5
5
|
matrix; everything below concerns the V2 path unless stated otherwise.
|
|
6
6
|
|
|
7
|
+
## 1.5.0 — 2026-10-16
|
|
8
|
+
|
|
9
|
+
Highlights: two new context-health signals — the **provider-reported prompt
|
|
10
|
+
cache** (exact) and a **waste detector** for redundant tool output already in
|
|
11
|
+
the context.
|
|
12
|
+
|
|
13
|
+
- **Prompt cache (provider-reported)**: `session.step.ended` carries
|
|
14
|
+
`tokens.cache.{read,write}` — the provider's EXACT prompt-cache figures. Shown
|
|
15
|
+
as `prompt cache read=X (Y% of input) · write=Z` in `/context-breakdown`
|
|
16
|
+
(a `### Prompt cache` section), `/context`, the `./cx` panel and the live log.
|
|
17
|
+
It is a **subset of `input`**, so it is deliberately **not** added to the
|
|
18
|
+
category sum (no double-count). `n/a` when the provider reports nothing;
|
|
19
|
+
persisted as `cacheRead`/`cacheWrite` and inherited by mirror writes (a
|
|
20
|
+
non-measuring write cannot drop them).
|
|
21
|
+
- **Waste detector**: finds tool output already in the context that is redundant
|
|
22
|
+
— the **same target** invoked repeatedly (the same file read twice, the same
|
|
23
|
+
URL fetched again) or **byte-identical output**. Reported as a `### Potential
|
|
24
|
+
reclaim (redundant tool output)` section in `/context-breakdown` (top findings
|
|
25
|
+
with tool + target) plus a `reclaim ~X redundant tool output (N dup)` panel/log
|
|
26
|
+
line. The number is the non-largest member of each duplicate group — an honest
|
|
27
|
+
*"N copies are in context; trimming/compaction could reclaim ~X"*, never "the
|
|
28
|
+
model wasted effort". Findings are **path-free** in the persisted/exported
|
|
29
|
+
form; the estimate is computed on both the hook and the fallback path.
|
|
30
|
+
- Tests: T23/T23a/T23b (cache persistence, surfaces, sanitisation), T24–T24c
|
|
31
|
+
(reclaim algorithm, target extraction, hook capture via call-id correlation,
|
|
32
|
+
path-free persistence + rendering).
|
|
33
|
+
- Legacy `state.json` entries (without the new fields) behave exactly as before.
|
|
34
|
+
|
|
35
|
+
## 1.4.1 — 2026-10-14
|
|
36
|
+
|
|
37
|
+
Highlights: the `toolOut` category is now split **by tool name**, and a
|
|
38
|
+
`(stale — compacted)` marker tells you when the numbers predate a compaction.
|
|
39
|
+
|
|
40
|
+
- **Tool results by tool**: `/context-breakdown` (a `### Tool results by tool
|
|
41
|
+
(main)` section) and the `./cx` panel (`tool results by tool` line) split the
|
|
42
|
+
`toolOut` column by tool name (`webfetch`, `read`, `bash`, …) — top-N with a
|
|
43
|
+
`+N more` tail. Aggregated on both the hook and the fallback path from each
|
|
44
|
+
tool part's `name`; names are sanitised and the map is bounded (overflow folds
|
|
45
|
+
into `other tools`). Persisted as `toolOutByTool` in `state.json`.
|
|
46
|
+
- **Compaction staleness marker**: opencode's compaction fires no `context`
|
|
47
|
+
hook, so `/context` right after `/compact` used to show stale pre-compaction
|
|
48
|
+
numbers with no hint. Such an entry is now flagged (`compactStale`) and every
|
|
49
|
+
surface (sidebar, panel, `/context`, `/context-breakdown`) shows
|
|
50
|
+
`(stale — compacted)` until the next model request refreshes the breakdown.
|
|
51
|
+
- Legacy `state.json` entries (no new fields) behave exactly as before.
|
|
52
|
+
- Tests: T19/T19a (staleness mark lifecycle + surfaces), T20–T20c (per-tool
|
|
53
|
+
split on both paths, sanitisation/bounding, persistence + rendering).
|
|
54
|
+
- **Refactor (behaviour-preserving)**: extracted the shared category helpers
|
|
55
|
+
(`sumCategories` / `breakdownCategories`) into `lib/estimate.js`, removing
|
|
56
|
+
four hand-rolled copies of the residual-`other` computation (V2 live log,
|
|
57
|
+
state writer, command report, TUI panel); the TUI now imports the ONE token
|
|
58
|
+
estimator and per-tool split from `lib/estimate.js` instead of keeping byte
|
|
59
|
+
copies; dropped unused module exports; unified the per-tool map cap. New
|
|
60
|
+
guard test T21.
|
|
61
|
+
|
|
7
62
|
## 1.4.0 — 2026-10-13
|
|
8
63
|
|
|
9
64
|
Highlights: a new **toolOut** category names what used to drown in `other` —
|
package/README.md
CHANGED
|
@@ -40,6 +40,8 @@ For every served request the plugin estimates how the context window is split:
|
|
|
40
40
|
tool schemas 1.4k (est tokens)
|
|
41
41
|
other 5.6k (= input - sum of estimates)
|
|
42
42
|
source context-hook
|
|
43
|
+
prompt cache read=12.0k (27% of input) write=1.1k (provider-reported)
|
|
44
|
+
reclaim ~9.8k redundant tool output (3 duplicate(s) — estimate)
|
|
43
45
|
subagents 2 session(s): in=8.1k out=2.3k r=1.0k worst=12%
|
|
44
46
|
```
|
|
45
47
|
|
|
@@ -49,12 +51,25 @@ For every served request the plugin estimates how the context window is split:
|
|
|
49
51
|
* `other` is the residual (`input − sum of estimates`): what no category
|
|
50
52
|
explains — images/media, unknown part types.
|
|
51
53
|
* `toolOut` names the former blind spot: **tool results** fed back into the
|
|
52
|
-
context (webfetch pages, file reads, subagent reports).
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
context (webfetch pages, file reads, subagent reports). `/context-breakdown`
|
|
55
|
+
and the TUI panel split it **by tool name** (`webfetch`, `read`, `bash`, …);
|
|
56
|
+
the TUI panel's *largest contributors* section attributes them **per tool
|
|
57
|
+
call**; the category only totals them.
|
|
55
58
|
* `ctx` is OpenCode's native overflow count: `tokens.total` when present, else
|
|
56
59
|
`input + output + cache.read + cache.write`. Percentages are **not clamped**
|
|
57
60
|
at 100% — exceeding the window stays visible.
|
|
61
|
+
* `prompt cache` is **provider-reported and exact** (`tokens.cache.read` /
|
|
62
|
+
`tokens.cache.write`), shown as a share of `input`. It is a *subset* of input,
|
|
63
|
+
so it is deliberately **not** added to the category sum: a cache read means the
|
|
64
|
+
provider served those prompt tokens from its prompt cache, not that they are
|
|
65
|
+
absent from the window. `n/a` when the provider reports nothing.
|
|
66
|
+
* `reclaim` is the **waste detector**: tool output already in the context that is
|
|
67
|
+
redundant — the **same target** invoked repeatedly (the same file read twice,
|
|
68
|
+
the same URL fetched again) or **byte-identical output**. It is an estimate of
|
|
69
|
+
what trimming/compaction could reclaim (the non-largest member of each
|
|
70
|
+
duplicate group), reported honestly as *"N redundant tool outputs ≈ X tokens"*
|
|
71
|
+
— never as "the model wasted effort". Findings are per-session and **path-free
|
|
72
|
+
in the persisted/exported form**.
|
|
58
73
|
* The denominator is the **opencode v2.0.23 compaction ceiling**: `window =
|
|
59
74
|
limit.input || limit.context`; `reserve = max(floor(window * 0.1), window >=
|
|
60
75
|
32_000 ? 16_000 : 0)`; the ceiling is `window - reserve`. When opencode
|
|
@@ -160,6 +175,16 @@ reads, subagent reports fed back into context), sys=system prompt, schm=tool
|
|
|
160
175
|
schemas, oth=residual input. src: live=context hook, snap=state.json,
|
|
161
176
|
fallback=session.context (no system/schemas → n/a), no-data=categories
|
|
162
177
|
unavailable._
|
|
178
|
+
|
|
179
|
+
### Tool results by tool (main)
|
|
180
|
+
webfetch 242.1k · read 18.4k · bash 4.9k · +3 more
|
|
181
|
+
|
|
182
|
+
### Prompt cache (main, provider-reported)
|
|
183
|
+
read 12.0k (27% of input) · write 1.1k
|
|
184
|
+
|
|
185
|
+
### Potential reclaim (redundant tool output)
|
|
186
|
+
- read src/a.ts x2 — ~9.8k reclaimable
|
|
187
|
+
_~9.8k across 3 redundant tool output(s) — estimate of what trimming/compaction could reclaim._
|
|
163
188
|
```
|
|
164
189
|
|
|
165
190
|
It is delivered with `resume:true` **and** a verbatim instruction prefix, so the
|
|
@@ -524,6 +549,13 @@ duplicate final summaries are suppressed with an atomic claim marker
|
|
|
524
549
|
(`lib/dedup.js`). Every file / estimate / subagent path is fault-tolerant: a
|
|
525
550
|
failure is logged and never breaks the main event or toast path.
|
|
526
551
|
|
|
552
|
+
**Compaction staleness.** OpenCode's compaction runs a separate flow that fires
|
|
553
|
+
no `context` hook, so the numbers a `session.compacted` write re-persists are
|
|
554
|
+
the *pre-compaction* ones. The plugin flags such an entry
|
|
555
|
+
(`compactStale` in `state.json`) and every surface shows `(stale — compacted)`
|
|
556
|
+
until the next model request refreshes the breakdown — i.e. the data is exactly
|
|
557
|
+
as fresh as the last model request in the session.
|
|
558
|
+
|
|
527
559
|
This plugin does not touch `opencode-token-monitor`
|
|
528
560
|
(`token_stats` / `token_history` / `token_export` keep working unchanged).
|
|
529
561
|
|
|
@@ -542,7 +574,7 @@ caller.
|
|
|
542
574
|
### Running tests
|
|
543
575
|
|
|
544
576
|
```bash
|
|
545
|
-
npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–
|
|
577
|
+
npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T24 unit checks
|
|
546
578
|
npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
|
|
547
579
|
npm run test:e2e:quick # E2E load-only, no LLM call
|
|
548
580
|
npm run test:drift # OpenTUI pin vs the latest RELEASED opencode (also in CI)
|
|
@@ -563,7 +595,7 @@ STEP 80 s, ~3 min total.
|
|
|
563
595
|
| (a) tarball composition | `package.json` `files[]` expands to the base files plus every `lib/*` module (count asserted dynamically) | no test/node_modules/tsconfig in the tarball |
|
|
564
596
|
| (b) pragma regression | esbuild `--jsx=automatic` → `@opentui/solid/jsx-runtime` present, `react/jsx-runtime` absent | a removed/broken pragma kills the sidebar on npm install |
|
|
565
597
|
| (c) resolve chain | replica install: host-bridged + peer packages resolve; local tui.tsx relative imports resolve via node from the entry (resolve-bridge.cjs) | broken peer/optional dep or a bad local import silently breaks the sidebar |
|
|
566
|
-
| (d) unit harness T1–
|
|
598
|
+
| (d) unit harness T1–T24 | limit/agent/state fix assertions plus measureContributors, redacted-export, shared category-helper, sanitizer/unicode, prompt-cache and waste-detector checks (58 total) | regressions in modelLimits, writeStateFile, hydrate, contributors, export |
|
|
567
599
|
| (e) corporate-identifier scan | no forbidden identifiers in the published surface + test tree | compliance |
|
|
568
600
|
| (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in any repo file | English-only source rule |
|
|
569
601
|
|
package/index.js
CHANGED
|
@@ -151,12 +151,14 @@
|
|
|
151
151
|
|
|
152
152
|
import { makeV1 } from "./lib/v1.js";
|
|
153
153
|
import { handleV2Event, onV2Context, estimateBreakdownFromContext, estimateBreakdownFromClientMessages } from "./lib/v2.js";
|
|
154
|
+
import { breakdownCategories, computeReclaim, contentFingerprint, estimateTokens, extractToolTarget, isSafeSessionKey, safeCell, safeReclaim, safeSaneOrNull, safeToolOutByTool, sumCategories, toolOutEntries } from "./lib/estimate.js";
|
|
154
155
|
import {
|
|
155
156
|
commandContextBreakdown,
|
|
156
157
|
commandContextSummary,
|
|
157
158
|
CONTEXT_BREAKDOWN_RESUME,
|
|
158
159
|
collectSessionReport,
|
|
159
160
|
renderSessionRow,
|
|
161
|
+
renderBreakdownTable,
|
|
160
162
|
shortSummaryText,
|
|
161
163
|
shortSummaryDescription,
|
|
162
164
|
} from "./lib/commands.js";
|
|
@@ -168,7 +170,7 @@ import {
|
|
|
168
170
|
modelLimits,
|
|
169
171
|
reserveFor,
|
|
170
172
|
} from "./lib/limits.js";
|
|
171
|
-
import { sessionAgentCache, sessionParentCache } from "./lib/cache.js";
|
|
173
|
+
import { lastKnownCacheRead, lastKnownCacheWrite, sessionAgentCache, sessionParentCache } from "./lib/cache.js";
|
|
172
174
|
import {
|
|
173
175
|
logErr,
|
|
174
176
|
readStateSnapshot,
|
|
@@ -194,6 +196,7 @@ export {
|
|
|
194
196
|
reserveFor,
|
|
195
197
|
collectSessionReport,
|
|
196
198
|
renderSessionRow,
|
|
199
|
+
renderBreakdownTable,
|
|
197
200
|
shortSummaryText,
|
|
198
201
|
shortSummaryDescription,
|
|
199
202
|
ensureModelLimits,
|
|
@@ -201,6 +204,20 @@ export {
|
|
|
201
204
|
estimateBreakdownFromContext,
|
|
202
205
|
estimateBreakdownFromClientMessages,
|
|
203
206
|
onV2Context,
|
|
207
|
+
safeToolOutByTool,
|
|
208
|
+
toolOutEntries,
|
|
209
|
+
sumCategories,
|
|
210
|
+
breakdownCategories,
|
|
211
|
+
safeCell,
|
|
212
|
+
isSafeSessionKey,
|
|
213
|
+
estimateTokens,
|
|
214
|
+
extractToolTarget,
|
|
215
|
+
contentFingerprint,
|
|
216
|
+
computeReclaim,
|
|
217
|
+
safeReclaim,
|
|
218
|
+
safeSaneOrNull,
|
|
219
|
+
lastKnownCacheRead,
|
|
220
|
+
lastKnownCacheWrite,
|
|
204
221
|
};
|
|
205
222
|
|
|
206
223
|
// ---------------------------------------------------------------------------
|
package/lib/cache.js
CHANGED
|
@@ -53,6 +53,12 @@ const lastKnownReasoningCache = new Map();
|
|
|
53
53
|
// sessionID -> last native opencode overflow count (tokens.total, else
|
|
54
54
|
// input + output + cache.read + cache.write)
|
|
55
55
|
const lastKnownCtxCache = new Map();
|
|
56
|
+
// sessionID -> last provider-reported prompt-cache figures from
|
|
57
|
+
// session.step.ended (`tokens.cache.read` / `tokens.cache.write`). These are
|
|
58
|
+
// EXACT provider numbers (not estimates) and are a subset of `input`, so they
|
|
59
|
+
// are shown as a separate metric and never folded into the category sum.
|
|
60
|
+
const lastKnownCacheReadCache = new Map();
|
|
61
|
+
const lastKnownCacheWriteCache = new Map();
|
|
56
62
|
|
|
57
63
|
// sessionID -> last time we wrote a live breakdown snapshot
|
|
58
64
|
const lastBdWrite = new Map();
|
|
@@ -92,6 +98,16 @@ function lastKnownReasoning(sessionID) {
|
|
|
92
98
|
function lastKnownCtx(sessionID) {
|
|
93
99
|
return lastKnownCtxCache.get(sessionID) || 0;
|
|
94
100
|
}
|
|
101
|
+
// Provider-reported prompt-cache figures; null when the provider never reported
|
|
102
|
+
// them (a plain "n/a" rather than a misleading 0).
|
|
103
|
+
function lastKnownCacheRead(sessionID) {
|
|
104
|
+
const v = lastKnownCacheReadCache.get(sessionID);
|
|
105
|
+
return typeof v === "number" ? v : null;
|
|
106
|
+
}
|
|
107
|
+
function lastKnownCacheWrite(sessionID) {
|
|
108
|
+
const v = lastKnownCacheWriteCache.get(sessionID);
|
|
109
|
+
return typeof v === "number" ? v : null;
|
|
110
|
+
}
|
|
95
111
|
|
|
96
112
|
// ---------------------------------------------------------------------------
|
|
97
113
|
// Clear per-session tracking. Full wipe on session.idle / session.deleted;
|
|
@@ -118,6 +134,8 @@ function clearSessionTracking(sessionID, keepToastDedup = false) {
|
|
|
118
134
|
lastKnownInputCache.delete(sessionID);
|
|
119
135
|
lastKnownReasoningCache.delete(sessionID);
|
|
120
136
|
lastKnownCtxCache.delete(sessionID);
|
|
137
|
+
lastKnownCacheReadCache.delete(sessionID);
|
|
138
|
+
lastKnownCacheWriteCache.delete(sessionID);
|
|
121
139
|
lastBdWrite.delete(sessionID);
|
|
122
140
|
// V2 subagent accumulation for this session.
|
|
123
141
|
sessionTotals.delete(sessionID);
|
|
@@ -145,6 +163,8 @@ export {
|
|
|
145
163
|
lastKnownInputCache,
|
|
146
164
|
lastKnownReasoningCache,
|
|
147
165
|
lastKnownCtxCache,
|
|
166
|
+
lastKnownCacheReadCache,
|
|
167
|
+
lastKnownCacheWriteCache,
|
|
148
168
|
lastBdWrite,
|
|
149
169
|
lastErrorToast,
|
|
150
170
|
lastCompactedToast,
|
|
@@ -157,5 +177,7 @@ export {
|
|
|
157
177
|
lastKnownInput,
|
|
158
178
|
lastKnownReasoning,
|
|
159
179
|
lastKnownCtx,
|
|
180
|
+
lastKnownCacheRead,
|
|
181
|
+
lastKnownCacheWrite,
|
|
160
182
|
clearSessionTracking,
|
|
161
183
|
};
|
package/lib/commands.js
CHANGED
|
@@ -6,16 +6,25 @@
|
|
|
6
6
|
// from index.js.
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
breakdownCategories,
|
|
9
10
|
fmt,
|
|
10
11
|
fmtCell,
|
|
11
12
|
isSafeSessionKey,
|
|
12
13
|
oneLine,
|
|
13
14
|
safeCategories,
|
|
14
15
|
safeCell,
|
|
16
|
+
safeCount,
|
|
17
|
+
safeReclaim,
|
|
18
|
+
safeSaneOrNull,
|
|
19
|
+
safeToolOutByTool,
|
|
20
|
+
sumCategories,
|
|
21
|
+
toolOutEntries,
|
|
15
22
|
withTimeout,
|
|
16
23
|
} from "./estimate.js";
|
|
17
24
|
import {
|
|
18
25
|
breakdownCache,
|
|
26
|
+
lastKnownCacheRead,
|
|
27
|
+
lastKnownCacheWrite,
|
|
19
28
|
lastKnownCtx,
|
|
20
29
|
lastKnownInput,
|
|
21
30
|
lastKnownModel,
|
|
@@ -73,16 +82,7 @@ const CONTEXT_BREAKDOWN_RESUME = true;
|
|
|
73
82
|
// Note: `system` / `toolSchemas` may legitimately be null (hook-only), so they
|
|
74
83
|
// are coerced with `|| 0`.
|
|
75
84
|
function isEmptyStateEntry(entry) {
|
|
76
|
-
const
|
|
77
|
-
const sum =
|
|
78
|
-
(c.user || 0) +
|
|
79
|
-
(c.assistant || 0) +
|
|
80
|
-
(c.reasoning || 0) +
|
|
81
|
-
(c.toolArgs || 0) +
|
|
82
|
-
(c.toolOut || 0) +
|
|
83
|
-
(c.system || 0) +
|
|
84
|
-
(c.toolSchemas || 0) +
|
|
85
|
-
(c.other || 0);
|
|
85
|
+
const sum = sumCategories(entry?.categories);
|
|
86
86
|
return (
|
|
87
87
|
sum === 0 &&
|
|
88
88
|
(!entry?.ctx || entry.ctx === 0) &&
|
|
@@ -135,21 +135,8 @@ function collectDescendantSessionIDs(rootID, state) {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
// Normalise a breakdown record (cache or state entry) into the 8 categories.
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
const user = rec?.userChars ?? rec?.user ?? 0;
|
|
141
|
-
const assistant = rec?.assistantChars ?? rec?.assistant ?? 0;
|
|
142
|
-
const reasoning = rec?.reasoningChars ?? rec?.reasoning ?? 0;
|
|
143
|
-
const toolArgs = rec?.toolArgsChars ?? rec?.toolArgs ?? 0;
|
|
144
|
-
const toolOut = rec?.toolOutChars ?? rec?.toolOut ?? 0;
|
|
145
|
-
const system = rec?.systemChars !== undefined ? rec.systemChars : rec?.system ?? null;
|
|
146
|
-
const toolSchemas =
|
|
147
|
-
rec?.toolSchemasChars !== undefined ? rec.toolSchemasChars : rec?.toolSchemas ?? null;
|
|
148
|
-
const sum =
|
|
149
|
-
user + assistant + reasoning + toolArgs + toolOut + (system || 0) + (toolSchemas || 0);
|
|
150
|
-
const other = Math.max(0, (inputTokens || 0) - sum);
|
|
151
|
-
return { user, assistant, reasoning, toolArgs, toolOut, system, toolSchemas, other };
|
|
152
|
-
}
|
|
138
|
+
// Moved to lib/estimate.js (shared with the V2 live log); re-used here so the
|
|
139
|
+
// residual computation has exactly one implementation.
|
|
153
140
|
|
|
154
141
|
// Short source tag for the table (kept tiny on purpose).
|
|
155
142
|
function sourceTag(source) {
|
|
@@ -238,6 +225,7 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
238
225
|
|
|
239
226
|
let categories = null;
|
|
240
227
|
let source = "none";
|
|
228
|
+
let fallbackParts = null;
|
|
241
229
|
if (bd) {
|
|
242
230
|
// Sanitise: every value reaches a markdown/log sink and must be a number.
|
|
243
231
|
categories = safeCategories(
|
|
@@ -249,9 +237,8 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
249
237
|
categories = safeCategories(entry.categories);
|
|
250
238
|
source = "snapshot";
|
|
251
239
|
} else if (Array.isArray(contextMsgs)) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
);
|
|
240
|
+
fallbackParts = estimateBreakdownFromClientMessages(contextMsgs);
|
|
241
|
+
categories = safeCategories(breakdownCategories(fallbackParts, 0));
|
|
255
242
|
source = "client-context";
|
|
256
243
|
} else if (needFallback) {
|
|
257
244
|
// No live cache, no state.json categories, and the bounded session.context
|
|
@@ -284,6 +271,24 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
284
271
|
const updatedAt =
|
|
285
272
|
entry?.updatedAt ||
|
|
286
273
|
(bd?.capturedAt ? new Date(bd.capturedAt).toISOString() : null);
|
|
274
|
+
const toolOutByTool = bd
|
|
275
|
+
? safeToolOutByTool(bd.toolOutByTool)
|
|
276
|
+
: safeToolOutByTool(entry?.toolOutByTool) ||
|
|
277
|
+
safeToolOutByTool(fallbackParts?.toolOutByTool);
|
|
278
|
+
const compactStale = entry?.compactStale === true;
|
|
279
|
+
// Provider-reported prompt cache (exact; subset of input). Live cache first,
|
|
280
|
+
// then the persisted snapshot.
|
|
281
|
+
const cacheRead =
|
|
282
|
+
safeSaneOrNull(lastKnownCacheRead(sessionID)) ??
|
|
283
|
+
safeSaneOrNull(entry?.cacheRead);
|
|
284
|
+
const cacheWrite =
|
|
285
|
+
safeSaneOrNull(lastKnownCacheWrite(sessionID)) ??
|
|
286
|
+
safeSaneOrNull(entry?.cacheWrite);
|
|
287
|
+
// Redundant tool output. The live breakdown owns targets; the persisted entry
|
|
288
|
+
// has them stripped (path-free) — sanitise both.
|
|
289
|
+
const reclaim = bd
|
|
290
|
+
? safeReclaim(bd.reclaim, true)
|
|
291
|
+
: safeReclaim(entry?.reclaim, false);
|
|
287
292
|
return {
|
|
288
293
|
sessionID,
|
|
289
294
|
parentID,
|
|
@@ -291,9 +296,15 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
291
296
|
modelID,
|
|
292
297
|
providerID,
|
|
293
298
|
ctxTokens,
|
|
299
|
+
inputTokens: lastKnownInput(sessionID) || entry?.input || 0,
|
|
294
300
|
usable,
|
|
295
301
|
reserve,
|
|
296
302
|
categories,
|
|
303
|
+
toolOutByTool,
|
|
304
|
+
reclaim,
|
|
305
|
+
cacheRead,
|
|
306
|
+
cacheWrite,
|
|
307
|
+
compactStale,
|
|
297
308
|
source,
|
|
298
309
|
updatedAt,
|
|
299
310
|
};
|
|
@@ -311,11 +322,62 @@ function renderSessionRow(r, isMain) {
|
|
|
311
322
|
? ` (${((r.ctxTokens / r.usable) * 100).toFixed(0)}%)`
|
|
312
323
|
: "";
|
|
313
324
|
const ctx = `${fmt(r.ctxTokens)}${pct}`;
|
|
314
|
-
const upd = `${timeOnly(r.updatedAt)} ${sourceTag(r.source)}`;
|
|
325
|
+
const upd = `${timeOnly(r.updatedAt)} ${sourceTag(r.source)}${r.compactStale ? " (stale)" : ""}`;
|
|
315
326
|
return `| ${role} | ${safeCell(r.modelID)} | ${ctx} | ${fmtCell(c.user)} | ${fmtCell(c.assistant)} | ${fmtCell(c.reasoning)} | ${fmtCell(c.toolArgs)} | ${fmtCell(c.toolOut)} | ${fmtCell(c.system)} | ${fmtCell(c.toolSchemas)} | ${fmtCell(c.other)} | ${upd} |`;
|
|
316
327
|
}
|
|
317
328
|
|
|
318
|
-
|
|
329
|
+
// A compact one-line "tool results by tool" split for the main session, built
|
|
330
|
+
// from the server-side aggregate (sanitised names, top-N). Empty when the map
|
|
331
|
+
// carries nothing.
|
|
332
|
+
function renderToolOutLine(toolOutByTool, limit) {
|
|
333
|
+
const entries = toolOutEntries(toolOutByTool, limit || 8);
|
|
334
|
+
if (entries.length === 0) return "";
|
|
335
|
+
const parts = entries.map(
|
|
336
|
+
([name, val]) => `${safeCell(name)} ${fmt(val)}`,
|
|
337
|
+
);
|
|
338
|
+
const total = Object.values(toolOutByTool || {}).filter(
|
|
339
|
+
(v) => safeCount(v) > 0,
|
|
340
|
+
).length;
|
|
341
|
+
if (total > entries.length) parts.push(`+${total - entries.length} more`);
|
|
342
|
+
return parts.join(" · ");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Provider-reported prompt cache for the main session — EXACT numbers, a
|
|
346
|
+
// SUBSET of input (so the percentage is "of input", never a category). Empty
|
|
347
|
+
// when the provider never reported cache tokens.
|
|
348
|
+
function renderCacheLine(r) {
|
|
349
|
+
if (!r || (r.cacheRead == null && r.cacheWrite == null)) return "";
|
|
350
|
+
const read = r.cacheRead != null ? fmt(r.cacheRead) : "0";
|
|
351
|
+
const write = r.cacheWrite != null ? fmt(r.cacheWrite) : "0";
|
|
352
|
+
const pct =
|
|
353
|
+
r.cacheRead != null && r.inputTokens > 0
|
|
354
|
+
? ` (${((r.cacheRead / r.inputTokens) * 100).toFixed(0)}% of input)`
|
|
355
|
+
: "";
|
|
356
|
+
return `read ${read}${pct} · write ${write}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Redundant tool output already in context (repeated target / identical output).
|
|
360
|
+
// Targets are shown only when present (the live breakdown has them; the
|
|
361
|
+
// persisted/redacted path strips them).
|
|
362
|
+
function renderReclaimSection(r) {
|
|
363
|
+
const rc = r?.reclaim;
|
|
364
|
+
if (!rc || rc.tokens <= 0) return "";
|
|
365
|
+
const lines = ["", "### Potential reclaim (redundant tool output)"];
|
|
366
|
+
for (const f of rc.findings || []) {
|
|
367
|
+
const label = f.target
|
|
368
|
+
? `${safeCell(f.name)} ${safeCell(f.target)}`
|
|
369
|
+
: f.kind === "content"
|
|
370
|
+
? `${safeCell(f.name)} (identical output)`
|
|
371
|
+
: safeCell(f.name);
|
|
372
|
+
lines.push(`- ${label} x${f.n} — ~${fmt(f.tokens)} reclaimable`);
|
|
373
|
+
}
|
|
374
|
+
lines.push(
|
|
375
|
+
`_~${fmt(rc.tokens)} across ${rc.count} redundant tool output(s) — estimate of what trimming/compaction could reclaim._`,
|
|
376
|
+
);
|
|
377
|
+
return lines.join("\n");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function renderBreakdownTable(rows, childCount, mainReport) {
|
|
319
381
|
const lines = [];
|
|
320
382
|
lines.push("### Context breakdown");
|
|
321
383
|
lines.push("");
|
|
@@ -332,6 +394,24 @@ function renderBreakdownTable(rows, childCount) {
|
|
|
332
394
|
`schm=tool schemas, oth=residual input. src: live=context hook, snap=state.json, ` +
|
|
333
395
|
`fallback=session.context (no system/schemas → n/a), no-data=categories unavailable._`,
|
|
334
396
|
);
|
|
397
|
+
// Per-tool split of the main session's tool results (which tool ate the
|
|
398
|
+
// "out" column). Server-side aggregate; names sanitised, top-N only.
|
|
399
|
+
const byTool = renderToolOutLine(mainReport?.toolOutByTool, 8);
|
|
400
|
+
if (byTool) {
|
|
401
|
+
lines.push("");
|
|
402
|
+
lines.push("### Tool results by tool (main)");
|
|
403
|
+
lines.push(byTool);
|
|
404
|
+
}
|
|
405
|
+
// Provider-reported prompt cache (exact; a subset of input).
|
|
406
|
+
const cache = renderCacheLine(mainReport);
|
|
407
|
+
if (cache) {
|
|
408
|
+
lines.push("");
|
|
409
|
+
lines.push("### Prompt cache (main, provider-reported)");
|
|
410
|
+
lines.push(cache);
|
|
411
|
+
}
|
|
412
|
+
// Redundant tool output (waste detector).
|
|
413
|
+
const reclaim = renderReclaimSection(mainReport);
|
|
414
|
+
if (reclaim) lines.push(reclaim);
|
|
335
415
|
return lines.join("\n");
|
|
336
416
|
}
|
|
337
417
|
|
|
@@ -402,7 +482,16 @@ function shortSummaryText(r) {
|
|
|
402
482
|
}
|
|
403
483
|
lines.push(compact);
|
|
404
484
|
}
|
|
485
|
+
// Provider-reported prompt cache (exact; subset of input) — shown when the
|
|
486
|
+
// provider reported it.
|
|
487
|
+
const cache = renderCacheLine(r);
|
|
488
|
+
if (cache) lines.push(`prompt cache ${cache}`);
|
|
405
489
|
lines.push(`updated ${timeOnly(r.updatedAt)} (${sourceTag(r.source)})`);
|
|
490
|
+
if (r.compactStale) {
|
|
491
|
+
lines.push(
|
|
492
|
+
"stale — compacted: these are pre-compaction numbers; they refresh after the next message",
|
|
493
|
+
);
|
|
494
|
+
}
|
|
406
495
|
return lines.join("\n");
|
|
407
496
|
}
|
|
408
497
|
|
|
@@ -484,7 +573,7 @@ async function commandContextBreakdown(ctx, invocation, opts) {
|
|
|
484
573
|
]);
|
|
485
574
|
const rows = [renderSessionRow(root, true)];
|
|
486
575
|
for (const rep of children) rows.push(renderSessionRow(rep, false));
|
|
487
|
-
const table = renderBreakdownTable(rows, childIDs.length);
|
|
576
|
+
const table = renderBreakdownTable(rows, childIDs.length, root);
|
|
488
577
|
const payload = resume
|
|
489
578
|
? `${BREAKDOWN_MODEL_INSTRUCTION}\n\n<context-breakdown>\n${table}\n</context-breakdown>`
|
|
490
579
|
: table;
|
|
@@ -510,6 +599,7 @@ export {
|
|
|
510
599
|
CONTEXT_BREAKDOWN_RESUME,
|
|
511
600
|
collectSessionReport,
|
|
512
601
|
renderSessionRow,
|
|
602
|
+
renderBreakdownTable,
|
|
513
603
|
shortSummaryText,
|
|
514
604
|
shortSummaryDescription,
|
|
515
605
|
};
|
package/lib/contributors.js
CHANGED
|
@@ -3,48 +3,19 @@
|
|
|
3
3
|
// Pure contributor-measurement helpers extracted from tui.tsx for unit testing.
|
|
4
4
|
// All functions are stateless transforms of their arguments.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// Functions: asRecord, safeText, measureContributors, contributorLine,
|
|
7
|
+
// exportFileName, buildRedactedPayload, panelDenom, panelRole. Token estimation
|
|
8
|
+
// is imported from lib/estimate.js (single source of truth).
|
|
9
|
+
|
|
10
|
+
import { estimateTokens } from "./estimate.js";
|
|
9
11
|
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
11
|
-
// Token estimation
|
|
12
|
-
//
|
|
13
|
+
// Token estimation — the SHARED lib/estimate.js heuristic (the TUI panel and
|
|
14
|
+
// the server must price the same text identically; the earlier self-contained
|
|
15
|
+
// copy had drifted risk). estimate.js is pure and dependency-free, so it loads
|
|
16
|
+
// in the TUI host exactly like the rest of lib/.
|
|
13
17
|
// ---------------------------------------------------------------------------
|
|
14
18
|
|
|
15
|
-
// Unicode heuristic — estimate, not a tokenizer. Cyrillic ~2.5, CJK ~1.5,
|
|
16
|
-
// latin/ASCII ~4 chars/token.
|
|
17
|
-
function estimateTokensLocal(s) {
|
|
18
|
-
let other = 0;
|
|
19
|
-
let cyr = 0;
|
|
20
|
-
let cjk = 0;
|
|
21
|
-
for (let i = 0; i < s.length; i++) {
|
|
22
|
-
const cp = s.codePointAt(i);
|
|
23
|
-
if (cp > 0xffff) i++; // astral pair — consume the low surrogate
|
|
24
|
-
if (cp < 0x80) other++;
|
|
25
|
-
else if (
|
|
26
|
-
(cp >= 0x0400 && cp <= 0x04ff) || // Cyrillic
|
|
27
|
-
(cp >= 0x0500 && cp <= 0x052f) || // Cyrillic Supplement
|
|
28
|
-
(cp >= 0x2de0 && cp <= 0x2dff) || // Cyrillic Extended-A
|
|
29
|
-
(cp >= 0xa640 && cp <= 0xa69f) || // Cyrillic Extended-B
|
|
30
|
-
(cp >= 0x1c80 && cp <= 0x1c8f) // Cyrillic Extended-C
|
|
31
|
-
)
|
|
32
|
-
cyr++;
|
|
33
|
-
else if (
|
|
34
|
-
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
|
35
|
-
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
|
|
36
|
-
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
|
|
37
|
-
(cp >= 0x3040 && cp <= 0x30ff) || // Hiragana + Katakana
|
|
38
|
-
(cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
|
|
39
|
-
(cp >= 0x3000 && cp <= 0x303f) || // CJK Symbols and Punctuation
|
|
40
|
-
(cp >= 0xff00 && cp <= 0xffef) // Halfwidth/Fullwidth Forms
|
|
41
|
-
)
|
|
42
|
-
cjk++;
|
|
43
|
-
else other++;
|
|
44
|
-
}
|
|
45
|
-
return Math.round(other / 4 + cyr / 2.5 + cjk / 1.5);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
19
|
// Live message data is untrusted: coerce every field defensively, never throw.
|
|
49
20
|
function asRecord(v) {
|
|
50
21
|
return v && typeof v === "object" ? v : null;
|
|
@@ -96,7 +67,7 @@ function measureContributors(messages) {
|
|
|
96
67
|
kind,
|
|
97
68
|
ref,
|
|
98
69
|
chars: sample.length,
|
|
99
|
-
tokensEstimate:
|
|
70
|
+
tokensEstimate: estimateTokens(sample),
|
|
100
71
|
tool: tool || "",
|
|
101
72
|
preview: previewOverride !== undefined ? previewOverride : previewOf(sample),
|
|
102
73
|
});
|
|
@@ -276,8 +247,6 @@ function panelRole(e, isMain) {
|
|
|
276
247
|
}
|
|
277
248
|
|
|
278
249
|
export {
|
|
279
|
-
estimateTokensLocal,
|
|
280
|
-
asRecord,
|
|
281
250
|
safeText,
|
|
282
251
|
measureContributors,
|
|
283
252
|
contributorLine,
|
package/lib/dedup.js
CHANGED
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
//
|
|
18
18
|
// EXPORTS
|
|
19
19
|
// claimOnce(key, dedupDir) atomic cross-instance / cross-process claim
|
|
20
|
-
// pruneDedup(dedupDir) delete claim markers older than DEDUP_TTL_MS
|
|
21
20
|
// pruneMap(map, maxSize) FIFO-trim an in-process Map (insertion order)
|
|
22
|
-
// DEDUP_TTL_MS
|
|
21
|
+
// (pruneDedup / DEDUP_TTL_MS are module-internal: pruning is driven from
|
|
22
|
+
// claimOnce, never from outside this file)
|
|
23
23
|
//
|
|
24
24
|
// The claim mechanism is the exact one proven in token-usage-logger.js:
|
|
25
25
|
// OpenCode loads one plugin instance per loaded location and mirrors the public
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
import { createHash } from "node:crypto";
|
|
41
41
|
import { join } from "node:path";
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
const DEDUP_TTL_MS = 24 * 60 * 60 * 1000; // prune claim markers after a day
|
|
44
44
|
// Time-throttled pruning. A claim directory can hold tens of thousands of
|
|
45
45
|
// markers (mostly legacy per-delta markers), and pruneDedup() does a
|
|
46
46
|
// synchronous readdirSync+statSync over the whole directory — running that in
|
|
@@ -103,7 +103,7 @@ export function claimOnce(key, dedupDir) {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
// Delete claim markers older than DEDUP_TTL_MS. Never throws.
|
|
106
|
-
|
|
106
|
+
function pruneDedup(dedupDir) {
|
|
107
107
|
try {
|
|
108
108
|
const now = Date.now();
|
|
109
109
|
for (const f of readdirSync(dedupDir)) {
|