opencode-context-indicator 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/estimate.js CHANGED
@@ -81,6 +81,18 @@ function safeCountOrNull(v) {
81
81
  return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
82
82
  }
83
83
 
84
+ // A finite, non-negative, SANE number or null — for persisted provider-reported
85
+ // figures (cache read/write). Like safeCountOrNull but also rejects absurd
86
+ // magnitudes, so a poisoned state.json cannot render garbage (fmt(NaN), 1e15).
87
+ function safeSaneOrNull(v) {
88
+ return typeof v === "number" &&
89
+ Number.isFinite(v) &&
90
+ v >= 0 &&
91
+ v <= MAX_SANE_LIMIT
92
+ ? v
93
+ : null;
94
+ }
95
+
84
96
  // Normalise a categories object from untrusted input (state.json) into the 8
85
97
  // known numeric fields — never trust its shape. `toolOut` is 0 for legacy
86
98
  // entries written before the category existed (their tool results stay folded
@@ -99,6 +111,53 @@ function safeCategories(raw) {
99
111
  };
100
112
  }
101
113
 
114
+ // A session key safe to use as an object property / map key. Rejects the
115
+ // prototype-pollution vectors ("__proto__", "constructor", "prototype").
116
+ // Sum of the 8 canonical category fields (null `system`/`toolSchemas` count as
117
+ // 0). The single place the "total measured tokens" is derived — shared by the
118
+ // residual-`other` computation, the continuity test and the log/summary writers.
119
+ function sumCategories(c) {
120
+ if (!c || typeof c !== "object") return 0;
121
+ return (
122
+ safeCount(c.user) +
123
+ safeCount(c.assistant) +
124
+ safeCount(c.reasoning) +
125
+ safeCount(c.toolArgs) +
126
+ safeCount(c.toolOut) +
127
+ safeCount(c.system) +
128
+ safeCount(c.toolSchemas) +
129
+ safeCount(c.other)
130
+ );
131
+ }
132
+
133
+ // Normalise a breakdown record (cache record with *Chars keys, or a persisted
134
+ // state entry with canonical keys) into the 8 categories, deriving the residual
135
+ // `other` from `inputTokens`. Pure; the one converter shared by the V2 live log
136
+ // (lib/v2.js) and the slash-command report (lib/commands.js).
137
+ function breakdownCategories(rec, inputTokens) {
138
+ const user = rec?.userChars ?? rec?.user ?? 0;
139
+ const assistant = rec?.assistantChars ?? rec?.assistant ?? 0;
140
+ const reasoning = rec?.reasoningChars ?? rec?.reasoning ?? 0;
141
+ const toolArgs = rec?.toolArgsChars ?? rec?.toolArgs ?? 0;
142
+ const toolOut = rec?.toolOutChars ?? rec?.toolOut ?? 0;
143
+ const system =
144
+ rec?.systemChars !== undefined ? rec.systemChars : (rec?.system ?? null);
145
+ const toolSchemas =
146
+ rec?.toolSchemasChars !== undefined
147
+ ? rec.toolSchemasChars
148
+ : (rec?.toolSchemas ?? null);
149
+ const sum =
150
+ user +
151
+ assistant +
152
+ reasoning +
153
+ toolArgs +
154
+ toolOut +
155
+ (system || 0) +
156
+ (toolSchemas || 0);
157
+ const other = Math.max(0, (inputTokens || 0) - sum);
158
+ return { user, assistant, reasoning, toolArgs, toolOut, system, toolSchemas, other };
159
+ }
160
+
102
161
  // A session key safe to use as an object property / map key. Rejects the
103
162
  // prototype-pollution vectors ("__proto__", "constructor", "prototype").
104
163
  function isSafeSessionKey(k) {
@@ -111,6 +170,250 @@ function isSafeSessionKey(k) {
111
170
  );
112
171
  }
113
172
 
