opencode-context-indicator 1.1.6 → 1.2.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/README.md +95 -39
- package/index.js +8 -5
- package/lib/commands.js +15 -0
- package/lib/contributors.js +288 -0
- package/lib/limits.js +80 -23
- package/lib/state.js +16 -1
- package/package.json +3 -3
- package/tui.tsx +502 -30
package/README.md
CHANGED
|
@@ -8,8 +8,13 @@ filling it: system prompt, tool schemas, user/assistant text, reasoning, tool
|
|
|
8
8
|
arguments and the residual "other" bucket.
|
|
9
9
|
|
|
10
10
|
The package ships a **dual V1 + V2 plugin** (one entry works in both OpenCode
|
|
11
|
-
generations) plus an optional TUI
|
|
12
|
-
|
|
11
|
+
generations) plus an optional TUI module (`./tui` entry) that renders a **live
|
|
12
|
+
sidebar** and a **zero-LLM `/cx` breakdown panel** with measured contributors
|
|
13
|
+
and a redacted export. The V2 path is **tested against opencode v2.0.25**; the
|
|
14
|
+
V1 path against 1.18.29+.
|
|
15
|
+
|
|
16
|
+
> **V1 is feature-frozen.** It is kept working for compatibility (bug fixes
|
|
17
|
+
> only); all new features land in the V2 path.
|
|
13
18
|
|
|
14
19
|

