opencode-context-indicator 1.4.0 → 1.4.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 +27 -0
- package/README.md +16 -5
- package/index.js +10 -0
- package/lib/commands.js +51 -31
- package/lib/contributors.js +10 -41
- package/lib/dedup.js +4 -4
- package/lib/estimate.js +92 -4
- package/lib/limits.js +0 -1
- package/lib/state.js +57 -29
- package/lib/tui-data.d.ts +10 -0
- package/lib/tui-data.js +13 -26
- package/lib/tui-panel.tsx +30 -7
- package/lib/tui-sidebar.tsx +3 -0
- package/lib/v2.js +59 -21
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@ 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.4.1 — 2026-10-14
|
|
8
|
+
|
|
9
|
+
Highlights: the `toolOut` category is now split **by tool name**, and a
|
|
10
|
+
`(stale — compacted)` marker tells you when the numbers predate a compaction.
|
|
11
|
+
|
|
12
|
+
- **Tool results by tool**: `/context-breakdown` (a `### Tool results by tool
|
|
13
|
+
(main)` section) and the `./cx` panel (`tool results by tool` line) split the
|
|
14
|
+
`toolOut` column by tool name (`webfetch`, `read`, `bash`, …) — top-N with a
|
|
15
|
+
`+N more` tail. Aggregated on both the hook and the fallback path from each
|
|
16
|
+
tool part's `name`; names are sanitised and the map is bounded (overflow folds
|
|
17
|
+
into `other tools`). Persisted as `toolOutByTool` in `state.json`.
|
|
18
|
+
- **Compaction staleness marker**: opencode's compaction fires no `context`
|
|
19
|
+
hook, so `/context` right after `/compact` used to show stale pre-compaction
|
|
20
|
+
numbers with no hint. Such an entry is now flagged (`compactStale`) and every
|
|
21
|
+
surface (sidebar, panel, `/context`, `/context-breakdown`) shows
|
|
22
|
+
`(stale — compacted)` until the next model request refreshes the breakdown.
|
|
23
|
+
- Legacy `state.json` entries (no new fields) behave exactly as before.
|
|
24
|
+
- Tests: T19/T19a (staleness mark lifecycle + surfaces), T20–T20c (per-tool
|
|
25
|
+
split on both paths, sanitisation/bounding, persistence + rendering).
|
|
26
|
+
- **Refactor (behaviour-preserving)**: extracted the shared category helpers
|
|
27
|
+
(`sumCategories` / `breakdownCategories`) into `lib/estimate.js`, removing
|
|
28
|
+
four hand-rolled copies of the residual-`other` computation (V2 live log,
|
|
29
|
+
state writer, command report, TUI panel); the TUI now imports the ONE token
|
|
30
|
+
estimator and per-tool split from `lib/estimate.js` instead of keeping byte
|
|
31
|
+
copies; dropped unused module exports; unified the per-tool map cap. New
|
|
32
|
+
guard test T21.
|
|
33
|
+
|
|
7
34
|
## 1.4.0 — 2026-10-13
|
|
8
35
|
|
|
9
36
|
Highlights: a new **toolOut** category names what used to drown in `other` —
|
package/README.md
CHANGED
|
@@ -49,9 +49,10 @@ For every served request the plugin estimates how the context window is split:
|
|
|
49
49
|
* `other` is the residual (`input − sum of estimates`): what no category
|
|
50
50
|
explains — images/media, unknown part types.
|
|
51
51
|
* `toolOut` names the former blind spot: **tool results** fed back into the
|
|
52
|
-
context (webfetch pages, file reads, subagent reports).
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
context (webfetch pages, file reads, subagent reports). `/context-breakdown`
|
|
53
|
+
and the TUI panel split it **by tool name** (`webfetch`, `read`, `bash`, …);
|
|
54
|
+
the TUI panel's *largest contributors* section attributes them **per tool
|
|
55
|
+
call**; the category only totals them.
|
|
55
56
|
* `ctx` is OpenCode's native overflow count: `tokens.total` when present, else
|
|
56
57
|
`input + output + cache.read + cache.write`. Percentages are **not clamped**
|
|
57
58
|
at 100% — exceeding the window stays visible.
|
|
@@ -160,6 +161,9 @@ reads, subagent reports fed back into context), sys=system prompt, schm=tool
|
|
|
160
161
|
schemas, oth=residual input. src: live=context hook, snap=state.json,
|
|
161
162
|
fallback=session.context (no system/schemas → n/a), no-data=categories
|
|
162
163
|
unavailable._
|
|
164
|
+
|
|
165
|
+
### Tool results by tool (main)
|
|
166
|
+
webfetch 242.1k · read 18.4k · bash 4.9k · +3 more
|
|
163
167
|
```
|
|
164
168
|
|
|
165
169
|
It is delivered with `resume:true` **and** a verbatim instruction prefix, so the
|
|
@@ -524,6 +528,13 @@ duplicate final summaries are suppressed with an atomic claim marker
|
|
|
524
528
|
(`lib/dedup.js`). Every file / estimate / subagent path is fault-tolerant: a
|
|
525
529
|
failure is logged and never breaks the main event or toast path.
|
|
526
530
|
|
|
531
|
+
**Compaction staleness.** OpenCode's compaction runs a separate flow that fires
|
|
532
|
+
no `context` hook, so the numbers a `session.compacted` write re-persists are
|
|
533
|
+
the *pre-compaction* ones. The plugin flags such an entry
|
|
534
|
+
(`compactStale` in `state.json`) and every surface shows `(stale — compacted)`
|
|
535
|
+
until the next model request refreshes the breakdown — i.e. the data is exactly
|
|
536
|
+
as fresh as the last model request in the session.
|
|
537
|
+
|
|
527
538
|
This plugin does not touch `opencode-token-monitor`
|
|
528
539
|
(`token_stats` / `token_history` / `token_export` keep working unchanged).
|
|
529
540
|
|
|
@@ -542,7 +553,7 @@ caller.
|
|
|
542
553
|
### Running tests
|
|
543
554
|
|
|
544
555
|
```bash
|
|
545
|
-
npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–
|
|
556
|
+
npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T22 unit checks
|
|
546
557
|
npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
|
|
547
558
|
npm run test:e2e:quick # E2E load-only, no LLM call
|
|
548
559
|
npm run test:drift # OpenTUI pin vs the latest RELEASED opencode (also in CI)
|
|
@@ -563,7 +574,7 @@ STEP 80 s, ~3 min total.
|
|
|
563
574
|
| (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
575
|
| (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
576
|
| (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–
|
|
577
|
+
| (d) unit harness T1–T22 | limit/agent/state fix assertions plus measureContributors, redacted-export, shared category-helper and sanitizer/unicode checks (51 total) | regressions in modelLimits, writeStateFile, hydrate, contributors, export |
|
|
567
578
|
| (e) corporate-identifier scan | no forbidden identifiers in the published surface + test tree | compliance |
|
|
568
579
|
| (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in any repo file | English-only source rule |
|
|
569
580
|
|
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, estimateTokens, isSafeSessionKey, safeCell, 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";
|
|
@@ -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,13 @@ export {
|
|
|
201
204
|
estimateBreakdownFromContext,
|
|
202
205
|
estimateBreakdownFromClientMessages,
|
|
203
206
|
onV2Context,
|
|
207
|
+
safeToolOutByTool,
|
|
208
|
+
toolOutEntries,
|
|
209
|
+
sumCategories,
|
|
210
|
+
breakdownCategories,
|
|
211
|
+
safeCell,
|
|
212
|
+
isSafeSessionKey,
|
|
213
|
+
estimateTokens,
|
|
204
214
|
};
|
|
205
215
|
|
|
206
216
|
// ---------------------------------------------------------------------------
|
package/lib/commands.js
CHANGED
|
@@ -6,12 +6,17 @@
|
|
|
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
|
+
safeToolOutByTool,
|
|
18
|
+
sumCategories,
|
|
19
|
+
toolOutEntries,
|
|
15
20
|
withTimeout,
|
|
16
21
|
} from "./estimate.js";
|
|
17
22
|
import {
|
|
@@ -73,16 +78,7 @@ const CONTEXT_BREAKDOWN_RESUME = true;
|
|
|
73
78
|
// Note: `system` / `toolSchemas` may legitimately be null (hook-only), so they
|
|
74
79
|
// are coerced with `|| 0`.
|
|
75
80
|
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);
|
|
81
|
+
const sum = sumCategories(entry?.categories);
|
|
86
82
|
return (
|
|
87
83
|
sum === 0 &&
|
|
88
84
|
(!entry?.ctx || entry.ctx === 0) &&
|
|
@@ -135,21 +131,8 @@ function collectDescendantSessionIDs(rootID, state) {
|
|
|
135
131
|
}
|
|
136
132
|
|
|
137
133
|
// 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
|
-
}
|
|
134
|
+
// Moved to lib/estimate.js (shared with the V2 live log); re-used here so the
|
|
135
|
+
// residual computation has exactly one implementation.
|
|
153
136
|
|
|
154
137
|
// Short source tag for the table (kept tiny on purpose).
|
|
155
138
|
function sourceTag(source) {
|
|
@@ -238,6 +221,7 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
238
221
|
|
|
239
222
|
let categories = null;
|
|
240
223
|
let source = "none";
|
|
224
|
+
let fallbackParts = null;
|
|
241
225
|
if (bd) {
|
|
242
226
|
// Sanitise: every value reaches a markdown/log sink and must be a number.
|
|
243
227
|
categories = safeCategories(
|
|
@@ -249,9 +233,8 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
249
233
|
categories = safeCategories(entry.categories);
|
|
250
234
|
source = "snapshot";
|
|
251
235
|
} else if (Array.isArray(contextMsgs)) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
);
|
|
236
|
+
fallbackParts = estimateBreakdownFromClientMessages(contextMsgs);
|
|
237
|
+
categories = safeCategories(breakdownCategories(fallbackParts, 0));
|
|
255
238
|
source = "client-context";
|
|
256
239
|
} else if (needFallback) {
|
|
257
240
|
// No live cache, no state.json categories, and the bounded session.context
|
|
@@ -284,6 +267,11 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
284
267
|
const updatedAt =
|
|
285
268
|
entry?.updatedAt ||
|
|
286
269
|
(bd?.capturedAt ? new Date(bd.capturedAt).toISOString() : null);
|
|
270
|
+
const toolOutByTool = bd
|
|
271
|
+
? safeToolOutByTool(bd.toolOutByTool)
|
|
272
|
+
: safeToolOutByTool(entry?.toolOutByTool) ||
|
|
273
|
+
safeToolOutByTool(fallbackParts?.toolOutByTool);
|
|
274
|
+
const compactStale = entry?.compactStale === true;
|
|
287
275
|
return {
|
|
288
276
|
sessionID,
|
|
289
277
|
parentID,
|
|
@@ -294,6 +282,8 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
294
282
|
usable,
|
|
295
283
|
reserve,
|
|
296
284
|
categories,
|
|
285
|
+
toolOutByTool,
|
|
286
|
+
compactStale,
|
|
297
287
|
source,
|
|
298
288
|
updatedAt,
|
|
299
289
|
};
|
|
@@ -311,11 +301,27 @@ function renderSessionRow(r, isMain) {
|
|
|
311
301
|
? ` (${((r.ctxTokens / r.usable) * 100).toFixed(0)}%)`
|
|
312
302
|
: "";
|
|
313
303
|
const ctx = `${fmt(r.ctxTokens)}${pct}`;
|
|
314
|
-
const upd = `${timeOnly(r.updatedAt)} ${sourceTag(r.source)}`;
|
|
304
|
+
const upd = `${timeOnly(r.updatedAt)} ${sourceTag(r.source)}${r.compactStale ? " (stale)" : ""}`;
|
|
315
305
|
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
306
|
}
|
|
317
307
|
|
|
318
|
-
|
|
308
|
+
// A compact one-line "tool results by tool" split for the main session, built
|
|
309
|
+
// from the server-side aggregate (sanitised names, top-N). Empty when the map
|
|
310
|
+
// carries nothing.
|
|
311
|
+
function renderToolOutLine(toolOutByTool, limit) {
|
|
312
|
+
const entries = toolOutEntries(toolOutByTool, limit || 8);
|
|
313
|
+
if (entries.length === 0) return "";
|
|
314
|
+
const parts = entries.map(
|
|
315
|
+
([name, val]) => `${safeCell(name)} ${fmt(val)}`,
|
|
316
|
+
);
|
|
317
|
+
const total = Object.values(toolOutByTool || {}).filter(
|
|
318
|
+
(v) => safeCount(v) > 0,
|
|
319
|
+
).length;
|
|
320
|
+
if (total > entries.length) parts.push(`+${total - entries.length} more`);
|
|
321
|
+
return parts.join(" · ");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function renderBreakdownTable(rows, childCount, mainReport) {
|
|
319
325
|
const lines = [];
|
|
320
326
|
lines.push("### Context breakdown");
|
|
321
327
|
lines.push("");
|
|
@@ -332,6 +338,14 @@ function renderBreakdownTable(rows, childCount) {
|
|
|
332
338
|
`schm=tool schemas, oth=residual input. src: live=context hook, snap=state.json, ` +
|
|
333
339
|
`fallback=session.context (no system/schemas → n/a), no-data=categories unavailable._`,
|
|
334
340
|
);
|
|
341
|
+
// Per-tool split of the main session's tool results (which tool ate the
|
|
342
|
+
// "out" column). Server-side aggregate; names sanitised, top-N only.
|
|
343
|
+
const byTool = renderToolOutLine(mainReport?.toolOutByTool, 8);
|
|
344
|
+
if (byTool) {
|
|
345
|
+
lines.push("");
|
|
346
|
+
lines.push("### Tool results by tool (main)");
|
|
347
|
+
lines.push(byTool);
|
|
348
|
+
}
|
|
335
349
|
return lines.join("\n");
|
|
336
350
|
}
|
|
337
351
|
|
|
@@ -403,6 +417,11 @@ function shortSummaryText(r) {
|
|
|
403
417
|
lines.push(compact);
|
|
404
418
|
}
|
|
405
419
|
lines.push(`updated ${timeOnly(r.updatedAt)} (${sourceTag(r.source)})`);
|
|
420
|
+
if (r.compactStale) {
|
|
421
|
+
lines.push(
|
|
422
|
+
"stale — compacted: these are pre-compaction numbers; they refresh after the next message",
|
|
423
|
+
);
|
|
424
|
+
}
|
|
406
425
|
return lines.join("\n");
|
|
407
426
|
}
|
|
408
427
|
|
|
@@ -484,7 +503,7 @@ async function commandContextBreakdown(ctx, invocation, opts) {
|
|
|
484
503
|
]);
|
|
485
504
|
const rows = [renderSessionRow(root, true)];
|
|
486
505
|
for (const rep of children) rows.push(renderSessionRow(rep, false));
|
|
487
|
-
const table = renderBreakdownTable(rows, childIDs.length);
|
|
506
|
+
const table = renderBreakdownTable(rows, childIDs.length, root);
|
|
488
507
|
const payload = resume
|
|
489
508
|
? `${BREAKDOWN_MODEL_INSTRUCTION}\n\n<context-breakdown>\n${table}\n</context-breakdown>`
|
|
490
509
|
: table;
|
|
@@ -510,6 +529,7 @@ export {
|
|
|
510
529
|
CONTEXT_BREAKDOWN_RESUME,
|
|
511
530
|
collectSessionReport,
|
|
512
531
|
renderSessionRow,
|
|
532
|
+
renderBreakdownTable,
|
|
513
533
|
shortSummaryText,
|
|
514
534
|
shortSummaryDescription,
|
|
515
535
|
};
|
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)) {
|
package/lib/estimate.js
CHANGED
|
@@ -99,6 +99,53 @@ function safeCategories(raw) {
|
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// A session key safe to use as an object property / map key. Rejects the
|
|
103
|
+
// prototype-pollution vectors ("__proto__", "constructor", "prototype").
|
|
104
|
+
// Sum of the 8 canonical category fields (null `system`/`toolSchemas` count as
|
|
105
|
+
// 0). The single place the "total measured tokens" is derived — shared by the
|
|
106
|
+
// residual-`other` computation, the continuity test and the log/summary writers.
|
|
107
|
+
function sumCategories(c) {
|
|
108
|
+
if (!c || typeof c !== "object") return 0;
|
|
109
|
+
return (
|
|
110
|
+
safeCount(c.user) +
|
|
111
|
+
safeCount(c.assistant) +
|
|
112
|
+
safeCount(c.reasoning) +
|
|
113
|
+
safeCount(c.toolArgs) +
|
|
114
|
+
safeCount(c.toolOut) +
|
|
115
|
+
safeCount(c.system) +
|
|
116
|
+
safeCount(c.toolSchemas) +
|
|
117
|
+
safeCount(c.other)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Normalise a breakdown record (cache record with *Chars keys, or a persisted
|
|
122
|
+
// state entry with canonical keys) into the 8 categories, deriving the residual
|
|
123
|
+
// `other` from `inputTokens`. Pure; the one converter shared by the V2 live log
|
|
124
|
+
// (lib/v2.js) and the slash-command report (lib/commands.js).
|
|
125
|
+
function breakdownCategories(rec, inputTokens) {
|
|
126
|
+
const user = rec?.userChars ?? rec?.user ?? 0;
|
|
127
|
+
const assistant = rec?.assistantChars ?? rec?.assistant ?? 0;
|
|
128
|
+
const reasoning = rec?.reasoningChars ?? rec?.reasoning ?? 0;
|
|
129
|
+
const toolArgs = rec?.toolArgsChars ?? rec?.toolArgs ?? 0;
|
|
130
|
+
const toolOut = rec?.toolOutChars ?? rec?.toolOut ?? 0;
|
|
131
|
+
const system =
|
|
132
|
+
rec?.systemChars !== undefined ? rec.systemChars : (rec?.system ?? null);
|
|
133
|
+
const toolSchemas =
|
|
134
|
+
rec?.toolSchemasChars !== undefined
|
|
135
|
+
? rec.toolSchemasChars
|
|
136
|
+
: (rec?.toolSchemas ?? null);
|
|
137
|
+
const sum =
|
|
138
|
+
user +
|
|
139
|
+
assistant +
|
|
140
|
+
reasoning +
|
|
141
|
+
toolArgs +
|
|
142
|
+
toolOut +
|
|
143
|
+
(system || 0) +
|
|
144
|
+
(toolSchemas || 0);
|
|
145
|
+
const other = Math.max(0, (inputTokens || 0) - sum);
|
|
146
|
+
return { user, assistant, reasoning, toolArgs, toolOut, system, toolSchemas, other };
|
|
147
|
+
}
|
|
148
|
+
|
|
102
149
|
// A session key safe to use as an object property / map key. Rejects the
|
|
103
150
|
// prototype-pollution vectors ("__proto__", "constructor", "prototype").
|
|
104
151
|
function isSafeSessionKey(k) {
|
|
@@ -111,6 +158,46 @@ function isSafeSessionKey(k) {
|
|
|
111
158
|
);
|
|
112
159
|
}
|
|
113
160
|
|
|
161
|
+
// Bound on the number of distinct tool names kept in a per-tool tool-result
|
|
162
|
+
// map (state.json / context payload are untrusted and a plugin could define an
|
|
163
|
+
// unbounded tool set). Everything past the cap — plus any unsafe name — is
|
|
164
|
+
// folded into a single "other tools" bucket so the total still reconciles with
|
|
165
|
+
// the toolOut category.
|
|
166
|
+
const TOOL_OUT_MAX_KEYS = 24;
|
|
167
|
+
// Normalise a per-tool tool-result map (tool name -> estimated tokens) from
|
|
168
|
+
// untrusted input. Names become object keys AND display cells, so prototype
|
|
169
|
+
// vectors are dropped and the map is bounded (see TOOL_OUT_MAX_KEYS). The
|
|
170
|
+
// overflow bucket is named "other tools" — a real tool with that exact name
|
|
171
|
+
// would merge into it (accepted: it is a display-only label).
|
|
172
|
+
function safeToolOutByTool(raw, maxKeys = TOOL_OUT_MAX_KEYS) {
|
|
173
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
174
|
+
const out = {};
|
|
175
|
+
let rest = 0;
|
|
176
|
+
let used = 0;
|
|
177
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
178
|
+
const val = safeCount(v);
|
|
179
|
+
if (!isSafeSessionKey(k) || used >= maxKeys) {
|
|
180
|
+
rest += val;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
out[k] = (out[k] || 0) + val;
|
|
184
|
+
used++;
|
|
185
|
+
}
|
|
186
|
+
if (rest > 0) out["other tools"] = (out["other tools"] || 0) + rest;
|
|
187
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Descending [name, tokens] pairs for display, capped at `limit`; empty when
|
|
191
|
+
// the map carries no positive rows.
|
|
192
|
+
function toolOutEntries(map, limit = 8) {
|
|
193
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) return [];
|
|
194
|
+
return Object.entries(map)
|
|
195
|
+
.map(([k, v]) => [k, safeCount(v)])
|
|
196
|
+
.filter(([, v]) => v > 0)
|
|
197
|
+
.sort((a, b) => b[1] - a[1])
|
|
198
|
+
.slice(0, Math.max(0, limit));
|
|
199
|
+
}
|
|
200
|
+
|
|
114
201
|
function fmt(n) {
|
|
115
202
|
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
116
203
|
}
|
|
@@ -215,17 +302,18 @@ function fmtCell(n) {
|
|
|
215
302
|
}
|
|
216
303
|
|
|
217
304
|
export {
|
|
218
|
-
CHARS_PER_TOKEN_OTHER,
|
|
219
|
-
CHARS_PER_TOKEN_CYRILLIC,
|
|
220
|
-
CHARS_PER_TOKEN_CJK,
|
|
221
|
-
MAX_SANE_LIMIT,
|
|
222
305
|
oneLine,
|
|
223
306
|
safeCell,
|
|
224
307
|
finitePos,
|
|
225
308
|
safeCount,
|
|
226
309
|
safeCountOrNull,
|
|
227
310
|
safeCategories,
|
|
311
|
+
sumCategories,
|
|
312
|
+
breakdownCategories,
|
|
228
313
|
isSafeSessionKey,
|
|
314
|
+
safeToolOutByTool,
|
|
315
|
+
toolOutEntries,
|
|
316
|
+
TOOL_OUT_MAX_KEYS,
|
|
229
317
|
fmt,
|
|
230
318
|
estimateTokens,
|
|
231
319
|
estJson,
|
package/lib/limits.js
CHANGED
package/lib/state.js
CHANGED
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
oneLine,
|
|
28
28
|
safeCount,
|
|
29
29
|
safeCountOrNull,
|
|
30
|
+
safeToolOutByTool,
|
|
31
|
+
sumCategories,
|
|
30
32
|
} from "./estimate.js";
|
|
31
33
|
import {
|
|
32
34
|
breakdownCache,
|
|
@@ -168,16 +170,6 @@ function writeLog(liveLines) {
|
|
|
168
170
|
}
|
|
169
171
|
}
|
|
170
172
|
|
|
171
|
-
// Append a single line to the file (used for final summaries / notes).
|
|
172
|
-
function appendLogLine(line) {
|
|
173
|
-
try {
|
|
174
|
-
ensureRegularFile(LOG_FILE); // refuse to follow a symlink planted at the path
|
|
175
|
-
appendFileSync(LOG_FILE, line + "\n", "utf8");
|
|
176
|
-
} catch (err) {
|
|
177
|
-
logErr("appendLogLine failed", err);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
173
|
// Synchronous sleep for the lock retry loop. Atomics.wait is the documented
|
|
182
174
|
// non-busy way to block this thread; fall back to a bounded spin if unavailable.
|
|
183
175
|
function sleepSync(ms) {
|
|
@@ -316,6 +308,7 @@ function writeStateFile(
|
|
|
316
308
|
inputTokens,
|
|
317
309
|
ctxTokens,
|
|
318
310
|
reasoningTokens,
|
|
311
|
+
opts,
|
|
319
312
|
) {
|
|
320
313
|
try {
|
|
321
314
|
if (!sessionID) return;
|
|
@@ -432,15 +425,57 @@ function writeStateFile(
|
|
|
432
425
|
? safeCount(bd.toolOutChars)
|
|
433
426
|
: safeCount(pc?.toolOut);
|
|
434
427
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
(
|
|
442
|
-
(
|
|
443
|
-
|
|
428
|
+
// Per-tool split of the toolOut category (webfetch / read / bash / …).
|
|
429
|
+
// Untrusted names -> sanitised + bounded. A present breakdown OWNS the
|
|
430
|
+
// split (an empty one means "no tool results now" — do NOT revive a stale
|
|
431
|
+
// peer value); only fall back to the persisted map when this instance has
|
|
432
|
+
// no live breakdown at all.
|
|
433
|
+
const toolOutByTool = bd
|
|
434
|
+
? safeToolOutByTool(bd.toolOutByTool)
|
|
435
|
+
: safeToolOutByTool(prevEntry?.toolOutByTool);
|
|
436
|
+
|
|
437
|
+
// Compaction staleness: a `session.compacted` write re-persists the
|
|
438
|
+
// pre-compaction numbers (the compaction flow fires no context hook), so
|
|
439
|
+
// the entry is stale BY CONSTRUCTION — even when this instance has no live
|
|
440
|
+
// breakdown to compare against (a mirror instance must not drop the
|
|
441
|
+
// signal). Later writes inherit `compactedAt` and clear the mark only once
|
|
442
|
+
// a FRESH breakdown (bd.capturedAt newer than the compaction) lands.
|
|
443
|
+
const compactedAt = opts?.compacted
|
|
444
|
+
? new Date().toISOString()
|
|
445
|
+
: typeof prevEntry?.compactedAt === "string"
|
|
446
|
+
? prevEntry.compactedAt
|
|
447
|
+
: null;
|
|
448
|
+
let compactStale = false;
|
|
449
|
+
if (opts?.compacted === true) {
|
|
450
|
+
compactStale = true;
|
|
451
|
+
} else if (compactedAt) {
|
|
452
|
+
const cMs = Date.parse(compactedAt);
|
|
453
|
+
const measuredMs =
|
|
454
|
+
bd && typeof bd.capturedAt === "number" ? bd.capturedAt : NaN;
|
|
455
|
+
if (Number.isFinite(cMs) && Number.isFinite(measuredMs)) {
|
|
456
|
+
compactStale = measuredMs <= cMs;
|
|
457
|
+
} else {
|
|
458
|
+
// No live breakdown here: keep the previous verdict (a peer instance
|
|
459
|
+
// may have measured it).
|
|
460
|
+
compactStale = prevEntry?.compactStale === true;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Residual = input minus every measured category (shared sum; null
|
|
465
|
+
// system/toolSchemas count as 0).
|
|
466
|
+
const other = Math.max(
|
|
467
|
+
0,
|
|
468
|
+
safeCount(inputTokens) -
|
|
469
|
+
sumCategories({
|
|
470
|
+
user: catUser,
|
|
471
|
+
assistant: catAssistant,
|
|
472
|
+
reasoning: catReasoning,
|
|
473
|
+
toolArgs: catToolArgs,
|
|
474
|
+
toolOut: catToolOut,
|
|
475
|
+
system: catSystem,
|
|
476
|
+
toolSchemas: catToolSchemas,
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
444
479
|
// Parent link for the /context-breakdown command. Prefer the live cache;
|
|
445
480
|
// fall back to the previously persisted value (backward compatible with
|
|
446
481
|
// snapshots written before parentID existed -> null). role mirrors it.
|
|
@@ -490,6 +525,9 @@ function writeStateFile(
|
|
|
490
525
|
toolSchemas: catToolSchemas,
|
|
491
526
|
other,
|
|
492
527
|
},
|
|
528
|
+
toolOutByTool,
|
|
529
|
+
compactedAt,
|
|
530
|
+
compactStale,
|
|
493
531
|
updatedAt: new Date().toISOString(),
|
|
494
532
|
};
|
|
495
533
|
all[sessionID] = entry;
|
|
@@ -618,19 +656,9 @@ function pushFinalSummary(claimKey, summaryLines) {
|
|
|
618
656
|
|
|
619
657
|
export {
|
|
620
658
|
logErr,
|
|
621
|
-
renderFile,
|
|
622
659
|
writeLog,
|
|
623
|
-
appendLogLine,
|
|
624
|
-
sleepSync,
|
|
625
|
-
ensureRegularFile,
|
|
626
|
-
isStaleLock,
|
|
627
|
-
acquireStateLock,
|
|
628
|
-
releaseStateLock,
|
|
629
|
-
renameWithRetry,
|
|
630
660
|
writeStateFile,
|
|
631
|
-
appendEventLog,
|
|
632
661
|
debugEvent,
|
|
633
662
|
readStateSnapshot,
|
|
634
|
-
finalSummaries,
|
|
635
663
|
pushFinalSummary,
|
|
636
664
|
};
|
package/lib/tui-data.d.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface StateEntry {
|
|
|
29
29
|
limit?: number | null
|
|
30
30
|
reasoning?: number
|
|
31
31
|
categories?: Categories
|
|
32
|
+
/** Per-tool tool-result split: tool name -> estimated tokens. */
|
|
33
|
+
toolOutByTool?: Record<string, number>
|
|
34
|
+
/** ISO time of the last session.compacted (marker only). */
|
|
35
|
+
compactedAt?: string | null
|
|
36
|
+
/** True while the entry still shows pre-compaction numbers. */
|
|
37
|
+
compactStale?: boolean
|
|
32
38
|
updatedAt?: string
|
|
33
39
|
}
|
|
34
40
|
|
|
@@ -44,3 +50,7 @@ export declare function fmt(n: number): string
|
|
|
44
50
|
export declare function num(v: unknown): number
|
|
45
51
|
export declare function fit(s: string, w: number): string
|
|
46
52
|
export declare function updatedAt(iso: string | undefined): string
|
|
53
|
+
export declare function toolOutEntries(
|
|
54
|
+
map: Record<string, number> | undefined,
|
|
55
|
+
limit?: number,
|
|
56
|
+
): Array<[string, number]>
|
package/lib/tui-data.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
//
|
|
13
13
|
// state.json shape (written by writeStateFile() in index.js, untrusted input):
|
|
14
14
|
// { [sessionID]: { sessionID, parentID, role, agent, model, providerID,
|
|
15
|
-
// ctx, input, usable, reserve, limit, reasoning, categories{...},
|
|
15
|
+
// ctx, input, usable, reserve, limit, reasoning, categories{...},
|
|
16
|
+
// toolOutByTool{name->tokens}, compactedAt, compactStale, updatedAt } }
|
|
16
17
|
// The authoritative TS mirror of the entry shape is the `StateEntry` interface
|
|
17
18
|
// in lib/tui-data.d.ts — keep the two structurally compatible.
|
|
18
19
|
|
|
@@ -20,6 +21,11 @@ import { readFileSync } from "node:fs";
|
|
|
20
21
|
import { tmpdir } from "node:os";
|
|
21
22
|
import { join } from "node:path";
|
|
22
23
|
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { isSafeSessionKey, sumCategories } from "./estimate.js";
|
|
25
|
+
|
|
26
|
+
// Re-exported so the panel keeps importing it from this module; the
|
|
27
|
+
// implementation is the shared one (lib/estimate.js) — no second copy to drift.
|
|
28
|
+
export { toolOutEntries } from "./estimate.js";
|
|
23
29
|
|
|
24
30
|
export const STATE_FILE =
|
|
25
31
|
process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
|
|
@@ -42,19 +48,6 @@ export function isNodeModulesInstall() {
|
|
|
42
48
|
}
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
// Reserved object keys must never be treated as session ids: state.json is
|
|
46
|
-
// untrusted input (structural mirror of isSafeSessionKey() in lib/estimate.js,
|
|
47
|
-
// including the string/length requirement).
|
|
48
|
-
function isSafeKey(k) {
|
|
49
|
-
return (
|
|
50
|
-
typeof k === "string" &&
|
|
51
|
-
k.length > 0 &&
|
|
52
|
-
k !== "__proto__" &&
|
|
53
|
-
k !== "constructor" &&
|
|
54
|
-
k !== "prototype"
|
|
55
|
-
)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
51
|
// Whole state.json snapshot ({ sessionID: entry }) or {} on any error. The
|
|
59
52
|
// sidebar resolves one entry from it and the breakdown panel walks the
|
|
60
53
|
// parentID family over it. Never throws — a missing / empty / corrupt
|
|
@@ -68,7 +61,7 @@ export function readSnapshot() {
|
|
|
68
61
|
// (__proto__ / constructor / prototype) and non-object entries in one pass.
|
|
69
62
|
const clean = {}
|
|
70
63
|
for (const [k, v] of Object.entries(all)) {
|
|
71
|
-
if (
|
|
64
|
+
if (isSafeSessionKey(k) && v && typeof v === "object") clean[k] = v
|
|
72
65
|
}
|
|
73
66
|
return clean
|
|
74
67
|
} catch {
|
|
@@ -82,17 +75,11 @@ export function readSnapshot() {
|
|
|
82
75
|
// a missing categories object is not the only trigger. `system` / `toolSchemas`
|
|
83
76
|
// may legitimately be null, hence the num() coercion.
|
|
84
77
|
function isEmptyEntry(e) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
num(
|
|
88
|
-
num(
|
|
89
|
-
|
|
90
|
-
num(c?.toolArgs) +
|
|
91
|
-
num(c?.toolOut) +
|
|
92
|
-
num(c?.system) +
|
|
93
|
-
num(c?.toolSchemas) +
|
|
94
|
-
num(c?.other)
|
|
95
|
-
return sum === 0 && num(e?.ctx) === 0 && num(e?.input) === 0
|
|
78
|
+
return (
|
|
79
|
+
sumCategories(e?.categories) === 0 &&
|
|
80
|
+
num(e?.ctx) === 0 &&
|
|
81
|
+
num(e?.input) === 0
|
|
82
|
+
)
|
|
96
83
|
}
|
|
97
84
|
|
|
98
85
|
// The panel row set: the root entry (panel.sessionID) plus every entry whose
|
package/lib/tui-panel.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import { mkdirSync, writeFileSync } from "node:fs"
|
|
|
11
11
|
import { tmpdir } from "node:os"
|
|
12
12
|
import { basename, join } from "node:path"
|
|
13
13
|
import type { StateEntry } from "./tui-data.js"
|
|
14
|
-
import { collectFamily, fit, fmt, num, updatedAt } from "./tui-data.js"
|
|
14
|
+
import { collectFamily, fit, fmt, num, toolOutEntries, updatedAt } from "./tui-data.js"
|
|
15
15
|
import { token } from "./tui-theme.js"
|
|
16
16
|
import {
|
|
17
17
|
buildRedactedPayload,
|
|
@@ -475,11 +475,13 @@ export function BreakdownPanel(props: {
|
|
|
475
475
|
const model = safeText(r.model && r.model !== "unknown" ? r.model : "model?")
|
|
476
476
|
const ctx = fmt(num(r.ctx))
|
|
477
477
|
const d = panelDenom(r)
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
478
|
+
const base =
|
|
479
|
+
d > 0
|
|
480
|
+
? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
|
|
481
|
+
(num(r.ctx) / d) * 100,
|
|
482
|
+
)}%)`
|
|
483
|
+
: `Context breakdown \u2014 ${model} ${ctx}`
|
|
484
|
+
return r.compactStale === true ? `${base} (stale — compacted)` : base
|
|
483
485
|
}
|
|
484
486
|
|
|
485
487
|
const layout = (): {
|
|
@@ -523,10 +525,27 @@ export function BreakdownPanel(props: {
|
|
|
523
525
|
L.gutter +
|
|
524
526
|
fit(money(costOf(e.sessionID)), L.costW) +
|
|
525
527
|
L.gutter +
|
|
526
|
-
updatedAt(e.updatedAt)
|
|
528
|
+
updatedAt(e.updatedAt) +
|
|
529
|
+
(e.compactStale === true ? " (stale)" : "")
|
|
527
530
|
)
|
|
528
531
|
}
|
|
529
532
|
|
|
533
|
+
// One compact line: the main session's tool results split by tool name
|
|
534
|
+
// (server-side aggregate; the per-call "largest contributors" block above is
|
|
535
|
+
// the measured, client-side view).
|
|
536
|
+
const byToolLine = (): string => {
|
|
537
|
+
const all = data().root?.toolOutByTool
|
|
538
|
+
const entries = toolOutEntries(all, 5)
|
|
539
|
+
if (entries.length === 0) return ""
|
|
540
|
+
const total =
|
|
541
|
+
all && typeof all === "object"
|
|
542
|
+
? Object.values(all).filter((v) => num(v) > 0).length
|
|
543
|
+
: 0
|
|
544
|
+
const parts = entries.map(([name, val]) => `${safeText(name)} ${fmt(val)}`)
|
|
545
|
+
if (total > entries.length) parts.push(`+${total - entries.length} more`)
|
|
546
|
+
return parts.join(" \u00b7 ")
|
|
547
|
+
}
|
|
548
|
+
|
|
530
549
|
return (
|
|
531
550
|
<box flexDirection="column">
|
|
532
551
|
<text fg={base()}>{header()}</text>
|
|
@@ -569,6 +588,10 @@ export function BreakdownPanel(props: {
|
|
|
569
588
|
}
|
|
570
589
|
</text>
|
|
571
590
|
</Show>
|
|
591
|
+
<Show when={byToolLine().length > 0}>
|
|
592
|
+
<text fg={base()}>{"tool results by tool"}</text>
|
|
593
|
+
<text fg={muted()}>{byToolLine()}</text>
|
|
594
|
+
</Show>
|
|
572
595
|
</Show>
|
|
573
596
|
</box>
|
|
574
597
|
)
|
package/lib/tui-sidebar.tsx
CHANGED
|
@@ -66,6 +66,9 @@ export function Sidebar(props: { entry: () => StateEntry | null }) {
|
|
|
66
66
|
: line("tool schemas", e()?.categories?.toolSchemas)}
|
|
67
67
|
</text>
|
|
68
68
|
<text fg={muted()}>{line("other", e()?.categories?.other)}</text>
|
|
69
|
+
<Show when={e()?.compactStale === true}>
|
|
70
|
+
<text fg={muted()}>{" stale — compacted"}</text>
|
|
71
|
+
</Show>
|
|
69
72
|
<text fg={muted()}>{`updated ${updatedAt(e()?.updatedAt)}`}</text>
|
|
70
73
|
</Show>
|
|
71
74
|
</box>
|
package/lib/v2.js
CHANGED
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
// from index.js.
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
+
breakdownCategories,
|
|
10
11
|
estJson,
|
|
11
12
|
estimateTokens,
|
|
12
13
|
fmt,
|
|
14
|
+
isSafeSessionKey,
|
|
13
15
|
oneLine,
|
|
14
16
|
tokenCountOf,
|
|
17
|
+
TOOL_OUT_MAX_KEYS,
|
|
15
18
|
withTimeout,
|
|
16
19
|
} from "./estimate.js";
|
|
17
20
|
import {
|
|
@@ -75,10 +78,11 @@ const V2_FALLBACK_TIMEOUT_MS = 3000;
|
|
|
75
78
|
// every schema does not run on every single model request.
|
|
76
79
|
// Cheap structural fingerprint of the tool schema map: count + total description
|
|
77
80
|
// length + number of schema property keys + two O(n) charCode checksums (mod
|
|
78
|
-
// 0xFFFFFF) over every description and over the
|
|
79
|
-
// checksums widen the fingerprint so two different
|
|
80
|
-
// the same length/key counts do not collide into a
|
|
81
|
-
// paying for full JSON.stringify (the whole point of
|
|
81
|
+
// 0xFFFFFF) over every description and over the PARAMETER names
|
|
82
|
+
// (input.properties keys). The checksums widen the fingerprint so two different
|
|
83
|
+
// schemas that happen to share the same length/key counts do not collide into a
|
|
84
|
+
// wrong cache hit — without paying for full JSON.stringify (the whole point of
|
|
85
|
+
// the cache).
|
|
82
86
|
function toolsFingerprint(tools) {
|
|
83
87
|
if (!tools || typeof tools !== "object") return "none";
|
|
84
88
|
let n = 0;
|
|
@@ -98,14 +102,16 @@ function toolsFingerprint(tools) {
|
|
|
98
102
|
const input = t?.input;
|
|
99
103
|
if (input && typeof input === "object") {
|
|
100
104
|
const props = input.properties;
|
|
101
|
-
|
|
105
|
+
// Hash the PARAMETER names (input.properties keys) when present — a
|
|
106
|
+
// property rename that keeps the same count/description must change the
|
|
107
|
+
// fingerprint, or the TTL cache would serve a stale schema estimate.
|
|
108
|
+
// Fall back to the top-level schema keys for schemas without `properties`.
|
|
109
|
+
const nameSource =
|
|
102
110
|
props && typeof props === "object"
|
|
103
|
-
? Object.keys(props)
|
|
104
|
-
: Object.keys(input)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const topKeys = Object.keys(input);
|
|
108
|
-
for (const k of topKeys) {
|
|
111
|
+
? Object.keys(props)
|
|
112
|
+
: Object.keys(input);
|
|
113
|
+
propKeys += nameSource.length;
|
|
114
|
+
for (const k of nameSource) {
|
|
109
115
|
for (let i = 0; i < k.length; i++) {
|
|
110
116
|
keyHash = (keyHash + k.charCodeAt(i)) & 0xffffff;
|
|
111
117
|
}
|
|
@@ -138,12 +144,34 @@ function toolResultChars(p) {
|
|
|
138
144
|
return estJson(r.value); // json | error | unexpected shapes
|
|
139
145
|
}
|
|
140
146
|
|
|
147
|
+
// Accumulate the per-tool tool-result estimate. Tool names are untrusted
|
|
148
|
+
// (host tool ids, plugin-defined tools): unsafe names and any name beyond the
|
|
149
|
+
// cap fold into "other tools" so the bucket never becomes a prototype vector
|
|
150
|
+
// and the map stays bounded. The sum of the map mirrors the toolOut category.
|
|
151
|
+
// The cap is the SHARED TOOL_OUT_MAX_KEYS (lib/estimate.js) so the accumulation
|
|
152
|
+
// bound and the persisted/displayed bound can never drift apart.
|
|
153
|
+
function addToolOut(map, name, chars) {
|
|
154
|
+
if (!(chars > 0)) return;
|
|
155
|
+
const safe =
|
|
156
|
+
isSafeSessionKey(name) && name.length <= 64 ? name : "other tools";
|
|
157
|
+
if (
|
|
158
|
+
safe !== "other tools" &&
|
|
159
|
+
!Object.prototype.hasOwnProperty.call(map, safe) &&
|
|
160
|
+
Object.keys(map).length >= TOOL_OUT_MAX_KEYS
|
|
161
|
+
) {
|
|
162
|
+
map["other tools"] = (map["other tools"] || 0) + chars;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
map[safe] = (map[safe] || 0) + chars;
|
|
166
|
+
}
|
|
167
|
+
|
|
141
168
|
function estimateBreakdownFromContext(context, sessionID) {
|
|
142
169
|
let userChars = 0;
|
|
143
170
|
let assistantChars = 0;
|
|
144
171
|
let reasoningChars = 0;
|
|
145
172
|
let toolArgsChars = 0;
|
|
146
173
|
let toolOutChars = 0;
|
|
174
|
+
const toolOutByTool = {};
|
|
147
175
|
|
|
148
176
|
const messages = Array.isArray(context?.messages) ? context.messages : [];
|
|
149
177
|
for (const msg of messages) {
|
|
@@ -153,7 +181,9 @@ function estimateBreakdownFromContext(context, sessionID) {
|
|
|
153
181
|
// carrying them varies by host version (user, assistant, or the dedicated
|
|
154
182
|
// "tool" role) — count them role-independently.
|
|
155
183
|
if (p?.type === "tool-result") {
|
|
156
|
-
|
|
184
|
+
const chars = toolResultChars(p);
|
|
185
|
+
toolOutChars += chars;
|
|
186
|
+
addToolOut(toolOutByTool, p?.name, chars);
|
|
157
187
|
continue;
|
|
158
188
|
}
|
|
159
189
|
if (msg?.role === "user") {
|
|
@@ -206,6 +236,7 @@ function estimateBreakdownFromContext(context, sessionID) {
|
|
|
206
236
|
reasoningChars,
|
|
207
237
|
toolArgsChars,
|
|
208
238
|
toolOutChars,
|
|
239
|
+
toolOutByTool,
|
|
209
240
|
systemChars,
|
|
210
241
|
toolSchemasChars,
|
|
211
242
|
};
|
|
@@ -259,6 +290,7 @@ function estimateBreakdownFromClientMessages(messages) {
|
|
|
259
290
|
let reasoningChars = 0;
|
|
260
291
|
let toolArgsChars = 0;
|
|
261
292
|
let toolOutChars = 0;
|
|
293
|
+
const toolOutByTool = {};
|
|
262
294
|
for (const m of messages || []) {
|
|
263
295
|
if (!m || typeof m !== "object") continue;
|
|
264
296
|
if (m.type === "user") {
|
|
@@ -282,11 +314,16 @@ function estimateBreakdownFromClientMessages(messages) {
|
|
|
282
314
|
// policy as the hook path).
|
|
283
315
|
const content = p?.state?.content;
|
|
284
316
|
if (Array.isArray(content)) {
|
|
317
|
+
let outChars = 0;
|
|
285
318
|
for (const item of content) {
|
|
286
319
|
if (item?.type === "text" && typeof item.text === "string") {
|
|
287
|
-
|
|
320
|
+
outChars += estimateTokens(item.text);
|
|
288
321
|
}
|
|
289
322
|
}
|
|
323
|
+
toolOutChars += outChars;
|
|
324
|
+
// The persisted tool part carries its name on `p.name`
|
|
325
|
+
// (SessionMessageAssistantTool.name) — same field as the hook path.
|
|
326
|
+
addToolOut(toolOutByTool, p?.name, outChars);
|
|
290
327
|
}
|
|
291
328
|
}
|
|
292
329
|
}
|
|
@@ -298,6 +335,7 @@ function estimateBreakdownFromClientMessages(messages) {
|
|
|
298
335
|
reasoningChars,
|
|
299
336
|
toolArgsChars,
|
|
300
337
|
toolOutChars,
|
|
338
|
+
toolOutByTool,
|
|
301
339
|
systemChars: null,
|
|
302
340
|
toolSchemasChars: null,
|
|
303
341
|
};
|
|
@@ -423,14 +461,9 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
|
|
|
423
461
|
const source = bd?.source || "none";
|
|
424
462
|
const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
|
|
425
463
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
(bd?.toolArgsChars || 0) +
|
|
430
|
-
(bd?.toolOutChars || 0) +
|
|
431
|
-
(bd?.systemChars || 0) +
|
|
432
|
-
(bd?.toolSchemasChars || 0);
|
|
433
|
-
const other = Math.max(0, inputTokens - sumEstimates);
|
|
464
|
+
// Residual = input minus every measured category (shared normaliser; `bd`
|
|
465
|
+
// may be absent -> everything 0 and the residual is the whole input).
|
|
466
|
+
const other = breakdownCategories(bd, inputTokens).other;
|
|
434
467
|
|
|
435
468
|
if (bd) {
|
|
436
469
|
lines.push(` user ${fmt(bd.userChars || 0)} (est tokens)`);
|
|
@@ -569,6 +602,11 @@ async function handleV2Event(ctx, event) {
|
|
|
569
602
|
lastKnownInput(sid),
|
|
570
603
|
lastKnownCtx(sid),
|
|
571
604
|
lastKnownReasoning(sid),
|
|
605
|
+
// This write re-persists the PRE-compaction snapshot (the compaction
|
|
606
|
+
// flow fires no context hook — see README): mark the entry stale so the
|
|
607
|
+
// UI shows the numbers as pre-compaction until the next model request
|
|
608
|
+
// refreshes them.
|
|
609
|
+
{ compacted: true },
|
|
572
610
|
);
|
|
573
611
|
pushFinalSummary(
|
|
574
612
|
`compacted:${event.id ?? sid}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-context-indicator",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.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",
|