173
+ // Bound on the number of distinct tool names kept in a per-tool tool-result
174
+ // map (state.json / context payload are untrusted and a plugin could define an
175
+ // unbounded tool set). Everything past the cap — plus any unsafe name — is
176
+ // folded into a single "other tools" bucket so the total still reconciles with
177
+ // the toolOut category.
178
+ const TOOL_OUT_MAX_KEYS = 24;
179
+ // Normalise a per-tool tool-result map (tool name -> estimated tokens) from
180
+ // untrusted input. Names become object keys AND display cells, so prototype
181
+ // vectors are dropped and the map is bounded (see TOOL_OUT_MAX_KEYS). The
182
+ // overflow bucket is named "other tools" — a real tool with that exact name
183
+ // would merge into it (accepted: it is a display-only label).
184
+ function safeToolOutByTool(raw, maxKeys = TOOL_OUT_MAX_KEYS) {
185
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
186
+ const out = {};
187
+ let rest = 0;
188
+ let used = 0;
189
+ for (const [k, v] of Object.entries(raw)) {
190
+ const val = safeCount(v);
191
+ if (!isSafeSessionKey(k) || used >= maxKeys) {
192
+ rest += val;
193
+ continue;
194
+ }
195
+ out[k] = (out[k] || 0) + val;
196
+ used++;
197
+ }
198
+ if (rest > 0) out["other tools"] = (out["other tools"] || 0) + rest;
199
+ return Object.keys(out).length > 0 ? out : null;
200
+ }
201
+
202
+ // Descending [name, tokens] pairs for display, capped at `limit`; empty when
203
+ // the map carries no positive rows.
204
+ function toolOutEntries(map, limit = 8) {
205
+ if (!map || typeof map !== "object" || Array.isArray(map)) return [];
206
+ return Object.entries(map)
207
+ .map(([k, v]) => [k, safeCount(v)])
208
+ .filter(([, v]) => v > 0)
209
+ .sort((a, b) => b[1] - a[1])
210
+ .slice(0, Math.max(0, limit));
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Waste detection: find redundant tool output already sitting in the context.
215
+ // Pure transforms — the caller supplies per-call instances captured from the
216
+ // request payload; nothing here touches the fs or the caches.
217
+ // ---------------------------------------------------------------------------
218
+
219
+ // Best-effort TARGET of a tool call, from the per-call input. A short, stable
220
+ // string used only to group repeated calls (the same file read twice) and to
221
+ // label a finding. Returns null when no suitable string field exists. The value
222
+ // is untrusted (tool args); it is whitespace-collapsed and bounded here, and
223
+ // display layers sanitise it further.
224
+ const TARGET_FIELD_MAP = {
225
+ webfetch: ["url", "uri"],
226
+ fetch: ["url", "uri"],
227
+ bash: ["command", "cmd"],
228
+ shell: ["command", "cmd"],
229
+ grep: ["pattern", "query", "regex", "path"],
230
+ search: ["query", "pattern"],
231
+ glob: ["pattern", "path"],
232
+ read: ["filePath", "file", "path", "filename"],
233
+ read_file: ["filePath", "file", "path", "filename"],
234
+ write: ["filePath", "file", "path", "filename"],
235
+ edit: ["filePath", "file", "path", "filename"],
236
+ list: ["path", "dir"],
237
+ ls: ["path", "dir"],
238
+ task: ["description", "prompt"],
239
+ agent: ["description", "prompt"],
240
+ };
241
+ function extractToolTarget(name, input) {
242
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
243
+ const n = typeof name === "string" ? name.toLowerCase() : "";
244
+ const fields = TARGET_FIELD_MAP[n];
245
+ const pick = (keys) => {
246
+ for (const k of keys) {
247
+ const v = input[k];
248
+ if (typeof v === "string" && v.length > 0) return v;
249
+ }
250
+ return null;
251
+ };
252
+ let t = fields ? pick(fields) : null;
253
+ if (t == null) {
254
+ // Generic fallback: the first non-empty string-valued field. Bounded scan
255
+ // (the input object is untrusted) so a crafted huge input cannot stall us.
256
+ let scanned = 0;
257
+ for (const k of Object.keys(input)) {
258
+ if (++scanned > 32) break;
259
+ const v = input[k];
260
+ if (typeof v === "string" && v.length > 0) {
261
+ t = v;
262
+ break;
263
+ }
264
+ }
265
+ }
266
+ if (typeof t !== "string" || t.length === 0) return null;
267
+ // Slice BEFORE the whitespace pass: normalising an untrusted multi-MB string
268
+ // is the expensive part, so cap the work first.
269
+ return t.slice(0, 400).replace(/\s+/g, " ").trim().slice(0, 200);
270
+ }
271
+
272
+ // Cheap content fingerprint of a tool RESULT (length + FNV-1a over the first
273
+ // 4KB). Used to detect byte-identical outputs already in context. Not
274
+ // cryptographic — deliberately cheap (runs on every model request).
275
+ function contentFingerprint(text) {
276
+ if (typeof text !== "string" || text.length === 0) return "";
277
+ const s = text.length > 4096 ? text.slice(0, 4096) : text;
278
+ let h = 2166136261;
279
+ for (let i = 0; i < s.length; i++) {
280
+ h ^= s.charCodeAt(i);
281
+ h = Math.imul(h, 16777619);
282
+ }
283
+ return `${text.length}:${(h >>> 0).toString(36)}`;
284
+ }
285
+
286
+ // Group per-call instances and estimate the tool-output tokens that are
287
+ // redundant (the same target invoked repeatedly, or byte-identical outputs).
288
+ // `instances`: [{ name, target, tokens, fp }]. Reclaim = the sum of the
289
+ // NON-largest member of every group of size >= 2, unioned across target- and
290
+ // content-groups so an instance is counted once. This is a LOWER bound of what
291
+ // compaction/trim could reclaim — labelled as an estimate everywhere.
292
+ function computeReclaim(instances, maxFindings = 8) {
293
+ const list = Array.isArray(instances) ? instances : [];
294
+ const byTarget = new Map();
295
+ const byContent = new Map();
296
+ const push = (map, key, idx) => {
297
+ const a = map.get(key);
298
+ if (a) a.push(idx);
299
+ else map.set(key, [idx]);
300
+ };
301
+ for (let i = 0; i < list.length; i++) {
302
+ const it = list[i];
303
+ if (safeCount(it?.tokens) <= 0) continue;
304
+ const name = typeof it?.name === "string" && it.name ? it.name : "?";
305
+ const target =
306
+ typeof it?.target === "string" && it.target ? it.target : null;
307
+ if (target) push(byTarget, `${name}\u0000${target}`, i);
308
+ const fp = typeof it?.fp === "string" && it.fp ? it.fp : "";
309
+ if (fp) push(byContent, fp, i);
310
+ }
311
+ // Resolve each group's keeper (largest member) once.
312
+ const buildGroups = (map) => {
313
+ const out = [];
314
+ for (const idxs of map.values()) {
315
+ if (idxs.length < 2) continue;
316
+ let maxI = idxs[0];
317
+ for (const i of idxs) {
318
+ if (safeCount(list[i]?.tokens) > safeCount(list[maxI]?.tokens)) maxI = i;
319
+ }
320
+ let groupTokens = 0;
321
+ for (const i of idxs) {
322
+ if (i !== maxI) groupTokens += safeCount(list[i]?.tokens);
323
+ }
324
+ out.push({ idxs, maxI, groupTokens });
325
+ }
326
+ out.sort((a, b) => b.groupTokens - a.groupTokens);
327
+ return out;
328
+ };
329
+ const targetGroups = buildGroups(byTarget);
330
+ const contentGroups = buildGroups(byContent);
331
+
332
+ // The authoritative total: every instance that is NOT the largest member of
333
+ // at least one duplicate group, counted ONCE (union across group kinds).
334
+ const removable = new Set();
335
+ for (const g of targetGroups) {
336
+ for (const i of g.idxs) if (i !== g.maxI) removable.add(i);
337
+ }
338
+ for (const g of contentGroups) {
339
+ for (const i of g.idxs) if (i !== g.maxI) removable.add(i);
340
+ }
341
+ let tokens = 0;
342
+ for (const i of removable) tokens += safeCount(list[i]?.tokens);
343
+
344
+ // Findings: target groups first (they carry the target label), then content
345
+ // groups only for instances not already covered — so the rendered findings
346
+ // never sum to more than the headline total.
347
+ const findings = [];
348
+ const covered = new Set();
349
+ for (const g of targetGroups) {
350
+ const rep = list[g.maxI] || {};
351
+ findings.push({
352
+ kind: "target",
353
+ name: typeof rep.name === "string" ? rep.name : "?",
354
+ target: typeof rep.target === "string" ? rep.target : null,
355
+ n: g.idxs.length,
356
+ tokens: g.groupTokens,
357
+ });
358
+ for (const i of g.idxs) covered.add(i);
359
+ }
360
+ for (const g of contentGroups) {
361
+ let t = 0;
362
+ for (const i of g.idxs) {
363
+ if (i === g.maxI || covered.has(i)) continue;
364
+ t += safeCount(list[i]?.tokens);
365
+ }
366
+ if (t > 0) {
367
+ const rep = list[g.maxI] || {};
368
+ findings.push({
369
+ kind: "content",
370
+ name: typeof rep.name === "string" ? rep.name : "?",
371
+ target: null,
372
+ n: g.idxs.length,
373
+ tokens: t,
374
+ });
375
+ }
376
+ }
377
+ findings.sort((a, b) => b.tokens - a.tokens);
378
+ return {
379
+ tokens,
380
+ count: removable.size,
381
+ findings: findings.slice(0, Math.max(0, maxFindings)),
382
+ };
383
+ }
384
+
385
+ // Sanitise a reclaim summary from a live estimate OR persisted state.json
386
+ // (untrusted). `keepTarget=false` drops the per-finding target so a persisted /
387
+ // exported payload stays free of file paths and URLs. Returns null when there
388
+ // is nothing worth showing.
389
+ function safeReclaim(raw, keepTarget = true, maxFindings = 8) {
390
+ if (!raw || typeof raw !== "object") return null;
391
+ const tokens = safeCount(raw.tokens);
392
+ const count = safeCount(raw.count);
393
+ if (tokens <= 0 || count <= 0) return null;
394
+ const findings = [];
395
+ const src = Array.isArray(raw.findings) ? raw.findings : [];
396
+ for (const f of src) {
397
+ if (!f || typeof f !== "object") continue;
398
+ const name = isSafeSessionKey(f.name) && f.name.length <= 64 ? f.name : "?";
399
+ const ft = safeCount(f.tokens);
400
+ const fn = safeCount(f.n);
401
+ if (ft <= 0 || fn < 2) continue;
402
+ const item = {
403
+ kind: f.kind === "content" ? "content" : "target",
404
+ name,
405
+ n: fn,
406
+ tokens: ft,
407
+ };
408
+ if (keepTarget && typeof f.target === "string" && f.target.length > 0) {
409
+ item.target = f.target.slice(0, 200);
410
+ }
411
+ findings.push(item);
412
+ if (findings.length >= Math.max(0, maxFindings)) break;
413
+ }
414
+ return { tokens, count, findings };
415
+ }
416
+
114
417
  function fmt(n) {
115
418
  return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
116
419
  }
@@ -215,17 +518,23 @@ function fmtCell(n) {
215
518
  }
216
519
 
217
520
  export {
218
- CHARS_PER_TOKEN_OTHER,
219
- CHARS_PER_TOKEN_CYRILLIC,
220
- CHARS_PER_TOKEN_CJK,
221
- MAX_SANE_LIMIT,
222
521
  oneLine,
223
522
  safeCell,
224
523
  finitePos,
225
524
  safeCount,
226
525
  safeCountOrNull,
526
+ safeSaneOrNull,
227
527
  safeCategories,
528
+ sumCategories,
529
+ breakdownCategories,
228
530
  isSafeSessionKey,
531
+ safeToolOutByTool,
532
+ toolOutEntries,
533
+ TOOL_OUT_MAX_KEYS,
534
+ extractToolTarget,
535
+ contentFingerprint,
536
+ computeReclaim,
537
+ safeReclaim,
229
538
  fmt,
230
539
  estimateTokens,
231
540
  estJson,
package/lib/limits.js CHANGED
@@ -453,7 +453,6 @@ export {
453
453
  getModelLimit,
454
454
  getUsableContext,
455
455
  reserveFor,
456
- setModelLimit,
457
456
  ensureProviderLimits,
458
457
  ensureModelLimits,
459
458
  hydrateModelLimitsFromState,
package/lib/state.js CHANGED
@@ -27,6 +27,10 @@ import {
27
27
  oneLine,
28
28
  safeCount,
29
29
  safeCountOrNull,
30
+ safeReclaim,
31
+ safeSaneOrNull,
32
+ safeToolOutByTool,
33
+ sumCategories,
30
34
  } from "./estimate.js";
31
35
  import {
32
36
  breakdownCache,
@@ -168,16 +172,6 @@ function writeLog(liveLines) {
168
172
  }
169
173
  }
170
174
 
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
175
  // Synchronous sleep for the lock retry loop. Atomics.wait is the documented
182
176
  // non-busy way to block this thread; fall back to a bounded spin if unavailable.
183
177
  function sleepSync(ms) {
@@ -316,6 +310,7 @@ function writeStateFile(
316
310
  inputTokens,
317
311
  ctxTokens,
318
312
  reasoningTokens,
313
+ opts,
319
314
  ) {
320
315
  try {
321
316
  if (!sessionID) return;
@@ -432,15 +427,74 @@ function writeStateFile(
432
427
  ? safeCount(bd.toolOutChars)
433
428
  : safeCount(pc?.toolOut);
434
429
 
435
- const sumEstimates =
436
- catUser +
437
- catAssistant +
438
- catReasoning +
439
- catToolArgs +
440
- catToolOut +
441
- (catSystem || 0) +
442
- (catToolSchemas || 0);
443
- const other = Math.max(0, (inputTokens || 0) - sumEstimates);
430
+ // Per-tool split of the toolOut category (webfetch / read / bash / …).
431
+ // Untrusted names -> sanitised + bounded. A present breakdown OWNS the
432
+ // split (an empty one means "no tool results now" — do NOT revive a stale
433
+ // peer value); only fall back to the persisted map when this instance has
434
+ // no live breakdown at all.
435
+ const toolOutByTool = bd
436
+ ? safeToolOutByTool(bd.toolOutByTool)
437
+ : safeToolOutByTool(prevEntry?.toolOutByTool);
438
+
439
+ // Redundant tool output (waste detector). Persisted WITHOUT targets
440
+ // (keepTarget=false): state.json is a local temp file, but keeping it
441
+ // path-free matches the redacted-export policy. A present breakdown owns
442
+ // the value; otherwise inherit the persisted one.
443
+ const reclaim = bd
444
+ ? safeReclaim(bd.reclaim, false)
445
+ : safeReclaim(prevEntry?.reclaim, false);
446
+
447
+ // Provider-reported prompt cache (EXACT; a subset of input — never added
448
+ // to the category sum). Inherit when this write carries no fresh figure
449
+ // (idle / compaction mirror writes must not drop it).
450
+ const cacheRead =
451
+ safeSaneOrNull(opts?.cacheRead) ?? safeSaneOrNull(prevEntry?.cacheRead);
452
+ const cacheWrite =
453
+ safeSaneOrNull(opts?.cacheWrite) ??
454
+ safeSaneOrNull(prevEntry?.cacheWrite);
455
+
456
+ // Compaction staleness: a `session.compacted` write re-persists the
457
+ // pre-compaction numbers (the compaction flow fires no context hook), so
458
+ // the entry is stale BY CONSTRUCTION — even when this instance has no live
459
+ // breakdown to compare against (a mirror instance must not drop the
460
+ // signal). Later writes inherit `compactedAt` and clear the mark only once
461
+ // a FRESH breakdown (bd.capturedAt newer than the compaction) lands.
462
+ const compactedAt = opts?.compacted
463
+ ? new Date().toISOString()
464
+ : typeof prevEntry?.compactedAt === "string"
465
+ ? prevEntry.compactedAt
466
+ : null;
467
+ let compactStale = false;
468
+ if (opts?.compacted === true) {
469
+ compactStale = true;
470
+ } else if (compactedAt) {
471
+ const cMs = Date.parse(compactedAt);
472
+ const measuredMs =
473
+ bd && typeof bd.capturedAt === "number" ? bd.capturedAt : NaN;
474
+ if (Number.isFinite(cMs) && Number.isFinite(measuredMs)) {
475
+ compactStale = measuredMs <= cMs;
476
+ } else {
477
+ // No live breakdown here: keep the previous verdict (a peer instance
478
+ // may have measured it).
479
+ compactStale = prevEntry?.compactStale === true;
480
+ }
481
+ }
482
+
483
+ // Residual = input minus every measured category (shared sum; null
484
+ // system/toolSchemas count as 0).
485
+ const other = Math.max(
486
+ 0,
487
+ safeCount(inputTokens) -
488
+ sumCategories({
489
+ user: catUser,
490
+ assistant: catAssistant,
491
+ reasoning: catReasoning,
492
+ toolArgs: catToolArgs,
493
+ toolOut: catToolOut,
494
+ system: catSystem,
495
+ toolSchemas: catToolSchemas,
496
+ }),
497
+ );
444
498
  // Parent link for the /context-breakdown command. Prefer the live cache;
445
499
  // fall back to the previously persisted value (backward compatible with
446
500
  // snapshots written before parentID existed -> null). role mirrors it.
@@ -490,6 +544,12 @@ function writeStateFile(
490
544
  toolSchemas: catToolSchemas,
491
545
  other,
492
546
  },
547
+ toolOutByTool,
548
+ reclaim,
549
+ cacheRead,
550
+ cacheWrite,
551
+ compactedAt,
552
+ compactStale,
493
553
  updatedAt: new Date().toISOString(),
494
554
  };
495
555
  all[sessionID] = entry;
@@ -618,19 +678,9 @@ function pushFinalSummary(claimKey, summaryLines) {
618
678
 
619
679
  export {
620
680
  logErr,
621
- renderFile,
622
681
  writeLog,
623
- appendLogLine,
624
- sleepSync,
625
- ensureRegularFile,
626
- isStaleLock,
627
- acquireStateLock,
628
- releaseStateLock,
629
- renameWithRetry,
630
682
  writeStateFile,
631
- appendEventLog,
632
683
  debugEvent,
633
684
  readStateSnapshot,
634
- finalSummaries,
635
685
  pushFinalSummary,
636
686
  };
package/lib/tui-data.d.ts CHANGED
@@ -29,6 +29,26 @@ 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
+ /** Provider-reported prompt-cache tokens (exact; a subset of input). */
35
+ cacheRead?: number | null
36
+ cacheWrite?: number | null
37
+ /** Redundant tool output (waste detector). Findings here are path-free. */
38
+ reclaim?: {
39
+ tokens: number
40
+ count: number
41
+ findings?: Array<{
42
+ kind?: string
43
+ name?: string
44
+ n?: number
45
+ tokens?: number
46
+ }>
47
+ } | null
48
+ /** ISO time of the last session.compacted (marker only). */
49
+ compactedAt?: string | null
50
+ /** True while the entry still shows pre-compaction numbers. */
51
+ compactStale?: boolean
32
52
  updatedAt?: string
33
53
  }
34
54
 
@@ -44,3 +64,7 @@ export declare function fmt(n: number): string
44
64
  export declare function num(v: unknown): number
45
65
  export declare function fit(s: string, w: number): string
46
66
  export declare function updatedAt(iso: string | undefined): string
67
+ export declare function toolOutEntries(
68
+ map: Record<string, number> | undefined,
69
+ limit?: number,
70
+ ): 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{...}, updatedAt } }
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 (isSafeKey(k) && v && typeof v === "object") clean[k] = v
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
- const c = e?.categories
86
- const sum =
87
- num(c?.user) +
88
- num(c?.assistant) +
89
- num(c?.reasoning) +
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