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/v2.js CHANGED
@@ -7,11 +7,14 @@
7
7
  // from index.js.
8
8
 
9
9
  import {
10
+ breakdownCategories,
10
11
  estJson,
11
12
  estimateTokens,
12
13
  fmt,
14
+ isSafeSessionKey,
13
15
  oneLine,
14
16
  tokenCountOf,
17
+ TOOL_OUT_MAX_KEYS,
15
18
  withTimeout,
16
19
  } from "./estimate.js";
17
20
  import {
@@ -60,12 +63,14 @@ const V2_FALLBACK_TIMEOUT_MS = 3000;
60
63
  // Estimate the per-category breakdown directly from a V2 SessionContext.
61
64
  // `context.messages` are @opencode/ai Message objects:
62
65
  // { role, content: [ {type:"text",text} | {type:"media"} |
63
- // {type:"tool-call",input} | {type:"tool-result"} |
66
+ // {type:"tool-call",input} | {type:"tool-result",result} |
64
67
  // {type:"reasoning",text} | ... ] }
65
68
  // `context.system` are SystemPart[] { type:"text", text }.
66
69
  // `context.tools` is Record<name, { description, input: JsonSchema }>.
67
- // As in V1, tool RESULTS are deliberately not counted (only call args), and
68
- // every returned *Chars value is a TOKEN estimate (unicode heuristic).
70
+ // Tool RESULTS are counted into the toolOut category (the payload the model
71
+ // sees back: webfetch pages, file reads, subagent reports); tool call ARGS are
72
+ // counted separately. Every returned *Chars value is a TOKEN estimate (unicode
73
+ // heuristic).
69
74
  //
70
75
  // `sessionID` is optional; when given, the tool-schema estimate is cached
71
76
  // per-session (see toolSchemasBySession) and reused while the cheap fingerprint
@@ -73,10 +78,11 @@ const V2_FALLBACK_TIMEOUT_MS = 3000;
73
78
  // every schema does not run on every single model request.
74
79
  // Cheap structural fingerprint of the tool schema map: count + total description
75
80
  // length + number of schema property keys + two O(n) charCode checksums (mod
76
- // 0xFFFFFF) over every description and over the top-level input keys. The
77
- // checksums widen the fingerprint so two different schemas that happen to share
78
- // the same length/key counts do not collide into a wrong cache hit — without
79
- // paying for full JSON.stringify (the whole point of the cache).
81
+ // 0xFFFFFF) over every description and over the PARAMETER names
82
+ // (input.properties keys). The checksums widen the fingerprint so two different
83
+ // schemas that happen to share the same length/key counts do not collide into a
84
+ // wrong cache hit — without paying for full JSON.stringify (the whole point of
85
+ // the cache).
80
86
  function toolsFingerprint(tools) {
81
87
  if (!tools || typeof tools !== "object") return "none";
82
88
  let n = 0;
@@ -96,14 +102,16 @@ function toolsFingerprint(tools) {
96
102
  const input = t?.input;
97
103
  if (input && typeof input === "object") {
98
104
  const props = input.properties;
99
- propKeys +=
105
+ // Hash the PARAMETER names (input.properties keys) when present — a
106
+ // property rename that keeps the same count/description must change the
107
+ // fingerprint, or the TTL cache would serve a stale schema estimate.
108
+ // Fall back to the top-level schema keys for schemas without `properties`.
109
+ const nameSource =
100
110
  props && typeof props === "object"
101
- ? Object.keys(props).length
102
- : Object.keys(input).length;
103
- // Sum the top-level key names so property RENAMES that keep the same count
104
- // still change the fingerprint.
105
- const topKeys = Object.keys(input);
106
- for (const k of topKeys) {
111
+ ? Object.keys(props)
112
+ : Object.keys(input);
113
+ propKeys += nameSource.length;
114
+ for (const k of nameSource) {
107
115
  for (let i = 0; i < k.length; i++) {
108
116
  keyHash = (keyHash + k.charCodeAt(i)) & 0xffffff;
109
117
  }
@@ -113,21 +121,74 @@ function toolsFingerprint(tools) {
113
121
  return `${n}:${descLen}:${propKeys}:${descHash}:${keyHash}`;
114
122
  }
115
123
 
124
+ // Token estimate for one ToolResultPart (@opencode/ai schema/messages.d.ts):
125
+ // result: {type:"text",value} | {type:"json",value} | {type:"error",value}
126
+ // | {type:"content",value:[{type:"text",text}|{type:"file",uri,mime}]}
127
+ // json / error payloads and non-string text values go through estJson; string
128
+ // values through the char estimator. `content` file items are URI references,
129
+ // not inline bytes — deliberately skipped (a data-URL base64 blob is consumed
130
+ // by the model as media tokens, not as text the char heuristic can price).
131
+ function toolResultChars(p) {
132
+ const r = p?.result;
133
+ if (!r || typeof r !== "object") return 0;
134
+ if (r.type === "content" && Array.isArray(r.value)) {
135
+ let n = 0;
136
+ for (const item of r.value) {
137
+ if (item?.type === "text") n += estimateTokens(item.text);
138
+ }
139
+ return n;
140
+ }
141
+ if (r.type === "text" && typeof r.value === "string") {
142
+ return estimateTokens(r.value);
143
+ }
144
+ return estJson(r.value); // json | error | unexpected shapes
145
+ }
146
+
147
+ // Accumulate the per-tool tool-result estimate. Tool names are untrusted
148
+ // (host tool ids, plugin-defined tools): unsafe names and any name beyond the
149
+ // cap fold into "other tools" so the bucket never becomes a prototype vector
150
+ // and the map stays bounded. The sum of the map mirrors the toolOut category.
151
+ // The cap is the SHARED TOOL_OUT_MAX_KEYS (lib/estimate.js) so the accumulation
152
+ // bound and the persisted/displayed bound can never drift apart.
153
+ function addToolOut(map, name, chars) {
154
+ if (!(chars > 0)) return;
155
+ const safe =
156
+ isSafeSessionKey(name) && name.length <= 64 ? name : "other tools";
157
+ if (
158
+ safe !== "other tools" &&
159
+ !Object.prototype.hasOwnProperty.call(map, safe) &&
160
+ Object.keys(map).length >= TOOL_OUT_MAX_KEYS
161
+ ) {
162
+ map["other tools"] = (map["other tools"] || 0) + chars;
163
+ return;
164
+ }
165
+ map[safe] = (map[safe] || 0) + chars;
166
+ }
167
+
116
168
  function estimateBreakdownFromContext(context, sessionID) {
117
169
  let userChars = 0;
118
170
  let assistantChars = 0;
119
171
  let reasoningChars = 0;
120
172
  let toolArgsChars = 0;
173
+ let toolOutChars = 0;
174
+ const toolOutByTool = {};
121
175
 
122
176
  const messages = Array.isArray(context?.messages) ? context.messages : [];
123
177
  for (const msg of messages) {
124
178
  const parts = Array.isArray(msg?.content) ? msg.content : [];
125
- if (msg?.role === "user") {
126
- for (const p of parts) {
127
- if (p?.type === "text") userChars += estimateTokens(p.text);
179
+ for (const p of parts) {
180
+ // Tool results arrive in dedicated tool-result parts; the message ROLE
181
+ // carrying them varies by host version (user, assistant, or the dedicated
182
+ // "tool" role) — count them role-independently.
183
+ if (p?.type === "tool-result") {
184
+ const chars = toolResultChars(p);
185
+ toolOutChars += chars;
186
+ addToolOut(toolOutByTool, p?.name, chars);
187
+ continue;
128
188
  }
129
- } else if (msg?.role === "assistant") {
130
- for (const p of parts) {
189
+ if (msg?.role === "user") {
190
+ if (p?.type === "text") userChars += estimateTokens(p.text);
191
+ } else if (msg?.role === "assistant") {
131
192
  if (p?.type === "text") {
132
193
  assistantChars += estimateTokens(p.text);
133
194
  } else if (p?.type === "reasoning") {
@@ -174,6 +235,8 @@ function estimateBreakdownFromContext(context, sessionID) {
174
235
  assistantChars,
175
236
  reasoningChars,
176
237
  toolArgsChars,
238
+ toolOutChars,
239
+ toolOutByTool,
177
240
  systemChars,
178
241
  toolSchemasChars,
179
242
  };
@@ -216,16 +279,18 @@ function onV2Context(context) {
216
279
  // instance (the public event stream is mirrored to every instance, but the
217
280
  // breakdown cache is process-local) or when the hook did not fire at all.
218
281
  //
219
- // Only user / assistant / reasoning / tool-args can be derived from the
220
- // persisted SessionMessageInfo[]: the ASSEMBLED system prompt and the tool
282
+ // Only user / assistant / reasoning / tool-args / tool-OUT can be derived from
283
+ // the persisted SessionMessageInfo[]: the ASSEMBLED system prompt and the tool
221
284
  // schemas are exclusive to the context hook, so they stay null ("n/a" in the
222
- // sidebar) unless the hook ran. Tool RESULTS are deliberately not counted
223
- // (only call args), exactly like the hook / V1 estimator.
285
+ // sidebar) unless the hook ran. Tool RESULTS are counted from the resolved
286
+ // `state.content` (ToolContent[]) of completed / errored tool parts.
224
287
  function estimateBreakdownFromClientMessages(messages) {
225
288
  let userChars = 0;
226
289
  let assistantChars = 0;
227
290
  let reasoningChars = 0;
228
291
  let toolArgsChars = 0;
292
+ let toolOutChars = 0;
293
+ const toolOutByTool = {};
229
294
  for (const m of messages || []) {
230
295
  if (!m || typeof m !== "object") continue;
231
296
  if (m.type === "user") {
@@ -242,6 +307,24 @@ function estimateBreakdownFromClientMessages(messages) {
242
307
  // object (running/completed/error) to avoid partial counts.
243
308
  const input = p?.state?.input;
244
309
  if (input && typeof input === "object") toolArgsChars += estJson(input);
310
+ // Tool results: completed / errored states carry the payload in
311
+ // state.content (ToolContent[]: {type:"text",text}|{type:"file"}) —
312
+ // NOT state.output (that field does not exist on SessionMessageInfo).
313
+ // Count text items; file items are URI references, skipped (same
314
+ // policy as the hook path).
315
+ const content = p?.state?.content;
316
+ if (Array.isArray(content)) {
317
+ let outChars = 0;
318
+ for (const item of content) {
319
+ if (item?.type === "text" && typeof item.text === "string") {
320
+ outChars += estimateTokens(item.text);
321
+ }
322
+ }
323
+ toolOutChars += outChars;
324
+ // The persisted tool part carries its name on `p.name`
325
+ // (SessionMessageAssistantTool.name) — same field as the hook path.
326
+ addToolOut(toolOutByTool, p?.name, outChars);
327
+ }
245
328
  }
246
329
  }
247
330
  }
@@ -251,6 +334,8 @@ function estimateBreakdownFromClientMessages(messages) {
251
334
  assistantChars,
252
335
  reasoningChars,
253
336
  toolArgsChars,
337
+ toolOutChars,
338
+ toolOutByTool,
254
339
  systemChars: null,
255
340
  toolSchemasChars: null,
256
341
  };
@@ -376,13 +461,9 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
376
461
  const source = bd?.source || "none";
377
462
  const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
378
463
 
379
- const sumEstimates = (bd?.userChars || 0) +
380
- (bd?.assistantChars || 0) +
381
- (bd?.reasoningChars || 0) +
382
- (bd?.toolArgsChars || 0) +
383
- (bd?.systemChars || 0) +
384
- (bd?.toolSchemasChars || 0);
385
- const other = Math.max(0, inputTokens - sumEstimates);
464
+ // Residual = input minus every measured category (shared normaliser; `bd`
465
+ // may be absent -> everything 0 and the residual is the whole input).
466
+ const other = breakdownCategories(bd, inputTokens).other;
386
467
 
387
468
  if (bd) {
388
469
  lines.push(` user ${fmt(bd.userChars || 0)} (est tokens)`);
@@ -390,6 +471,9 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
390
471
  const reasoningNote = rt > 0 ? ` (exact ${fmt(rt)})` : "";
391
472
  lines.push(` reasoning ${fmt(bd.reasoningChars || 0)} (est tokens)${reasoningNote}`);
392
473
  lines.push(` tool args ${fmt(bd.toolArgsChars || 0)} (est tokens, input only)`);
474
+ lines.push(
475
+ ` tool results ${fmt(bd.toolOutChars || 0)} (est tokens, outputs fed back)`,
476
+ );
393
477
  lines.push(
394
478
  bd.systemChars != null
395
479
  ? ` system ${fmt(bd.systemChars)} (est tokens)`
@@ -405,6 +489,7 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
405
489
  lines.push(` assistant n/a`);
406
490
  lines.push(` reasoning n/a`);
407
491
  lines.push(` tool args n/a`);
492
+ lines.push(` tool results n/a`);
408
493
  lines.push(` system n/a (hook did not run)`);
409
494
  lines.push(` tool schemas n/a`);
410
495
  }
@@ -517,6 +602,11 @@ async function handleV2Event(ctx, event) {
517
602
  lastKnownInput(sid),
518
603
  lastKnownCtx(sid),
519
604
  lastKnownReasoning(sid),
605
+ // This write re-persists the PRE-compaction snapshot (the compaction
606
+ // flow fires no context hook — see README): mark the entry stale so the
607
+ // UI shows the numbers as pre-compaction until the next model request
608
+ // refreshes them.
609
+ { compacted: true },
520
610
  );
521
611
  pushFinalSummary(
522
612
  `compacted:${event.id ?? sid}`,
@@ -687,6 +777,7 @@ async function handleV2Event(ctx, event) {
687
777
  export {
688
778
  onV2Context,
689
779
  handleV2Event,
780
+ estimateBreakdownFromContext,
690
781
  estimateBreakdownFromClientMessages,
691
782
  V2_FALLBACK_TIMEOUT_MS,
692
783
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "Real-time context-window usage for OpenCode: per-category token breakdown, a zero-LLM /cx panel with per-session cost and a searchable family-session picker, and a TUI sidebar. Dual V1 + V2 plugin entry.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,7 +64,7 @@
64
64
  "@opentui/solid": "0.5.17"
65
65
  },
66
66
  "devDependencies": {
67
- "@opencode/plugin": "2.0.26",
67
+ "@opencode/plugin": "2.0.28",
68
68
  "@opentui/core": "0.5.17",
69
69
  "@types/node": "26.6.3",
70
70
  "esbuild": "^0.28.2",