|
|
15
20
|
|
|
@@ -38,11 +43,21 @@ For every served request the plugin estimates how the context window is split:
|
|
|
38
43
|
(Cyrillic ≈ 2.5, CJK ≈ 1.5, latin/ASCII ≈ 4 chars/token) — not a tokenizer.
|
|
39
44
|
`reasoning` also carries the exact value reported by the model when present.
|
|
40
45
|
* `other` is the residual (`input − sum of estimates`).
|
|
41
|
-
* Tool **results** are deliberately not counted
|
|
42
|
-
|
|
46
|
+
* Tool **results** are deliberately not counted in the server-side categories;
|
|
47
|
+
only tool **call arguments** are. The TUI panel's *largest contributors*
|
|
48
|
+
section **does** measure retained results — that is why it can explain the
|
|
49
|
+
(often large) residual `other` bucket.
|
|
43
50
|
* `ctx` is OpenCode's native overflow count: `tokens.total` when present, else
|
|
44
51
|
`input + output + cache.read + cache.write`. Percentages are **not clamped**
|
|
45
52
|
at 100% — exceeding the window stays visible.
|
|
53
|
+
* The denominator is the **opencode v2.0.23 compaction ceiling**: `window =
|
|
54
|
+
limit.input || limit.context`; `reserve = max(floor(window * 0.1), window >=
|
|
55
|
+
32_000 ? 16_000 : 0)`; the ceiling is `window - reserve`. When opencode
|
|
56
|
+
carries a `compaction.buffer` configured in its own settings, the plugin
|
|
57
|
+
cannot read that value (it is not exposed to plugins), so the default
|
|
58
|
+
reserve formula is used — set `OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER`
|
|
59
|
+
to override the reserve for testing or custom setups (positive integer <=
|
|
60
|
+
1e9; invalid values are silently ignored).
|
|
46
61
|
|
|
47
62
|
### Toast (V1 only)
|
|
48
63
|
|
|
@@ -218,23 +233,57 @@ back to a plain notice if needed.
|
|
|
218
233
|
is evicted by the `state.json` LRU cap (`MAX_TRACKED_SESSIONS`) — it may
|
|
219
234
|
momentarily appear in the table.
|
|
220
235
|
|
|
236
|
+
### TUI session panel (V2, terminal only)
|
|
237
|
+
|
|
238
|
+
#### TUI panel (/cx) — zero-LLM breakdown
|
|
239
|
+
|
|
240
|
+
`/cx` opens a full `session.panel` showing the breakdown for the current session
|
|
241
|
+
**and its subagent descendants**, read from the same `state.json` bridge as the
|
|
242
|
+
sidebar. Keys: `/cx` opens, `esc` closes, `r` refreshes (re-read the bridge),
|
|
243
|
+
`e` exports a redacted snapshot.
|
|
244
|
+
|
|
245
|
+
**Zero LLM calls.** Unlike `/context` and `/context-breakdown` (which run a real
|
|
246
|
+
agent turn so the model renders the table), the panel only reads local TUI data
|
|
247
|
+
and the state file; it never wakes the session and never grows the context.
|
|
248
|
+
|
|
249
|
+
The **largest contributors (measured)** section char-counts the in-process
|
|
250
|
+
message list and labels each row `assistant 21:04 reasoning — 4.5k "…"`: role
|
|
251
|
+
in full words, the message's own clock time, the bucket (`reasoning` /
|
|
252
|
+
`tool args (bash)` / `tool result (task)`), the token estimate and a bounded
|
|
253
|
+
one-line preview of that part. Tool calls are measured **per tool call** (each
|
|
254
|
+
carries its tool name); tool **result outputs** are counted here but not by the
|
|
255
|
+
server's categories — which is why the residual `other` can look large. Top 5
|
|
256
|
+
are shown. Empty state: `no messages yet`.
|
|
257
|
+
|
|
258
|
+
Live only for a `file://` install from a path outside `node_modules`; the npm
|
|
259
|
+
path auto-disables it (upstream `#33884`). The Desktop has no TUI, so it uses
|
|
260
|
+
`/context` and `/context-breakdown` instead.
|
|
261
|
+
|
|
262
|
+
#### Export (redacted)
|
|
263
|
+
|
|
264
|
+
`e` writes a redacted JSON snapshot — no message text, no full session id, no
|
|
265
|
+
tool output content — under `%TEMP%/opencode-context-indicator/exports/`
|
|
266
|
+
(`$TMPDIR/...` elsewhere), named `context-<id8>-<yyyymmdd-HHMMSSmmm>.json`, and
|
|
267
|
+
shows a toast with the file name. It never throws; on failure it logs and shows
|
|
268
|
+
an error toast.
|
|
269
|
+
|
|
221
270
|
---
|
|
222
271
|
|
|
223
272
|
## Surface matrix
|
|
224
273
|
|
|
225
|
-
| Surface | Toast | TUI sidebar | Breakdown log file | Slash commands |
|
|
226
|
-
| --- | :---: | :---: | :---: | :---: |
|
|
227
|
-
| OpenCode **1.x** — Desktop | ✅ | — | ✅ | — |
|
|
228
|
-
| OpenCode **1.x** — TUI | ✅ | — | ✅ | — |
|
|
229
|
-
| OpenCode **2.x** — TUI | — | ✅ | ✅ | ✅ |
|
|
230
|
-
| OpenCode **2.x** — Desktop | — | — | ✅ | ✅ |
|
|
274
|
+
| Surface | Toast | TUI sidebar | TUI panel (/cx) | Breakdown log file | Slash commands |
|
|
275
|
+
| --- | :---: | :---: | :---: | :---: | :---: |
|
|
276
|
+
| OpenCode **1.x** — Desktop | ✅ | — | — | ✅ | — |
|
|
277
|
+
| OpenCode **1.x** — TUI | ✅ | — | — | ✅ | — |
|
|
278
|
+
| OpenCode **2.x** — TUI | — | ✅ | ✅ | ✅ | ✅ |
|
|
279
|
+
| OpenCode **2.x** — Desktop | — | — | — | ✅ | ✅ |
|
|
231
280
|
|
|
232
281
|
Notes — these reflect what the plugin API actually exposes today:
|
|
233
282
|
|
|
234
283
|
* **V2 has no toast channel on the server side.** The V2 plugin API
|
|
235
284
|
(`ctx.*`) exposes no TUI/toast method (opencode issue `#49380`); the V2 path
|
|
236
|
-
therefore delivers everything through the log file, the TUI sidebar
|
|
237
|
-
`/context` / `/context-breakdown` slash commands. The sidebar is a TUI-process
|
|
285
|
+
therefore delivers everything through the log file, the TUI sidebar, the
|
|
286
|
+
`/cx` session panel and the `/context` / `/context-breakdown` slash commands. The sidebar is a TUI-process
|
|
238
287
|
slot and does not exist in the Desktop app; the slash commands work in **both**.
|
|
239
288
|
The TUI sidebar is **live only for a `file://` install** from a path outside
|
|
240
289
|
`node_modules`; on the npm path it is auto-disabled (upstream `#33884`) — see
|
|
@@ -255,7 +304,7 @@ and `$TMPDIR` (usually `/tmp`) elsewhere:
|
|
|
255
304
|
| File | Purpose |
|
|
256
305
|
| --- | --- |
|
|
257
306
|
| `context-breakdown.log` | Human-readable live snapshot + bounded final summaries |
|
|
258
|
-
| `opencode-context-indicator-state.json` | Machine-readable snapshot consumed by the TUI sidebar |
|
|
307
|
+
| `opencode-context-indicator-state.json` | Machine-readable snapshot consumed by the TUI sidebar and the `/cx` panel. Fields per entry: `sessionID`, `parentID`, `role`, `agent`, `model`, `providerID`, `ctx`, `input`, `usable` (compaction ceiling), `reserve` (tokens reserved for compaction), `limit` (context window), `reasoning`, `categories{user,assistant,reasoning,toolArgs,system,toolSchemas,other}`, `updatedAt`. |
|
|
259
308
|
| `context-events.log` | Raw event tap — **only** when `DEBUG_EVENTS` is flipped to `true` in the source (off by default) |
|
|
260
309
|
|
|
261
310
|
The live `context-breakdown.log` snapshot is **rewritten** (never grows); final
|
|
@@ -306,15 +355,15 @@ limitation (`anomalyco/opencode#33884`), not a plugin bug: OpenCode's TUI loader
|
|
|
306
355
|
skips the host Solid transform for any path inside a `node_modules` directory, so
|
|
307
356
|
a slot mounted from an npm install renders once and then never live-updates.
|
|
308
357
|
|
|
309
|
-
| Install | Desktop | TUI commands | TUI live sidebar |
|
|
310
|
-
| --- | :---: | :---: | :---: |
|
|
311
|
-
| npm (`opencode plugin add opencode-context-indicator`) | ✅ | ✅ | ❌ auto-disabled (upstream #33884) |
|
|
312
|
-
| `file://` (path **outside** any `node_modules`) | ✅ | ✅ | ✅ live |
|
|
358
|
+
| Install | Desktop | TUI commands | TUI live sidebar | TUI panel (/cx) |
|
|
359
|
+
| --- | :---: | :---: | :---: | :---: |
|
|
360
|
+
| npm (`opencode plugin add opencode-context-indicator`) | ✅ | ✅ | ❌ auto-disabled (upstream #33884) | ❌ auto-disabled |
|
|
361
|
+
| `file://` (path **outside** any `node_modules`) | ✅ | ✅ | ✅ live | ✅ live |
|
|
313
362
|
|
|
314
363
|
On the **npm** path the plugin detects that it lives under `node_modules` and
|
|
315
|
-
**disables the sidebar gracefully** — it logs a single hint and
|
|
316
|
-
slot, so you never see a frozen "no data yet" panel. The `/context`
|
|
317
|
-
`/context-breakdown` slash commands and the breakdown log file keep working on
|
|
364
|
+
**disables the sidebar and the panel gracefully** — it logs a single hint and
|
|
365
|
+
registers no slot, so you never see a frozen "no data yet" panel. The `/context`
|
|
366
|
+
/ `/context-breakdown` slash commands and the breakdown log file keep working on
|
|
318
367
|
every install.
|
|
319
368
|
|
|
320
369
|
#### Getting the live sidebar (`file://` install)
|
|
@@ -350,13 +399,21 @@ Add the package name to the `plugin` array in your `opencode.json`:
|
|
|
350
399
|
|
|
351
400
|
### Local file (no npm)
|
|
352
401
|
|
|
353
|
-
Copy `index.js` and `lib
|
|
354
|
-
the `lib/` subdirectory next to the
|
|
355
|
-
|
|
402
|
+
Copy `index.js` and the whole `lib/` directory into
|
|
403
|
+
`~/.config/opencode/plugins/`, keeping the `lib/` subdirectory next to the
|
|
404
|
+
plugin file so the plugin's relative imports resolve. The `lib/` folder is
|
|
405
|
+
support code, not a separate plugin entry:
|
|
356
406
|
|
|
357
407
|
```
|
|
358
408
|
~/.config/opencode/plugins/context-indicator.js
|
|
409
|
+
~/.config/opencode/plugins/lib/cache.js
|
|
410
|
+
~/.config/opencode/plugins/lib/commands.js
|
|
359
411
|
~/.config/opencode/plugins/lib/dedup.js
|
|
412
|
+
~/.config/opencode/plugins/lib/estimate.js
|
|
413
|
+
~/.config/opencode/plugins/lib/limits.js
|
|
414
|
+
~/.config/opencode/plugins/lib/state.js
|
|
415
|
+
~/.config/opencode/plugins/lib/v1.js
|
|
416
|
+
~/.config/opencode/plugins/lib/v2.js
|
|
360
417
|
```
|
|
361
418
|
|
|
362
419
|
To use the sidebar locally as well, point OpenCode at a `file://` copy of the
|
|
@@ -372,19 +429,21 @@ the sidebar instead.
|
|
|
372
429
|
* **Node.js ≥ 18** (the main plugin uses only Node built-ins). `@opencode/plugin`
|
|
373
430
|
is an **optional** peer dependency: OpenCode resolves it at runtime, and
|
|
374
431
|
`index.js` itself does not import it (the V2 `define` helper is inlined).
|
|
375
|
-
* **OpenCode ≥ 1.18.29** for the V1 path. ⚠️
|
|
376
|
-
|
|
377
|
-
|
|
432
|
+
* **OpenCode ≥ 1.18.29** for the V1 path. ⚠️ **V1 is feature-frozen** — it
|
|
433
|
+
receives bug fixes only; all new features land in the V2 path. The V1 path
|
|
434
|
+
relies on `experimental.chat.*` hooks, which are **experimental** and may
|
|
435
|
+
change or stop firing in future OpenCode 1.x releases; if they do, the
|
|
436
|
+
indicator degrades
|
|
378
437
|
gracefully (the breakdown falls back to throttled `session.messages` fetches
|
|
379
438
|
and toasts keep working).
|
|
380
439
|
* **OpenCode ≥ 2.0.16** for the V2 path (built and verified against
|
|
381
|
-
**2.0.
|
|
440
|
+
**2.0.25**).
|
|
382
441
|
* **TUI sidebar**: terminal TUI only, requires the OpenTUI rendering stack that
|
|
383
442
|
ships with OpenCode. `@opentui/core` and `solid-js` are **optional** peer
|
|
384
443
|
dependencies resolved by OpenCode at runtime; the main plugin installs and runs
|
|
385
444
|
fine without them (the sidebar simply is not available). `@opentui/solid` is
|
|
386
|
-
instead shipped as a **pinned direct dependency** (exact `0.5.
|
|
387
|
-
the OpenTUI stack OpenCode 2.0.
|
|
445
|
+
instead shipped as a **pinned direct dependency** (exact `0.5.16`, matching
|
|
446
|
+
the OpenTUI stack OpenCode 2.0.25 ships): OpenCode's
|
|
388
447
|
TUI loader does not expose a host instance of it, so for npm-installed plugins
|
|
389
448
|
the JSX pragma would otherwise fail to resolve `@opentui/solid/jsx-runtime`
|
|
390
449
|
(upstream: opencode issue #33884 — `node_modules` plugins are excluded from the
|
|
@@ -420,7 +479,10 @@ The package default export is a dual plugin:
|
|
|
420
479
|
experimental transform hooks + toasts.
|
|
421
480
|
|
|
422
481
|
Both paths write the same breakdown log; the V2 path additionally feeds the
|
|
423
|
-
sidebar through `opencode-context-indicator-state.json`.
|
|
482
|
+
sidebar and the `/cx` panel through `opencode-context-indicator-state.json`.
|
|
483
|
+
The `./tui` entry (OpenCode 2.x terminal TUI) renders the sidebar slot and the
|
|
484
|
+
`session.panel` contribution (`/cx`), with measured per-part contributors and
|
|
485
|
+
the redacted export. Cross-instance
|
|
424
486
|
duplicate final summaries are suppressed with an atomic claim marker
|
|
425
487
|
(`lib/dedup.js`). Every file / estimate / subagent path is fault-tolerant: a
|
|
426
488
|
failure is logged and never breaks the main event or toast path.
|
|
@@ -430,14 +492,6 @@ This plugin does not touch `opencode-token-monitor`
|
|
|
430
492
|
|
|
431
493
|
---
|
|
432
494
|
|
|
433
|
-
## Screenshots
|
|
434
|
-
|
|
435
|
-
<!-- TODO: add screenshots -->
|
|
436
|
-
|
|
437
|
-
_(to be added)_
|
|
438
|
-
|
|
439
|
-
---
|
|
440
|
-
|
|
441
495
|
## Contributing
|
|
442
496
|
|
|
443
497
|
Issues and pull requests are welcome. Please keep changes minimal and
|
|
@@ -468,10 +522,12 @@ STEP 80 s, ~3 min total.
|
|
|
468
522
|
|
|
469
523
|
| Step | What | Why |
|
|
470
524
|
|---|---|---|
|
|
471
|
-
| (a) tarball composition | `package.json` `files[]`
|
|
525
|
+
| (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 |
|
|
472
526
|
| (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 |
|
|
473
527
|
| (c) resolve chain | replica install → `import.meta.resolve` for all tui.tsx imports | broken peer/optional dep silently breaks the sidebar |
|
|
474
528
|
| (d) unit harness T1–T12 | all limit/agent/state fix assertions | regressions in modelLimits, writeStateFile, hydrate |
|
|
529
|
+
| (e) corporate-identifier scan | no forbidden identifiers in the published surface + test tree | compliance |
|
|
530
|
+
| (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in any repo file | English-only source rule |
|
|
475
531
|
|
|
476
532
|
**Isolated E2E — `test/e2e-tui.mjs`** (requires the OpenCode CLI)
|
|
477
533
|
|
package/index.js
CHANGED
|
@@ -112,8 +112,8 @@
|
|
|
112
112
|
* Every live/final log write ALSO rewrites
|
|
113
113
|
* %TEMP%\opencode-context-indicator-state.json (os.tmpdir())
|
|
114
114
|
* — a machine-readable { sessionID: { model, providerID, ctx, usable,
|
|
115
|
-
* limit, categories{...}, updatedAt } } snapshot consumed by the
|
|
116
|
-
* TUI sidebar in ./tui.tsx (package entry "./tui"). Written atomically
|
|
115
|
+
* reserve, limit, categories{...}, updatedAt } } snapshot consumed by the
|
|
116
|
+
* optional TUI sidebar in ./tui.tsx (package entry "./tui"). Written atomically
|
|
117
117
|
* (temp + rename), keyed by session, bounded to MAX_TRACKED_SESSIONS, and
|
|
118
118
|
* strictly additive: any failure is swallowed and never affects the log.
|
|
119
119
|
*
|
|
@@ -123,9 +123,10 @@
|
|
|
123
123
|
*
|
|
124
124
|
* - Metric: the native opencode overflow count — tokens.total when present,
|
|
125
125
|
* else input + output + cache.read + cache.write — compared against the
|
|
126
|
-
* model's
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
* model's compaction ceiling (opencode v2.0.23 calculateCeiling): window =
|
|
127
|
+
* limit.input || limit.context, reserve = max(10% of window, 16k when
|
|
128
|
+
* window >= 32k), usable = window - reserve. Percentages are NOT
|
|
129
|
+
* clamped at 100% - exceeding the ceiling must stay visible.
|
|
129
130
|
* - Category estimates use a unicode-aware heuristic (see estimateTokens):
|
|
130
131
|
* Cyrillic ~2.5 chars/token, CJK ~1.5, latin/ASCII ~4. These are ESTIMATES,
|
|
131
132
|
* not a tokenizer; "other" is the residual input − sum of estimates.
|
|
@@ -165,6 +166,7 @@ import {
|
|
|
165
166
|
getUsableContext,
|
|
166
167
|
hydrateModelLimitsFromState,
|
|
167
168
|
modelLimits,
|
|
169
|
+
reserveFor,
|
|
168
170
|
} from "./lib/limits.js";
|
|
169
171
|
import { sessionAgentCache, sessionParentCache } from "./lib/cache.js";
|
|
170
172
|
import {
|
|
@@ -189,6 +191,7 @@ export {
|
|
|
189
191
|
hydrateModelLimitsFromState,
|
|
190
192
|
getUsableContext,
|
|
191
193
|
getModelLimit,
|
|
194
|
+
reserveFor,
|
|
192
195
|
collectSessionReport,
|
|
193
196
|
renderSessionRow,
|
|
194
197
|
shortSummaryText,
|
package/lib/commands.js
CHANGED
|
@@ -272,6 +272,12 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
272
272
|
const usableInfo = getUsableContext(providerID, modelID);
|
|
273
273
|
const usable =
|
|
274
274
|
usableInfo?.usable ?? entry?.usable ?? getModelLimit(providerID, modelID) ?? null;
|
|
275
|
+
const reserve =
|
|
276
|
+
usableInfo?.reserved != null
|
|
277
|
+
? usableInfo.reserved
|
|
278
|
+
: Number.isFinite(entry?.reserve) && entry.reserve > 0
|
|
279
|
+
? entry.reserve
|
|
280
|
+
: null;
|
|
275
281
|
const parentID = entry?.parentID ?? null;
|
|
276
282
|
const updatedAt =
|
|
277
283
|
entry?.updatedAt ||
|
|
@@ -284,6 +290,7 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
284
290
|
providerID,
|
|
285
291
|
ctxTokens,
|
|
286
292
|
usable,
|
|
293
|
+
reserve,
|
|
287
294
|
categories,
|
|
288
295
|
source,
|
|
289
296
|
updatedAt,
|
|
@@ -384,6 +391,14 @@ function shortSummaryText(r) {
|
|
|
384
391
|
} else {
|
|
385
392
|
lines.push("_category breakdown unavailable (no cache/state entry yet)_");
|
|
386
393
|
}
|
|
394
|
+
// Compact-at line: show the new ceiling and, when known, the reserve.
|
|
395
|
+
if (r.usable && r.usable > 0) {
|
|
396
|
+
let compact = `compact at ${fmt(r.usable)}`;
|
|
397
|
+
if (r.reserve != null && r.reserve > 0) {
|
|
398
|
+
compact += ` (${fmt(r.reserve)} reserve)`;
|
|
399
|
+
}
|
|
400
|
+
lines.push(compact);
|
|
401
|
+
}
|
|
387
402
|
lines.push(`updated ${timeOnly(r.updatedAt)} (${sourceTag(r.source)})`);
|
|
388
403
|
return lines.join("\n");
|
|
389
404
|
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// lib/contributors.js
|
|
2
|
+
//
|
|
3
|
+
// Pure contributor-measurement helpers extracted from tui.tsx for unit testing.
|
|
4
|
+
// All functions are stateless transforms of their arguments.
|
|
5
|
+
//
|
|
6
|
+
// Extracted 1:1 (behaviour + WHY comments, stripped of TS syntax):
|
|
7
|
+
// estimateTokensLocal, asRecord, safeText, measureContributors,
|
|
8
|
+
// contributorLine, exportFileName, buildRedactedPayload, panelDenom, panelRole.
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Token estimation (mirrors lib/estimate.js but self-contained — tui.tsx
|
|
12
|
+
// loads standalone and must NOT import lib/*).
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
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
|
+
// Live message data is untrusted: coerce every field defensively, never throw.
|
|
49
|
+
function asRecord(v) {
|
|
50
|
+
return v && typeof v === "object" ? v : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// One-line sanitisation for untrusted labels (agent / model). Drops zero-width
|
|
54
|
+
// + bidi control chars, collapses C0/C1 controls, caps at 32 code points
|
|
55
|
+
// (surrogate-safe). Only labels are ever rendered — never message content.
|
|
56
|
+
function safeText(v) {
|
|
57
|
+
try {
|
|
58
|
+
const s = v == null ? "" : String(v);
|
|
59
|
+
const cleaned = s
|
|
60
|
+
.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, "")
|
|
61
|
+
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g, " ");
|
|
62
|
+
const cps = Array.from(cleaned);
|
|
63
|
+
return cps.length > 32 ? cps.slice(0, 31).join("") + "~" : cleaned;
|
|
64
|
+
} catch {
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Contributor measurement
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
// Char counts per (message, bucket). `asst.text` = assistant text,
|
|
74
|
+
// `asst.rsn` = reasoning, `asst.tool` = tool CALL arguments (streaming raw
|
|
75
|
+
// string or JSON.stringify of the input object), `asst.result` = tool RESULT
|
|
76
|
+
// outputs (text content of completed/error tool states). Sorted descending by
|
|
77
|
+
// estimated tokens.
|
|
78
|
+
//
|
|
79
|
+
// Returns an array of plain objects (no class/prototype dependency):
|
|
80
|
+
// { kind, ref, chars, tokensEstimate, tool, preview }
|
|
81
|
+
function measureContributors(messages) {
|
|
82
|
+
const out = [];
|
|
83
|
+
// Bounded preview: control chars collapse to spaces, bidi/zero-width stripped,
|
|
84
|
+
// capped at 26 code points with a "~" tail. Local rendering only.
|
|
85
|
+
const previewOf = (sample) => {
|
|
86
|
+
const cleaned = sample
|
|
87
|
+
.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, "")
|
|
88
|
+
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, " ")
|
|
89
|
+
.trim();
|
|
90
|
+
const cps = Array.from(cleaned);
|
|
91
|
+
return cps.length > 26 ? cps.slice(0, 25).join("") + "~" : cleaned;
|
|
92
|
+
};
|
|
93
|
+
const add = (kind, ref, sample, tool, previewOverride) => {
|
|
94
|
+
if (sample.length === 0) return;
|
|
95
|
+
out.push({
|
|
96
|
+
kind,
|
|
97
|
+
ref,
|
|
98
|
+
chars: sample.length,
|
|
99
|
+
tokensEstimate: estimateTokensLocal(sample),
|
|
100
|
+
tool: tool || "",
|
|
101
|
+
preview: previewOverride !== undefined ? previewOverride : previewOf(sample),
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
// Human clock ref: "21:04" from the message's own creation time — far more
|
|
105
|
+
// actionable than an array ordinal. Falls back to "#N" when time is absent.
|
|
106
|
+
const hhmm = (ms) => {
|
|
107
|
+
if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0) return "";
|
|
108
|
+
try {
|
|
109
|
+
const d = new Date(ms);
|
|
110
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
111
|
+
const pad = (x) => String(x).padStart(2, "0");
|
|
112
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
113
|
+
} catch {
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
for (let i = 0; i < messages.length; i++) {
|
|
118
|
+
const m = asRecord(messages[i]);
|
|
119
|
+
if (!m) continue;
|
|
120
|
+
const timeRec = asRecord(m.time);
|
|
121
|
+
const ref = hhmm(timeRec && timeRec.created) || `#${i + 1}`;
|
|
122
|
+
const type = typeof m.type === "string" ? m.type : "";
|
|
123
|
+
if (type === "user") {
|
|
124
|
+
if (typeof m.text === "string") add("user.text", ref, m.text);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (type !== "assistant") continue;
|
|
128
|
+
const content = Array.isArray(m.content) ? m.content : [];
|
|
129
|
+
let text = "";
|
|
130
|
+
let rsn = "";
|
|
131
|
+
for (const rawPart of content) {
|
|
132
|
+
const part = asRecord(rawPart);
|
|
133
|
+
if (!part) continue;
|
|
134
|
+
const pt = typeof part.type === "string" ? part.type : "";
|
|
135
|
+
if (pt === "text") {
|
|
136
|
+
if (typeof part.text === "string") text += part.text;
|
|
137
|
+
} else if (pt === "reasoning") {
|
|
138
|
+
if (typeof part.text === "string") rsn += part.text;
|
|
139
|
+
} else if (pt === "tool") {
|
|
140
|
+
const state = asRecord(part.state);
|
|
141
|
+
if (!state) continue;
|
|
142
|
+
// Per-tool-part contributors: each tool call carries its own name and
|
|
143
|
+
// measured size (one message can hold several tool calls). The raw
|
|
144
|
+
// args JSON is measured but NOT previewed — the tool name is the
|
|
145
|
+
// useful identifier; result text previews fine.
|
|
146
|
+
const toolName =
|
|
147
|
+
typeof part.name === "string" && part.name ? safeText(part.name) : "";
|
|
148
|
+
let toolArgs = "";
|
|
149
|
+
let result = "";
|
|
150
|
+
if (typeof state.input === "string") {
|
|
151
|
+
toolArgs += state.input;
|
|
152
|
+
} else if (state.input != null) {
|
|
153
|
+
try {
|
|
154
|
+
toolArgs += JSON.stringify(state.input) || "";
|
|
155
|
+
} catch {
|
|
156
|
+
/* unstringifiable input: skip its chars */
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const c = state.content;
|
|
160
|
+
if (Array.isArray(c)) {
|
|
161
|
+
for (const rawItem of c) {
|
|
162
|
+
const item = asRecord(rawItem);
|
|
163
|
+
if (item && item.type === "text" && typeof item.text === "string")
|
|
164
|
+
result += item.text;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
add("asst.tool", ref, toolArgs, toolName, ""); // raw args: no preview
|
|
168
|
+
add("asst.result", ref, result, toolName);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
add("asst.text", ref, text);
|
|
172
|
+
add("asst.rsn", ref, rsn);
|
|
173
|
+
}
|
|
174
|
+
return out.sort((a, b) => b.tokensEstimate - a.tokensEstimate);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Human line for one measured contributor: role in full words, the message's
|
|
178
|
+
// own clock time, the bucket (with the tool name for tool buckets), the token
|
|
179
|
+
// estimate, and a bounded single-line preview. Never contains a full session id
|
|
180
|
+
// or file paths.
|
|
181
|
+
function contributorLine(c, rank) {
|
|
182
|
+
const dot = c.kind.indexOf(".");
|
|
183
|
+
const who = dot >= 0 ? c.kind.slice(0, dot) : c.kind;
|
|
184
|
+
const bucket = dot >= 0 ? c.kind.slice(dot + 1) : "";
|
|
185
|
+
const role = who === "asst" ? "assistant" : who;
|
|
186
|
+
const what =
|
|
187
|
+
bucket === "rsn"
|
|
188
|
+
? "reasoning"
|
|
189
|
+
: bucket === "tool"
|
|
190
|
+
? "tool args"
|
|
191
|
+
: bucket === "result"
|
|
192
|
+
? "tool result"
|
|
193
|
+
: "";
|
|
194
|
+
const tool = c.tool ? ` (${c.tool})` : "";
|
|
195
|
+
const whatFull = what ? `${what}${tool}` : "";
|
|
196
|
+
const head = `${rank}. ${role} ${c.ref}${whatFull ? ` ${whatFull}` : ""} \u2014 ${c.tokensEstimate}`;
|
|
197
|
+
return c.preview ? `${head} "${c.preview}"` : head;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Redacted export helpers (pure)
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
// Filename for a redacted export snapshot:
|
|
205
|
+
// context-<id8>-<yyyymmdd-HHMMSSmmm>.json
|
|
206
|
+
function exportFileName(sessionID, now) {
|
|
207
|
+
const id8 = String(sessionID).slice(0, 8);
|
|
208
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
209
|
+
const stamp =
|
|
210
|
+
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
|
211
|
+
`-${pad(now.getHours())}${pad(now.getMinutes())}` +
|
|
212
|
+
`${pad(now.getSeconds())}${String(now.getMilliseconds()).padStart(3, "0")}`;
|
|
213
|
+
return `context-${id8}-${stamp}.json`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Build the redacted export payload. All IO (mkdirSync/writeFileSync) and
|
|
217
|
+
// toasts stay in tui.tsx as the thin exportRedacted() wrapper.
|
|
218
|
+
//
|
|
219
|
+
// Fields: generatedAt, session{id8-prefixed id, model, providerID},
|
|
220
|
+
// family[role/model/ctx/usable/limit/pct/updated], contributors[kind/tool/ref/
|
|
221
|
+
// chars/tokensEstimate]. NO message text, NO full session id, NO tool output
|
|
222
|
+
// content, NO contributor preview text.
|
|
223
|
+
//
|
|
224
|
+
// panelDenom / panelRole are passed as parameters so this file stays pure and
|
|
225
|
+
// tui.tsx controls their source.
|
|
226
|
+
function buildRedactedPayload({ sessionID, root, rows, contributors, now, panelDenom, panelRole, num }) {
|
|
227
|
+
const id8 = String(sessionID).slice(0, 8);
|
|
228
|
+
return {
|
|
229
|
+
generatedAt: now.toISOString(),
|
|
230
|
+
session: {
|
|
231
|
+
id: `${id8}...`,
|
|
232
|
+
model: safeText(root && root.model != null ? root.model : ""),
|
|
233
|
+
providerID: safeText(root && root.providerID != null ? root.providerID : ""),
|
|
234
|
+
},
|
|
235
|
+
family: rows.map((e, i) => {
|
|
236
|
+
const d = panelDenom(e);
|
|
237
|
+
const ctxVal = num(e && e.ctx);
|
|
238
|
+
return {
|
|
239
|
+
role: panelRole(e, i === 0),
|
|
240
|
+
model: safeText(e && e.model != null ? e.model : ""),
|
|
241
|
+
ctx: ctxVal,
|
|
242
|
+
usable: e && e.usable != null ? num(e.usable) : null,
|
|
243
|
+
limit: e && e.limit != null ? num(e.limit) : null,
|
|
244
|
+
pct: d > 0 ? Math.round((ctxVal / d) * 100) : null,
|
|
245
|
+
updated: safeText(e && e.updatedAt != null ? e.updatedAt : ""),
|
|
246
|
+
};
|
|
247
|
+
}),
|
|
248
|
+
contributors: contributors.map((c) => ({
|
|
249
|
+
kind: c.kind,
|
|
250
|
+
tool: c.tool || null,
|
|
251
|
+
ref: c.ref,
|
|
252
|
+
chars: c.chars,
|
|
253
|
+
tokensEstimate: c.tokensEstimate,
|
|
254
|
+
})),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Panel helpers (pure, shared with tui.tsx)
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
// Per-row denominator with the same fallback cascade as the server table:
|
|
263
|
+
// usable, else limit, else 0.
|
|
264
|
+
function panelDenom(e) {
|
|
265
|
+
const u = e && typeof e.usable === "number" && e.usable > 0 ? e.usable : 0;
|
|
266
|
+
if (u > 0) return u;
|
|
267
|
+
const l = e && typeof e.limit === "number" && e.limit > 0 ? e.limit : 0;
|
|
268
|
+
return l;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Role label for a state entry: "main" for root, "sub" or "sub:<agent>" for
|
|
272
|
+
// descendants.
|
|
273
|
+
function panelRole(e, isMain) {
|
|
274
|
+
if (isMain) return "main";
|
|
275
|
+
return e && e.agent ? `sub:${safeText(e.agent)}` : "sub";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export {
|
|
279
|
+
estimateTokensLocal,
|
|
280
|
+
asRecord,
|
|
281
|
+
safeText,
|
|
282
|
+
measureContributors,
|
|
283
|
+
contributorLine,
|
|
284
|
+
exportFileName,
|
|
285
|
+
buildRedactedPayload,
|
|
286
|
+
panelDenom,
|
|
287
|
+
panelRole,
|
|
288
|
+
};
|