opencode-context-indicator 1.1.5 → 1.2.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/README.md +87 -38
- package/index.js +24 -3233
- package/lib/cache.js +161 -0
- package/lib/commands.js +489 -0
- package/lib/estimate.js +232 -0
- package/lib/limits.js +403 -0
- package/lib/state.js +595 -0
- package/lib/v1.js +957 -0
- package/lib/v2.js +692 -0
- package/package.json +4 -4
- package/tui.tsx +711 -30
package/lib/v2.js
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
// lib/v2.js
|
|
2
|
+
//
|
|
3
|
+
// The V2 (opencode >= 2.0.16 / Desktop) path: the context hook estimator and
|
|
4
|
+
// handler, the bounded session.context fallback, the event-driven subagent
|
|
5
|
+
// roll-up, the live log assembly, and handleV2Event. No toasts on this path
|
|
6
|
+
// (the Desktop exposes no TUI channel). Extracted 1:1 (code + WHY comments)
|
|
7
|
+
// from index.js.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
estJson,
|
|
11
|
+
estimateTokens,
|
|
12
|
+
fmt,
|
|
13
|
+
oneLine,
|
|
14
|
+
tokenCountOf,
|
|
15
|
+
withTimeout,
|
|
16
|
+
} from "./estimate.js";
|
|
17
|
+
import {
|
|
18
|
+
BREAKDOWN_THROTTLE_MS,
|
|
19
|
+
breakdownCache,
|
|
20
|
+
clearSessionTracking,
|
|
21
|
+
ERROR_TOAST_DEDUP_MS,
|
|
22
|
+
lastBdWrite,
|
|
23
|
+
lastKnownCtx,
|
|
24
|
+
lastKnownCtxCache,
|
|
25
|
+
lastKnownInput,
|
|
26
|
+
lastKnownInputCache,
|
|
27
|
+
lastKnownModel,
|
|
28
|
+
lastKnownModelCache,
|
|
29
|
+
lastKnownProvider,
|
|
30
|
+
lastKnownProviderCache,
|
|
31
|
+
lastKnownReasoning,
|
|
32
|
+
lastKnownReasoningCache,
|
|
33
|
+
MAX_TRACKED_SESSIONS,
|
|
34
|
+
sessionAgentCache,
|
|
35
|
+
sessionParentCache,
|
|
36
|
+
sessionTotals,
|
|
37
|
+
toolSchemasBySession,
|
|
38
|
+
TOOL_SCHEMA_TTL_MS,
|
|
39
|
+
v2ContextFetchTimes,
|
|
40
|
+
} from "./cache.js";
|
|
41
|
+
import {
|
|
42
|
+
ensureModelLimits,
|
|
43
|
+
getModelLimit,
|
|
44
|
+
getUsableContext,
|
|
45
|
+
} from "./limits.js";
|
|
46
|
+
import {
|
|
47
|
+
debugEvent,
|
|
48
|
+
logErr,
|
|
49
|
+
pushFinalSummary,
|
|
50
|
+
writeLog,
|
|
51
|
+
writeStateFile,
|
|
52
|
+
} from "./state.js";
|
|
53
|
+
import { pruneMap } from "./dedup.js";
|
|
54
|
+
|
|
55
|
+
const V2_CONTEXT_FETCH_THROTTLE_MS = 5000; // min gap between fallback fetches
|
|
56
|
+
// Background fallback requests are fire-and-forget (see the callers in the
|
|
57
|
+
// event handlers): cap each at 3s so a slow/hung client cannot pin resources.
|
|
58
|
+
const V2_FALLBACK_TIMEOUT_MS = 3000;
|
|
59
|
+
|
|
60
|
+
// Estimate the per-category breakdown directly from a V2 SessionContext.
|
|
61
|
+
// `context.messages` are @opencode/ai Message objects:
|
|
62
|
+
// { role, content: [ {type:"text",text} | {type:"media"} |
|
|
63
|
+
// {type:"tool-call",input} | {type:"tool-result"} |
|
|
64
|
+
// {type:"reasoning",text} | ... ] }
|
|
65
|
+
// `context.system` are SystemPart[] { type:"text", text }.
|
|
66
|
+
// `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).
|
|
69
|
+
//
|
|
70
|
+
// `sessionID` is optional; when given, the tool-schema estimate is cached
|
|
71
|
+
// per-session (see toolSchemasBySession) and reused while the cheap fingerprint
|
|
72
|
+
// is unchanged and the TTL has not elapsed — so the expensive estJson pass over
|
|
73
|
+
// every schema does not run on every single model request.
|
|
74
|
+
// Cheap structural fingerprint of the tool schema map: count + total description
|
|
75
|
+
// 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).
|
|
80
|
+
function toolsFingerprint(tools) {
|
|
81
|
+
if (!tools || typeof tools !== "object") return "none";
|
|
82
|
+
let n = 0;
|
|
83
|
+
let descLen = 0;
|
|
84
|
+
let propKeys = 0;
|
|
85
|
+
let descHash = 0;
|
|
86
|
+
let keyHash = 0;
|
|
87
|
+
for (const t of Object.values(tools)) {
|
|
88
|
+
n++;
|
|
89
|
+
const d = t?.description;
|
|
90
|
+
if (typeof d === "string") {
|
|
91
|
+
descLen += d.length;
|
|
92
|
+
for (let i = 0; i < d.length; i++) {
|
|
93
|
+
descHash = (descHash + d.charCodeAt(i)) & 0xffffff;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const input = t?.input;
|
|
97
|
+
if (input && typeof input === "object") {
|
|
98
|
+
const props = input.properties;
|
|
99
|
+
propKeys +=
|
|
100
|
+
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) {
|
|
107
|
+
for (let i = 0; i < k.length; i++) {
|
|
108
|
+
keyHash = (keyHash + k.charCodeAt(i)) & 0xffffff;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return `${n}:${descLen}:${propKeys}:${descHash}:${keyHash}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function estimateBreakdownFromContext(context, sessionID) {
|
|
117
|
+
let userChars = 0;
|
|
118
|
+
let assistantChars = 0;
|
|
119
|
+
let reasoningChars = 0;
|
|
120
|
+
let toolArgsChars = 0;
|
|
121
|
+
|
|
122
|
+
const messages = Array.isArray(context?.messages) ? context.messages : [];
|
|
123
|
+
for (const msg of messages) {
|
|
124
|
+
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);
|
|
128
|
+
}
|
|
129
|
+
} else if (msg?.role === "assistant") {
|
|
130
|
+
for (const p of parts) {
|
|
131
|
+
if (p?.type === "text") {
|
|
132
|
+
assistantChars += estimateTokens(p.text);
|
|
133
|
+
} else if (p?.type === "reasoning") {
|
|
134
|
+
reasoningChars += estimateTokens(p.text);
|
|
135
|
+
} else if (p?.type === "tool-call") {
|
|
136
|
+
toolArgsChars += estJson(p.input);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let systemChars = 0;
|
|
143
|
+
const system = Array.isArray(context?.system) ? context.system : [];
|
|
144
|
+
for (const s of system) systemChars += estimateTokens(s?.text);
|
|
145
|
+
|
|
146
|
+
let toolSchemasChars = 0;
|
|
147
|
+
const tools = context?.tools;
|
|
148
|
+
if (tools && typeof tools === "object") {
|
|
149
|
+
const fp = toolsFingerprint(tools);
|
|
150
|
+
const cached = sessionID ? toolSchemasBySession.get(sessionID) : null;
|
|
151
|
+
if (
|
|
152
|
+
cached &&
|
|
153
|
+
cached.toolsFingerprint === fp &&
|
|
154
|
+
Date.now() - cached.at < TOOL_SCHEMA_TTL_MS
|
|
155
|
+
) {
|
|
156
|
+
toolSchemasChars = cached.toolSchemasChars; // unchanged schemas -> reuse
|
|
157
|
+
} else {
|
|
158
|
+
for (const t of Object.values(tools)) {
|
|
159
|
+
toolSchemasChars += estimateTokens(t?.description) + estJson(t?.input);
|
|
160
|
+
}
|
|
161
|
+
if (sessionID) {
|
|
162
|
+
toolSchemasBySession.set(sessionID, {
|
|
163
|
+
toolSchemasChars,
|
|
164
|
+
toolsFingerprint: fp,
|
|
165
|
+
at: Date.now(),
|
|
166
|
+
});
|
|
167
|
+
pruneMap(toolSchemasBySession, MAX_TRACKED_SESSIONS);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
userChars,
|
|
174
|
+
assistantChars,
|
|
175
|
+
reasoningChars,
|
|
176
|
+
toolArgsChars,
|
|
177
|
+
systemChars,
|
|
178
|
+
toolSchemasChars,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// V2 context hook handler: capture the final messages / system prompt / tool
|
|
183
|
+
// schemas + the request model. READ-ONLY — the context object is never mutated.
|
|
184
|
+
function onV2Context(context) {
|
|
185
|
+
try {
|
|
186
|
+
if (!context || typeof context !== "object") return;
|
|
187
|
+
const sid = context.sessionID;
|
|
188
|
+
if (!sid) return;
|
|
189
|
+
|
|
190
|
+
const parts = estimateBreakdownFromContext(context, sid);
|
|
191
|
+
breakdownCache.set(sid, {
|
|
192
|
+
...parts,
|
|
193
|
+
capturedAt: Date.now(),
|
|
194
|
+
source: "context-hook",
|
|
195
|
+
});
|
|
196
|
+
pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
|
|
197
|
+
|
|
198
|
+
const model = context.model;
|
|
199
|
+
if (model && typeof model === "object") {
|
|
200
|
+
if (typeof model.id === "string") lastKnownModelCache.set(sid, model.id);
|
|
201
|
+
if (typeof model.providerID === "string") {
|
|
202
|
+
lastKnownProviderCache.set(sid, model.providerID);
|
|
203
|
+
}
|
|
204
|
+
pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
|
|
205
|
+
pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
logErr("v2 context hook failed", err);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// V2 fallback: derive the message-based categories from the session's persisted
|
|
214
|
+
// message list when the context hook did NOT populate the cache for this
|
|
215
|
+
// session. This happens when the request was served by a DIFFERENT plugin
|
|
216
|
+
// instance (the public event stream is mirrored to every instance, but the
|
|
217
|
+
// breakdown cache is process-local) or when the hook did not fire at all.
|
|
218
|
+
//
|
|
219
|
+
// Only user / assistant / reasoning / tool-args can be derived from the
|
|
220
|
+
// persisted SessionMessageInfo[]: the ASSEMBLED system prompt and the tool
|
|
221
|
+
// 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.
|
|
224
|
+
function estimateBreakdownFromClientMessages(messages) {
|
|
225
|
+
let userChars = 0;
|
|
226
|
+
let assistantChars = 0;
|
|
227
|
+
let reasoningChars = 0;
|
|
228
|
+
let toolArgsChars = 0;
|
|
229
|
+
for (const m of messages || []) {
|
|
230
|
+
if (!m || typeof m !== "object") continue;
|
|
231
|
+
if (m.type === "user") {
|
|
232
|
+
userChars += estimateTokens(m.text);
|
|
233
|
+
} else if (m.type === "assistant") {
|
|
234
|
+
const parts = Array.isArray(m.content) ? m.content : [];
|
|
235
|
+
for (const p of parts) {
|
|
236
|
+
if (p?.type === "text") {
|
|
237
|
+
assistantChars += estimateTokens(p.text);
|
|
238
|
+
} else if (p?.type === "reasoning") {
|
|
239
|
+
reasoningChars += estimateTokens(p.text);
|
|
240
|
+
} else if (p?.type === "tool") {
|
|
241
|
+
// state.input is a string while streaming; only count the resolved
|
|
242
|
+
// object (running/completed/error) to avoid partial counts.
|
|
243
|
+
const input = p?.state?.input;
|
|
244
|
+
if (input && typeof input === "object") toolArgsChars += estJson(input);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
userChars,
|
|
251
|
+
assistantChars,
|
|
252
|
+
reasoningChars,
|
|
253
|
+
toolArgsChars,
|
|
254
|
+
systemChars: null,
|
|
255
|
+
toolSchemasChars: null,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Fill breakdownCache for a session when the context hook has not already done
|
|
260
|
+
// so. Throttled, fault-tolerant, READ-ONLY. Hook data always wins.
|
|
261
|
+
async function ensureV2Breakdown(ctx, sessionID) {
|
|
262
|
+
if (!sessionID) return;
|
|
263
|
+
const cached = breakdownCache.get(sessionID);
|
|
264
|
+
if (cached && cached.source === "context-hook") return; // hook data wins
|
|
265
|
+
const now = Date.now();
|
|
266
|
+
if (
|
|
267
|
+
now - (v2ContextFetchTimes.get(sessionID) || 0) <
|
|
268
|
+
V2_CONTEXT_FETCH_THROTTLE_MS
|
|
269
|
+
) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
v2ContextFetchTimes.set(sessionID, now);
|
|
273
|
+
pruneMap(v2ContextFetchTimes, MAX_TRACKED_SESSIONS);
|
|
274
|
+
try {
|
|
275
|
+
const res = await withTimeout(
|
|
276
|
+
ctx.session.context({ sessionID }),
|
|
277
|
+
V2_FALLBACK_TIMEOUT_MS,
|
|
278
|
+
"session.context",
|
|
279
|
+
);
|
|
280
|
+
const msgs = Array.isArray(res) ? res : res?.data;
|
|
281
|
+
if (Array.isArray(msgs)) {
|
|
282
|
+
// Re-check AFTER the await: the context hook may have populated the cache
|
|
283
|
+
// while this fetch was in flight — hook data must win and must never be
|
|
284
|
+
// overwritten by a late fallback.
|
|
285
|
+
const latest = breakdownCache.get(sessionID);
|
|
286
|
+
if (!latest || latest.source !== "context-hook") {
|
|
287
|
+
breakdownCache.set(sessionID, {
|
|
288
|
+
...estimateBreakdownFromClientMessages(msgs),
|
|
289
|
+
capturedAt: Date.now(),
|
|
290
|
+
source: "client-context",
|
|
291
|
+
});
|
|
292
|
+
pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
} catch (err) {
|
|
296
|
+
logErr("v2 session.context fallback failed", err);
|
|
297
|
+
}
|
|
298
|
+
// Opportunistically resolve the model: Desktop selects it AFTER
|
|
299
|
+
// session.created, so without this (or session.model.selected) a session can
|
|
300
|
+
// stay "unknown" even though its limit is resolvable.
|
|
301
|
+
if (
|
|
302
|
+
!lastKnownModelCache.has(sessionID) ||
|
|
303
|
+
!lastKnownProviderCache.has(sessionID)
|
|
304
|
+
) {
|
|
305
|
+
try {
|
|
306
|
+
const info = await withTimeout(
|
|
307
|
+
ctx.session.get({ sessionID }),
|
|
308
|
+
V2_FALLBACK_TIMEOUT_MS,
|
|
309
|
+
"session.get",
|
|
310
|
+
);
|
|
311
|
+
const model = info?.model ?? info?.data?.model;
|
|
312
|
+
if (model && typeof model === "object") {
|
|
313
|
+
if (typeof model.id === "string") lastKnownModelCache.set(sessionID, model.id);
|
|
314
|
+
if (typeof model.providerID === "string") {
|
|
315
|
+
lastKnownProviderCache.set(sessionID, model.providerID);
|
|
316
|
+
}
|
|
317
|
+
pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
|
|
318
|
+
pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
logErr("v2 session.get model fallback failed", err);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// V2 subagent aggregation: children are discovered from session.created
|
|
327
|
+
// (parentID), their totals accumulated from session.step.ended. No
|
|
328
|
+
// session.children/messages API needed. Synchronous (in-memory only).
|
|
329
|
+
function aggregateSubagentsV2(sessionID) {
|
|
330
|
+
const result = { count: 0, input: 0, output: 0, reasoning: 0, perChild: [] };
|
|
331
|
+
for (const [childID, parentID] of sessionParentCache) {
|
|
332
|
+
if (parentID !== sessionID) continue;
|
|
333
|
+
result.count++;
|
|
334
|
+
const t = sessionTotals.get(childID);
|
|
335
|
+
if (t) {
|
|
336
|
+
result.input += t.input || 0;
|
|
337
|
+
result.output += t.output || 0;
|
|
338
|
+
result.reasoning += t.reasoning || 0;
|
|
339
|
+
const lim = getModelLimit(t.providerID, t.modelID);
|
|
340
|
+
const ctxTokens = typeof t.ctx === "number" ? t.ctx : 0;
|
|
341
|
+
result.perChild.push({
|
|
342
|
+
id: childID,
|
|
343
|
+
input: ctxTokens,
|
|
344
|
+
limit: lim,
|
|
345
|
+
ratio: lim ? ctxTokens / lim : null,
|
|
346
|
+
});
|
|
347
|
+
} else {
|
|
348
|
+
result.perChild.push({ id: childID, input: 0, limit: null, ratio: null });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// V2: assemble the live breakdown lines (no client, no fallback fetch —
|
|
355
|
+
// the context hook fills breakdownCache on every request).
|
|
356
|
+
function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens, reasoningTokens) {
|
|
357
|
+
const lines = [];
|
|
358
|
+
const ts = new Date().toISOString();
|
|
359
|
+
const limit = getModelLimit(providerID, modelID);
|
|
360
|
+
const usableInfo = getUsableContext(providerID, modelID);
|
|
361
|
+
const total = typeof ctxTokens === "number" && ctxTokens > 0 ? ctxTokens : inputTokens;
|
|
362
|
+
const denom =
|
|
363
|
+
usableInfo != null
|
|
364
|
+
? usableInfo.usable
|
|
365
|
+
: limit != null && limit > 0
|
|
366
|
+
? limit
|
|
367
|
+
: null;
|
|
368
|
+
const pct =
|
|
369
|
+
denom != null ? ` (${((total / denom) * 100).toFixed(1)}%)` : "";
|
|
370
|
+
|
|
371
|
+
lines.push(
|
|
372
|
+
`[${ts}] session=${sessionID} model=${modelID} ctx=${fmt(total)}/${denom != null ? fmt(denom) : "?"}${pct} count=${fmt(total)} usable=${usableInfo != null ? fmt(usableInfo.usable) : "?"} limit=${limit != null ? fmt(limit) : "?"} maxOutput=${usableInfo != null ? fmt(usableInfo.maxOutput) : "?"} reserved=${usableInfo != null ? fmt(usableInfo.reserved) : "?"} input=${fmt(inputTokens)}`,
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const bd = breakdownCache.get(sessionID);
|
|
376
|
+
const source = bd?.source || "none";
|
|
377
|
+
const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
|
|
378
|
+
|
|
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);
|
|
386
|
+
|
|
387
|
+
if (bd) {
|
|
388
|
+
lines.push(` user ${fmt(bd.userChars || 0)} (est tokens)`);
|
|
389
|
+
lines.push(` assistant ${fmt(bd.assistantChars || 0)} (est tokens)`);
|
|
390
|
+
const reasoningNote = rt > 0 ? ` (exact ${fmt(rt)})` : "";
|
|
391
|
+
lines.push(` reasoning ${fmt(bd.reasoningChars || 0)} (est tokens)${reasoningNote}`);
|
|
392
|
+
lines.push(` tool args ${fmt(bd.toolArgsChars || 0)} (est tokens, input only)`);
|
|
393
|
+
lines.push(
|
|
394
|
+
bd.systemChars != null
|
|
395
|
+
? ` system ${fmt(bd.systemChars)} (est tokens)`
|
|
396
|
+
: ` system n/a (hook did not run)`,
|
|
397
|
+
);
|
|
398
|
+
lines.push(
|
|
399
|
+
bd.toolSchemasChars != null
|
|
400
|
+
? ` tool schemas ${fmt(bd.toolSchemasChars)} (est tokens)`
|
|
401
|
+
: ` tool schemas n/a`,
|
|
402
|
+
);
|
|
403
|
+
} else {
|
|
404
|
+
lines.push(` user n/a (no breakdown captured yet)`);
|
|
405
|
+
lines.push(` assistant n/a`);
|
|
406
|
+
lines.push(` reasoning n/a`);
|
|
407
|
+
lines.push(` tool args n/a`);
|
|
408
|
+
lines.push(` system n/a (hook did not run)`);
|
|
409
|
+
lines.push(` tool schemas n/a`);
|
|
410
|
+
}
|
|
411
|
+
lines.push(` other ${fmt(other)} (= input - sum of estimates)`);
|
|
412
|
+
lines.push(` source ${source}`);
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const sub = aggregateSubagentsV2(sessionID);
|
|
416
|
+
if (sub.count > 0) {
|
|
417
|
+
const ratios = (sub.perChild || [])
|
|
418
|
+
.map((c) => c?.ratio)
|
|
419
|
+
.filter((r) => typeof r === "number");
|
|
420
|
+
const worst =
|
|
421
|
+
ratios.length > 0
|
|
422
|
+
? ` worst=${(Math.max(...ratios) * 100).toFixed(0)}%`
|
|
423
|
+
: "";
|
|
424
|
+
lines.push(
|
|
425
|
+
` subagents ${sub.count} session(s): in=${fmt(sub.input)} out=${fmt(sub.output)} r=${fmt(sub.reasoning)}${worst}`,
|
|
426
|
+
);
|
|
427
|
+
} else {
|
|
428
|
+
lines.push(` subagents 0`);
|
|
429
|
+
}
|
|
430
|
+
} catch (err) {
|
|
431
|
+
logErr("v2 subagent aggregation failed", err);
|
|
432
|
+
lines.push(` subagents n/a`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return lines;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// V2 event handler. `event` is a V2Event ({ type, data, ... }).
|
|
439
|
+
async function handleV2Event(ctx, event) {
|
|
440
|
+
if (!event || typeof event.type !== "string") return;
|
|
441
|
+
|
|
442
|
+
// DEBUG tap (fault-tolerant, off via flag).
|
|
443
|
+
debugEvent(event);
|
|
444
|
+
|
|
445
|
+
// --- session lifecycle: parent links + model hints ---
|
|
446
|
+
if (event.type === "session.created") {
|
|
447
|
+
const data = event.data || {};
|
|
448
|
+
const sid = data.sessionID;
|
|
449
|
+
if (!sid) return;
|
|
450
|
+
const parentID = data.parentID || null;
|
|
451
|
+
// sessionParentCache is the durable parent link used when WRITING the
|
|
452
|
+
// state.json entry (writeStateFile); discovery itself reads the persisted
|
|
453
|
+
// parentID chain — see collectDescendantSessionIDs.
|
|
454
|
+
sessionParentCache.set(sid, parentID);
|
|
455
|
+
pruneMap(sessionParentCache, MAX_TRACKED_SESSIONS);
|
|
456
|
+
if (typeof data.agent === "string" && data.agent) {
|
|
457
|
+
sessionAgentCache.set(sid, data.agent);
|
|
458
|
+
pruneMap(sessionAgentCache, MAX_TRACKED_SESSIONS);
|
|
459
|
+
}
|
|
460
|
+
if (data.model && typeof data.model === "object") {
|
|
461
|
+
if (typeof data.model.id === "string") lastKnownModelCache.set(sid, data.model.id);
|
|
462
|
+
if (typeof data.model.providerID === "string") {
|
|
463
|
+
lastKnownProviderCache.set(sid, data.model.providerID);
|
|
464
|
+
}
|
|
465
|
+
pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
|
|
466
|
+
pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Model chosen after session creation (the normal Desktop flow: the user
|
|
472
|
+
// picks the model in the UI). Without this the session stays "unknown"
|
|
473
|
+
// because session.created carries no model and session.step.ended has no
|
|
474
|
+
// model field.
|
|
475
|
+
if (event.type === "session.model.selected") {
|
|
476
|
+
const d = event.data || {};
|
|
477
|
+
const sid = d.sessionID;
|
|
478
|
+
if (!sid) return;
|
|
479
|
+
if (d.model && typeof d.model === "object") {
|
|
480
|
+
if (typeof d.model.id === "string") lastKnownModelCache.set(sid, d.model.id);
|
|
481
|
+
if (typeof d.model.providerID === "string") {
|
|
482
|
+
lastKnownProviderCache.set(sid, d.model.providerID);
|
|
483
|
+
}
|
|
484
|
+
pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
|
|
485
|
+
pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (event.type === "session.deleted") {
|
|
491
|
+
const sid = event.data?.sessionID;
|
|
492
|
+
if (!sid) return;
|
|
493
|
+
// Discovery reads the persisted parentID chain, so a deleted session drops
|
|
494
|
+
// out of the table as soon as its state.json entry is gone (or, while the
|
|
495
|
+
// stale entry lingers, via the phantom filter — a deleted session has no
|
|
496
|
+
// step). No dedicated in-memory index to clean up.
|
|
497
|
+
clearSessionTracking(sid);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (event.type === "session.compacted") {
|
|
502
|
+
const sid = event.data?.sessionID;
|
|
503
|
+
if (!sid) return;
|
|
504
|
+
try {
|
|
505
|
+
const lines = buildLiveLinesV2(
|
|
506
|
+
sid,
|
|
507
|
+
lastKnownModel(sid),
|
|
508
|
+
lastKnownProvider(sid),
|
|
509
|
+
lastKnownInput(sid),
|
|
510
|
+
lastKnownCtx(sid),
|
|
511
|
+
lastKnownReasoning(sid),
|
|
512
|
+
);
|
|
513
|
+
writeStateFile(
|
|
514
|
+
sid,
|
|
515
|
+
lastKnownModel(sid),
|
|
516
|
+
lastKnownProvider(sid),
|
|
517
|
+
lastKnownInput(sid),
|
|
518
|
+
lastKnownCtx(sid),
|
|
519
|
+
lastKnownReasoning(sid),
|
|
520
|
+
);
|
|
521
|
+
pushFinalSummary(
|
|
522
|
+
`compacted:${event.id ?? sid}`,
|
|
523
|
+
[
|
|
524
|
+
`[${new Date().toISOString()}] COMPACTED session=${sid} (auto — the context may have overflowed)`,
|
|
525
|
+
...lines.slice(1),
|
|
526
|
+
],
|
|
527
|
+
);
|
|
528
|
+
} catch (err) {
|
|
529
|
+
logErr("v2 session.compacted handling failed", err);
|
|
530
|
+
}
|
|
531
|
+
clearSessionTracking(sid, true);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (event.type === "session.idle") {
|
|
536
|
+
const sid = event.data?.sessionID;
|
|
537
|
+
if (!sid) return;
|
|
538
|
+
try {
|
|
539
|
+
// Fire-and-forget: the client-API fallback must NOT block this
|
|
540
|
+
// instance's event loop (it would delay every other session's events).
|
|
541
|
+
// The result is picked up by a later state write; the 5s throttle bounds
|
|
542
|
+
// the cost. Never rejects.
|
|
543
|
+
void ensureV2Breakdown(ctx, sid).catch(() => {});
|
|
544
|
+
const lines = buildLiveLinesV2(
|
|
545
|
+
sid,
|
|
546
|
+
lastKnownModel(sid),
|
|
547
|
+
lastKnownProvider(sid),
|
|
548
|
+
lastKnownInput(sid),
|
|
549
|
+
lastKnownCtx(sid),
|
|
550
|
+
lastKnownReasoning(sid),
|
|
551
|
+
);
|
|
552
|
+
writeStateFile(
|
|
553
|
+
sid,
|
|
554
|
+
lastKnownModel(sid),
|
|
555
|
+
lastKnownProvider(sid),
|
|
556
|
+
lastKnownInput(sid),
|
|
557
|
+
lastKnownCtx(sid),
|
|
558
|
+
lastKnownReasoning(sid),
|
|
559
|
+
);
|
|
560
|
+
pushFinalSummary(`final:${event.id ?? sid}`, [
|
|
561
|
+
`[${new Date().toISOString()}] FINAL session=${sid}`,
|
|
562
|
+
...lines.slice(1),
|
|
563
|
+
]);
|
|
564
|
+
} catch (err) {
|
|
565
|
+
logErr("v2 final summary on idle failed", err);
|
|
566
|
+
}
|
|
567
|
+
clearSessionTracking(sid);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// --- finished LLM step: tokens + live breakdown snapshot ---
|
|
572
|
+
if (event.type === "session.step.ended") {
|
|
573
|
+
const data = event.data || {};
|
|
574
|
+
const sid = data.sessionID;
|
|
575
|
+
if (!sid) return;
|
|
576
|
+
const t = data.tokens || {};
|
|
577
|
+
const input = typeof t.input === "number" ? t.input : 0;
|
|
578
|
+
const output = typeof t.output === "number" ? t.output : 0;
|
|
579
|
+
const reasoning = typeof t.reasoning === "number" ? t.reasoning : 0;
|
|
580
|
+
const contextTokens = tokenCountOf(t);
|
|
581
|
+
|
|
582
|
+
lastKnownInputCache.set(sid, input);
|
|
583
|
+
lastKnownCtxCache.set(sid, contextTokens);
|
|
584
|
+
lastKnownReasoningCache.set(sid, reasoning);
|
|
585
|
+
if (!lastKnownModelCache.has(sid) && data.model) {
|
|
586
|
+
// Some builds put the model on the step payload; harmless when absent.
|
|
587
|
+
if (typeof data.model.id === "string") lastKnownModelCache.set(sid, data.model.id);
|
|
588
|
+
if (typeof data.model.providerID === "string") {
|
|
589
|
+
lastKnownProviderCache.set(sid, data.model.providerID);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
pruneMap(lastKnownInputCache, MAX_TRACKED_SESSIONS);
|
|
593
|
+
pruneMap(lastKnownCtxCache, MAX_TRACKED_SESSIONS);
|
|
594
|
+
pruneMap(lastKnownReasoningCache, MAX_TRACKED_SESSIONS);
|
|
595
|
+
pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
|
|
596
|
+
pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
|
|
597
|
+
|
|
598
|
+
// Accumulate per-session totals for parent subagent roll-up.
|
|
599
|
+
const tot = sessionTotals.get(sid) || {
|
|
600
|
+
input: 0,
|
|
601
|
+
output: 0,
|
|
602
|
+
reasoning: 0,
|
|
603
|
+
ctx: 0,
|
|
604
|
+
modelID: null,
|
|
605
|
+
providerID: "",
|
|
606
|
+
at: 0,
|
|
607
|
+
};
|
|
608
|
+
tot.input += input;
|
|
609
|
+
tot.output += output;
|
|
610
|
+
tot.reasoning += reasoning;
|
|
611
|
+
tot.ctx = contextTokens;
|
|
612
|
+
tot.modelID = lastKnownModel(sid);
|
|
613
|
+
tot.providerID = lastKnownProvider(sid);
|
|
614
|
+
tot.at = Date.now();
|
|
615
|
+
sessionTotals.set(sid, tot);
|
|
616
|
+
pruneMap(sessionTotals, MAX_TRACKED_SESSIONS);
|
|
617
|
+
|
|
618
|
+
// Ensure limits are available (fault-tolerant; await is cheap after warm).
|
|
619
|
+
try {
|
|
620
|
+
await ensureModelLimits(ctx);
|
|
621
|
+
} catch {
|
|
622
|
+
// ignore — absolute token figures only
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Make sure a breakdown exists for this session even if the context hook
|
|
626
|
+
// did not fire here (cross-instance mirror / hook gap). Fire-and-forget on
|
|
627
|
+
// purpose: awaiting it would serialize this instance's event handling
|
|
628
|
+
// behind up to the full client timeout. The cached result is written on the
|
|
629
|
+
// NEXT event; the 5s throttle bounds the cost. Never rejects.
|
|
630
|
+
void ensureV2Breakdown(ctx, sid).catch(() => {});
|
|
631
|
+
|
|
632
|
+
const now = Date.now();
|
|
633
|
+
const lastWrite = lastBdWrite.get(sid) || 0;
|
|
634
|
+
if (now - lastWrite >= BREAKDOWN_THROTTLE_MS) {
|
|
635
|
+
lastBdWrite.set(sid, now);
|
|
636
|
+
pruneMap(lastBdWrite, MAX_TRACKED_SESSIONS);
|
|
637
|
+
try {
|
|
638
|
+
const lines = buildLiveLinesV2(
|
|
639
|
+
sid,
|
|
640
|
+
lastKnownModel(sid),
|
|
641
|
+
lastKnownProvider(sid),
|
|
642
|
+
input,
|
|
643
|
+
contextTokens,
|
|
644
|
+
reasoning,
|
|
645
|
+
);
|
|
646
|
+
writeStateFile(
|
|
647
|
+
sid,
|
|
648
|
+
lastKnownModel(sid),
|
|
649
|
+
lastKnownProvider(sid),
|
|
650
|
+
input,
|
|
651
|
+
contextTokens,
|
|
652
|
+
reasoning,
|
|
653
|
+
);
|
|
654
|
+
writeLog(lines);
|
|
655
|
+
} catch (err) {
|
|
656
|
+
logErr("v2 live breakdown write failed", err);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// --- failed step / execution: persist a claimed one-line note ---
|
|
663
|
+
if (
|
|
664
|
+
event.type === "session.step.failed" ||
|
|
665
|
+
event.type === "session.execution.failed" ||
|
|
666
|
+
event.type === "session.execution.interrupted"
|
|
667
|
+
) {
|
|
668
|
+
const data = event.data || {};
|
|
669
|
+
// sid / error text come from the event payload (untrusted): collapse newlines
|
|
670
|
+
// so they cannot forge extra rows in the human-readable log (CWE-117).
|
|
671
|
+
const sid = oneLine(data.sessionID || "?");
|
|
672
|
+
const interrupted = event.type === "session.execution.interrupted";
|
|
673
|
+
const err = data.error || {};
|
|
674
|
+
const name = interrupted
|
|
675
|
+
? "Interrupted"
|
|
676
|
+
: typeof err.type === "string"
|
|
677
|
+
? oneLine(err.type)
|
|
678
|
+
: "session error";
|
|
679
|
+
const detail = oneLine(err.message || data.reason || "").slice(0, 200);
|
|
680
|
+
const bucket = Math.floor(Date.now() / ERROR_TOAST_DEDUP_MS);
|
|
681
|
+
pushFinalSummary(`err:${sid}:${name}:${bucket}`, [
|
|
682
|
+
`[${new Date().toISOString()}] ERROR session=${sid} ${name}${detail ? `: ${detail}` : ""}`,
|
|
683
|
+
]);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export {
|
|
688
|
+
onV2Context,
|
|
689
|
+
handleV2Event,
|
|
690
|
+
estimateBreakdownFromClientMessages,
|
|
691
|
+
V2_FALLBACK_TIMEOUT_MS,
|
|
692
|
+
};
|