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 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 sidebar module exposed as the `./tui` entry.
12
- The V2 path is **tested against opencode v2.0.21**; the V1 path against 1.18.29+.
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
  ![TUI sidebar — per-category context breakdown](docs/sidebar.png)
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; only tool **call arguments**
42
- (results are not part of the sent prompt).
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 and the
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 registers no
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/dedup.js` into `~/.config/opencode/plugins/`, keeping
354
- the `lib/` subdirectory next to the plugin file so the plugin's `import` of the
355
- helper resolves. The `lib/` folder is support code, not a separate plugin entry:
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. ⚠️ The V1 path relies on
376
- `experimental.chat.*` hooks, which are **experimental** and may change or stop
377
- firing in future OpenCode 1.x releases; if they do, the indicator degrades
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.21**).
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.14`, matching
387
- the OpenTUI stack OpenCode 2.0.21 ships): OpenCode's
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`. Cross-instance
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[]` field expands to exactly 6 members | no test/node_modules/tsconfig in the tarball |
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 optional
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 usable window (limit.input − reserved, else limit.context −
127
- * maxOutput). Percentages are NOT clamped at 100% — exceeding the window
128
- * must stay visible.
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
+ };