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/CHANGELOG.md +55 -0
- package/README.md +37 -5
- package/index.js +18 -1
- package/lib/cache.js +22 -0
- package/lib/commands.js +121 -31
- package/lib/contributors.js +10 -41
- package/lib/dedup.js +4 -4
- package/lib/estimate.js +313 -4
- package/lib/limits.js +0 -1
- package/lib/state.js +79 -29
- package/lib/tui-data.d.ts +24 -0
- package/lib/tui-data.js +13 -26
- package/lib/tui-panel.tsx +53 -7
- package/lib/tui-sidebar.tsx +3 -0
- package/lib/v2.js +169 -21
- package/package.json +2 -2
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
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
(
|
|
442
|
-
(
|
|
443
|
-
|
|
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{...},
|
|
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 (
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
num(
|
|
88
|
-
num(
|
|
89
|
-
|
|
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
|