opencode-context-indicator 1.3.1 → 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/lib/estimate.js CHANGED
@@ -81,8 +81,10 @@ function safeCountOrNull(v) {
81
81
  return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
82
82
  }
83
83
 
84
- // Normalise a categories object from untrusted input (state.json) into the 7
85
- // known numeric fields — never trust its shape.
84
+ // Normalise a categories object from untrusted input (state.json) into the 8
85
+ // known numeric fields — never trust its shape. `toolOut` is 0 for legacy
86
+ // entries written before the category existed (their tool results stay folded
87
+ // into `other`, matching how they were measured).
86
88
  function safeCategories(raw) {
87
89
  if (!raw || typeof raw !== "object") return null;
88
90
  return {
@@ -90,12 +92,60 @@ function safeCategories(raw) {
90
92
  assistant: safeCount(raw.assistant),
91
93
  reasoning: safeCount(raw.reasoning),
92
94
  toolArgs: safeCount(raw.toolArgs),
95
+ toolOut: safeCount(raw.toolOut),
93
96
  system: safeCountOrNull(raw.system),
94
97
  toolSchemas: safeCountOrNull(raw.toolSchemas),
95
98
  other: safeCount(raw.other),
96
99
  };
97
100
  }
98
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
+
99
149
  // A session key safe to use as an object property / map key. Rejects the
100
150
  // prototype-pollution vectors ("__proto__", "constructor", "prototype").
101
151
  function isSafeSessionKey(k) {
@@ -108,6 +158,46 @@ function isSafeSessionKey(k) {
108
158
  );
109
159
  }
110
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
+
111
201
  function fmt(n) {
112
202
  return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
113
203
  }
@@ -212,17 +302,18 @@ function fmtCell(n) {
212
302
  }
213
303
 
214
304
  export {
215
- CHARS_PER_TOKEN_OTHER,
216
- CHARS_PER_TOKEN_CYRILLIC,
217
- CHARS_PER_TOKEN_CJK,
218
- MAX_SANE_LIMIT,
219
305
  oneLine,
220
306
  safeCell,
221
307
  finitePos,
222
308
  safeCount,
223
309
  safeCountOrNull,
224
310
  safeCategories,
311
+ sumCategories,
312
+ breakdownCategories,
225
313
  isSafeSessionKey,
314
+ safeToolOutByTool,
315
+ toolOutEntries,
316
+ TOOL_OUT_MAX_KEYS,
226
317
  fmt,
227
318
  estimateTokens,
228
319
  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,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;
@@ -426,15 +419,63 @@ function writeStateFile(
426
419
  bd && bd.toolSchemasChars != null
427
420
  ? safeCount(bd.toolSchemasChars)
428
421
  : safeCountOrNull(pc?.toolSchemas);
422
+ // Tool results are counted by both the hook and the fallback path, so
423
+ // this is a plain counted category (never null): a missing value is 0.
424
+ const catToolOut = bd
425
+ ? safeCount(bd.toolOutChars)
426
+ : safeCount(pc?.toolOut);
427
+
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
+ }
429
463
 
430
- const sumEstimates =
431
- catUser +
432
- catAssistant +
433
- catReasoning +
434
- catToolArgs +
435
- (catSystem || 0) +
436
- (catToolSchemas || 0);
437
- const other = Math.max(0, (inputTokens || 0) - sumEstimates);
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
+ );
438
479
  // Parent link for the /context-breakdown command. Prefer the live cache;
439
480
  // fall back to the previously persisted value (backward compatible with
440
481
  // snapshots written before parentID existed -> null). role mirrors it.
@@ -479,10 +520,14 @@ function writeStateFile(
479
520
  assistant: catAssistant,
480
521
  reasoning: catReasoning,
481
522
  toolArgs: catToolArgs,
523
+ toolOut: catToolOut,
482
524
  system: catSystem,
483
525
  toolSchemas: catToolSchemas,
484
526
  other,
485
527
  },
528
+ toolOutByTool,
529
+ compactedAt,
530
+ compactStale,
486
531
  updatedAt: new Date().toISOString(),
487
532
  };
488
533
  all[sessionID] = entry;
@@ -611,19 +656,9 @@ function pushFinalSummary(claimKey, summaryLines) {
611
656
 
612
657
  export {
613
658
  logErr,
614
- renderFile,
615
659
  writeLog,
616
- appendLogLine,
617
- sleepSync,
618
- ensureRegularFile,
619
- isStaleLock,
620
- acquireStateLock,
621
- releaseStateLock,
622
- renameWithRetry,
623
660
  writeStateFile,
624
- appendEventLog,
625
661
  debugEvent,
626
662
  readStateSnapshot,
627
- finalSummaries,
628
663
  pushFinalSummary,
629
664
  };
package/lib/tui-data.d.ts CHANGED
@@ -9,6 +9,7 @@ export interface Categories {
9
9
  assistant?: number
10
10
  reasoning?: number
11
11
  toolArgs?: number
12
+ toolOut?: number
12
13
  system?: number | null
13
14
  toolSchemas?: number | null
14
15
  other?: number
@@ -28,6 +29,12 @@ export interface StateEntry {
28
29
  limit?: number | null
29
30
  reasoning?: number
30
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
31
38
  updatedAt?: string
32
39
  }
33
40
 
@@ -43,3 +50,7 @@ export declare function fmt(n: number): string
43
50
  export declare function num(v: unknown): number
44
51
  export declare function fit(s: string, w: number): string
45
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{...}, 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,16 +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?.system) +
92
- num(c?.toolSchemas) +
93
- num(c?.other)
94
- 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
+ )
95
83
  }
96
84
 
97
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
- return d > 0
479
- ? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
480
- (num(r.ctx) / d) * 100,
481
- )}%)`
482
- : `Context breakdown \u2014 ${model} ${ctx}`
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>
@@ -565,10 +584,14 @@ export function BreakdownPanel(props: {
565
584
  </Show>
566
585
  <text fg={muted()}>
567
586
  {
568
- "measured != server categories (tool outputs counted here; server categories exclude them)"
587
+ "measured != server categories: contributors attribute outputs per tool call; the server's toolOut category only totals them"
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
  )
@@ -54,6 +54,7 @@ export function Sidebar(props: { entry: () => StateEntry | null }) {
54
54
  <text fg={muted()}>{line("assistant", e()?.categories?.assistant)}</text>
55
55
  <text fg={muted()}>{line("reasoning", e()?.categories?.reasoning)}</text>
56
56
  <text fg={muted()}>{line("tool args", e()?.categories?.toolArgs)}</text>
57
+ <text fg={muted()}>{line("tool out", e()?.categories?.toolOut)}</text>
57
58
  <text fg={muted()}>
58
59
  {e()?.categories?.system == null
59
60
  ? `${"system".padEnd(LABEL_WIDTH)} n/a`
@@ -65,6 +66,9 @@ export function Sidebar(props: { entry: () => StateEntry | null }) {
65
66
  : line("tool schemas", e()?.categories?.toolSchemas)}
66
67
  </text>
67
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>
68
72
  <text fg={muted()}>{`updated ${updatedAt(e()?.updatedAt)}`}</text>
69
73
  </Show>
70
74
  </box>