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/CHANGELOG.md +45 -2
- package/README.md +53 -24
- package/index.js +14 -1
- package/lib/cache.js +1 -1
- package/lib/commands.js +59 -36
- package/lib/contributors.js +10 -41
- package/lib/dedup.js +4 -4
- package/lib/estimate.js +97 -6
- package/lib/limits.js +0 -1
- package/lib/state.js +63 -28
- package/lib/tui-data.d.ts +11 -0
- package/lib/tui-data.js +13 -25
- package/lib/tui-panel.tsx +31 -8
- package/lib/tui-sidebar.tsx +4 -0
- package/lib/v2.js +121 -30
- package/package.json +2 -2
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
|
-
//
|
|
68
|
-
//
|
|
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
|
|
77
|
-
// checksums widen the fingerprint so two different
|
|
78
|
-
// the same length/key counts do not collide into a
|
|
79
|
-
// paying for full JSON.stringify (the whole point of
|
|
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
|
-
|
|
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)
|
|
102
|
-
: Object.keys(input)
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
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
|
|
223
|
-
// (
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
+
"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.
|
|
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",
|