opencode-context-indicator 1.1.4 → 1.1.6
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 +4 -3
- package/index.js +28 -2794
- package/lib/cache.js +161 -0
- package/lib/commands.js +489 -0
- package/lib/dedup.js +12 -3
- 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 +1 -1
package/lib/v1.js
ADDED
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
// lib/v1.js
|
|
2
|
+
//
|
|
3
|
+
// The V1 (opencode >= 1.18.29) entrypoint: makeV1(client) and every helper that
|
|
4
|
+
// belongs exclusively to the classic client-based path (message/system
|
|
5
|
+
// transforms, toast throttling and signals, the expensive session.messages
|
|
6
|
+
// fallback, subagent aggregation, and the live log assembly). Extracted 1:1
|
|
7
|
+
// (code + WHY comments) from index.js.
|
|
8
|
+
|
|
9
|
+
import { estJson, estimateTokens, fmt, tokenCountOf } from "./estimate.js";
|
|
10
|
+
import {
|
|
11
|
+
BREAKDOWN_THROTTLE_MS,
|
|
12
|
+
breakdownCache,
|
|
13
|
+
clearSessionTracking,
|
|
14
|
+
COMPACTED_TOAST_DEDUP_MS,
|
|
15
|
+
ERROR_TOAST_DEDUP_MS,
|
|
16
|
+
lastBdWrite,
|
|
17
|
+
lastCompactedToast,
|
|
18
|
+
lastErrorToast,
|
|
19
|
+
lastKnownCtx,
|
|
20
|
+
lastKnownCtxCache,
|
|
21
|
+
lastKnownInput,
|
|
22
|
+
lastKnownInputCache,
|
|
23
|
+
lastKnownModel,
|
|
24
|
+
lastKnownModelCache,
|
|
25
|
+
lastKnownProvider,
|
|
26
|
+
lastKnownProviderCache,
|
|
27
|
+
lastKnownReasoning,
|
|
28
|
+
lastKnownReasoningCache,
|
|
29
|
+
lastShown,
|
|
30
|
+
MAX_TRACKED_SESSIONS,
|
|
31
|
+
msgFetchTimes,
|
|
32
|
+
sessionParentCache,
|
|
33
|
+
subagentCache,
|
|
34
|
+
toolSchemaCache,
|
|
35
|
+
TOOL_SCHEMA_TTL_MS,
|
|
36
|
+
} from "./cache.js";
|
|
37
|
+
import {
|
|
38
|
+
ensureProviderLimits,
|
|
39
|
+
getModelLimit,
|
|
40
|
+
getUsableContext,
|
|
41
|
+
modelKey,
|
|
42
|
+
modelLimits,
|
|
43
|
+
modelPrices,
|
|
44
|
+
} from "./limits.js";
|
|
45
|
+
import {
|
|
46
|
+
debugEvent,
|
|
47
|
+
logErr,
|
|
48
|
+
pushFinalSummary,
|
|
49
|
+
writeLog,
|
|
50
|
+
writeStateFile,
|
|
51
|
+
} from "./state.js";
|
|
52
|
+
import { pruneMap } from "./dedup.js";
|
|
53
|
+
|
|
54
|
+
const THROTTLE_MS = 2500; // min gap between toasts
|
|
55
|
+
const MIN_GROWTH_TOKENS = 2048; // only re-toast if context grew by this many tokens
|
|
56
|
+
|
|
57
|
+
const MSG_FETCH_THROTTLE_MS = 30000; // min gap between fallback session.messages calls
|
|
58
|
+
const SUBAGENT_THROTTLE_MS = 30000; // min gap between subagent aggregations
|
|
59
|
+
|
|
60
|
+
const CRITICAL_RATIO = 0.9; // > this fill: bypass growth gate, shorter throttle
|
|
61
|
+
const CRITICAL_THROTTLE_MS = 10000; // min gap between critical (>90%) toasts
|
|
62
|
+
|
|
63
|
+
const TOAST_MS_ERROR = 8000; // duration for error / overflow toasts
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// V1 breakdown estimation from messages (Array<{info, parts}>).
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
function estimateBreakdown(messages) {
|
|
70
|
+
let userChars = 0;
|
|
71
|
+
let assistantChars = 0;
|
|
72
|
+
let reasoningTokens = 0;
|
|
73
|
+
let reasoningChars = 0;
|
|
74
|
+
let toolArgsChars = 0;
|
|
75
|
+
|
|
76
|
+
for (const { info, parts } of messages || []) {
|
|
77
|
+
const partsArr = parts || [];
|
|
78
|
+
if (info?.role === "user") {
|
|
79
|
+
for (const p of partsArr) {
|
|
80
|
+
if (p?.type === "text") userChars += estimateTokens(p.text);
|
|
81
|
+
}
|
|
82
|
+
} else if (info?.role === "assistant") {
|
|
83
|
+
// Exact reasoning token count straight from the message metadata.
|
|
84
|
+
if (typeof info?.tokens?.reasoning === "number") {
|
|
85
|
+
reasoningTokens += info.tokens.reasoning;
|
|
86
|
+
}
|
|
87
|
+
for (const p of partsArr) {
|
|
88
|
+
if (p?.type === "text") {
|
|
89
|
+
assistantChars += estimateTokens(p.text);
|
|
90
|
+
} else if (p?.type === "reasoning") {
|
|
91
|
+
reasoningChars += estimateTokens(p.text);
|
|
92
|
+
} else if (p?.type === "tool") {
|
|
93
|
+
// ONLY the call arguments (state.input). Deliberately NOT summing
|
|
94
|
+
// state.output — in some sessions tool output reached ~29 MB of
|
|
95
|
+
// chars, which a naive char-based estimate turns into "7 million tokens" and a
|
|
96
|
+
// negative remainder. Tool output is not part of the sent prompt.
|
|
97
|
+
toolArgsChars += estJson(p.state?.input);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
userChars,
|
|
105
|
+
assistantChars,
|
|
106
|
+
reasoningTokens,
|
|
107
|
+
reasoningChars,
|
|
108
|
+
toolArgsChars,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// V1 tool schema estimate (cached per modelID).
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
async function getToolSchemas(client, providerID, modelID) {
|
|
117
|
+
const key = modelKey(providerID, modelID);
|
|
118
|
+
const cached = key ? toolSchemaCache.get(key) : null;
|
|
119
|
+
if (cached && Date.now() - cached.at < TOOL_SCHEMA_TTL_MS) {
|
|
120
|
+
return cached.chars;
|
|
121
|
+
}
|
|
122
|
+
let chars = 0;
|
|
123
|
+
try {
|
|
124
|
+
const res = await client.tool.list({ query: { provider: providerID, model: modelID } });
|
|
125
|
+
const tools = Array.isArray(res) ? res : res?.data;
|
|
126
|
+
if (Array.isArray(tools)) {
|
|
127
|
+
for (const t of tools) {
|
|
128
|
+
chars += estimateTokens(t?.description) + estJson(t?.parameters);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
logErr(`tool.list failed for ${modelID}`, err);
|
|
133
|
+
}
|
|
134
|
+
if (key) {
|
|
135
|
+
toolSchemaCache.set(key, { chars, at: Date.now() });
|
|
136
|
+
pruneMap(toolSchemaCache, MAX_TRACKED_SESSIONS);
|
|
137
|
+
}
|
|
138
|
+
return chars;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// V1 subagent (child session) aggregation — throttled + cached.
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
async function aggregateSubagents(client, sessionID) {
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
const cached = subagentCache.get(sessionID);
|
|
148
|
+
if (cached && now - cached.at < SUBAGENT_THROTTLE_MS) {
|
|
149
|
+
return cached;
|
|
150
|
+
}
|
|
151
|
+
const result = { count: 0, input: 0, output: 0, reasoning: 0, perChild: [], at: now };
|
|
152
|
+
try {
|
|
153
|
+
const res = await client.session.children({ path: { id: sessionID } });
|
|
154
|
+
const children = Array.isArray(res) ? res : res?.data;
|
|
155
|
+
if (Array.isArray(children)) {
|
|
156
|
+
result.count = children.length;
|
|
157
|
+
for (const child of children) {
|
|
158
|
+
try {
|
|
159
|
+
const msgsRes = await client.session.messages({ path: { id: child.id } });
|
|
160
|
+
const msgs = Array.isArray(msgsRes) ? msgsRes : msgsRes?.data;
|
|
161
|
+
let childInput = 0;
|
|
162
|
+
let childModelID = null;
|
|
163
|
+
let childProviderID = "";
|
|
164
|
+
for (const { info } of msgs || []) {
|
|
165
|
+
if (info?.role === "assistant" && info?.tokens) {
|
|
166
|
+
result.input += info.tokens.input || 0;
|
|
167
|
+
result.output += info.tokens.output || 0;
|
|
168
|
+
result.reasoning += info.tokens.reasoning || 0;
|
|
169
|
+
// Per-child fill: the LAST assistant message's native overflow
|
|
170
|
+
// count (tokens.total, else input + output + cache.read +
|
|
171
|
+
// cache.write, incl. legacy cacheRead/cacheWrite) + that child's
|
|
172
|
+
// model — same formula as the main path, so cache-heavy children
|
|
173
|
+
// are not under-reported and still get the ⚠ marker.
|
|
174
|
+
if (typeof info.tokens.input === "number") {
|
|
175
|
+
childInput = tokenCountOf(info.tokens);
|
|
176
|
+
childModelID = info.modelID || childModelID;
|
|
177
|
+
childProviderID = info.providerID || childProviderID;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const childLimit = getModelLimit(childProviderID, childModelID);
|
|
182
|
+
result.perChild.push({
|
|
183
|
+
id: child.id,
|
|
184
|
+
input: childInput,
|
|
185
|
+
limit: childLimit,
|
|
186
|
+
ratio: childLimit ? childInput / childLimit : null,
|
|
187
|
+
});
|
|
188
|
+
} catch (err) {
|
|
189
|
+
logErr(`subagent messages failed for ${child.id}`, err);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
logErr(`session.children failed for ${sessionID}`, err);
|
|
195
|
+
}
|
|
196
|
+
subagentCache.set(sessionID, result);
|
|
197
|
+
pruneMap(subagentCache, MAX_TRACKED_SESSIONS);
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// V1 toast helpers: attribution, safe showing, error/overflow toasts.
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
// "[main]" / "[sub]" prefix from Session.parentID. Cached from
|
|
206
|
+
// session.created/updated; falls back to client.session.get once per session.
|
|
207
|
+
// Unknown attribution -> "" (never blocks the toast).
|
|
208
|
+
async function sessionPrefix(client, sessionID) {
|
|
209
|
+
if (!sessionID) return "";
|
|
210
|
+
try {
|
|
211
|
+
if (!sessionParentCache.has(sessionID)) {
|
|
212
|
+
const res = await client.session.get({ path: { id: sessionID } });
|
|
213
|
+
const info = res?.data?.info ?? res?.data ?? res?.info ?? res;
|
|
214
|
+
sessionParentCache.set(sessionID, info?.parentID || null);
|
|
215
|
+
pruneMap(sessionParentCache, MAX_TRACKED_SESSIONS);
|
|
216
|
+
}
|
|
217
|
+
return sessionParentCache.get(sessionID) ? "[sub]" : "[main]";
|
|
218
|
+
} catch (err) {
|
|
219
|
+
logErr(`session.get failed for ${sessionID}`, err);
|
|
220
|
+
return "";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Show a toast; on failure retry once with the "warning" variant (in case the
|
|
225
|
+
// build rejects "error"). Never throws.
|
|
226
|
+
async function showToastSafe(client, message, variant, duration) {
|
|
227
|
+
try {
|
|
228
|
+
await client.tui.showToast({ body: { message, variant, duration } });
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (variant !== "warning") {
|
|
231
|
+
try {
|
|
232
|
+
await client.tui.showToast({ body: { message, variant: "warning", duration } });
|
|
233
|
+
return;
|
|
234
|
+
} catch (err2) {
|
|
235
|
+
logErr(`toast fallback failed (variant ${variant})`, err2);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
logErr("toast error", err);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function truncate(text, max) {
|
|
244
|
+
const s = String(text ?? "");
|
|
245
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Short session id for toasts (last 6 chars).
|
|
249
|
+
function shortID(sessionID) {
|
|
250
|
+
if (!sessionID) return "";
|
|
251
|
+
const s = String(sessionID);
|
|
252
|
+
return s.length > 8 ? `…${s.slice(-6)}` : s;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Best-effort message extraction from an arbitrary error payload.
|
|
256
|
+
function errMessageOf(err) {
|
|
257
|
+
if (!err) return "";
|
|
258
|
+
if (typeof err === "string") return err;
|
|
259
|
+
return err?.data?.message || err.message || "";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Dedup for error-ish toasts: identical signature for the same session is
|
|
263
|
+
// suppressed within ERROR_TOAST_DEDUP_MS. Returns true when allowed.
|
|
264
|
+
function errorToastDedup(sessionID, sig) {
|
|
265
|
+
const key = sessionID || "__unknown__";
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
const prev = lastErrorToast.get(key);
|
|
268
|
+
if (prev && prev.sig === sig && now - prev.at < ERROR_TOAST_DEDUP_MS) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
lastErrorToast.set(key, { sig, at: now });
|
|
272
|
+
pruneMap(lastErrorToast, MAX_TRACKED_SESSIONS);
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// session.error payload: { sessionID?, error?: { name, data: { message,
|
|
277
|
+
// statusCode?, isRetryable? } } } — sessionID is optional, handle undefined.
|
|
278
|
+
async function toastSessionError(client, event) {
|
|
279
|
+
const props = event.properties || {};
|
|
280
|
+
const sessionID =
|
|
281
|
+
typeof props.sessionID === "string" ? props.sessionID : undefined;
|
|
282
|
+
const error = props.error || {};
|
|
283
|
+
const name = error.name || error?.data?.name || "session error";
|
|
284
|
+
const detail = errMessageOf(error);
|
|
285
|
+
const status = error?.data?.statusCode;
|
|
286
|
+
const sig = `${name}|${status ?? ""}|${String(detail).slice(0, 120)}`;
|
|
287
|
+
if (!errorToastDedup(sessionID, sig)) return;
|
|
288
|
+
|
|
289
|
+
let text = name;
|
|
290
|
+
try {
|
|
291
|
+
const prefix = await sessionPrefix(client, sessionID);
|
|
292
|
+
if (prefix) text = `${prefix} ${text}`;
|
|
293
|
+
if (status != null) text += ` (HTTP ${status})`;
|
|
294
|
+
if (detail) text += `: ${truncate(detail, 160)}`;
|
|
295
|
+
if (sessionID) text += ` · ${shortID(sessionID)}`;
|
|
296
|
+
} catch (err) {
|
|
297
|
+
logErr("session.error message build failed", err);
|
|
298
|
+
}
|
|
299
|
+
await showToastSafe(client, text.trim(), "error", TOAST_MS_ERROR);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Assistant message with info.error: force a warning toast bypassing the
|
|
303
|
+
// throttle gates (same dedup as session.error).
|
|
304
|
+
async function toastMessageError(client, sessionID, error) {
|
|
305
|
+
const name = (error && (error.name || error?.data?.name)) || "model error";
|
|
306
|
+
const detail = errMessageOf(error);
|
|
307
|
+
const sig = `msg|${name}|${String(detail).slice(0, 120)}`;
|
|
308
|
+
if (!errorToastDedup(sessionID, sig)) return;
|
|
309
|
+
|
|
310
|
+
let text = name;
|
|
311
|
+
try {
|
|
312
|
+
const prefix = await sessionPrefix(client, sessionID);
|
|
313
|
+
if (prefix) text = `${prefix} ${text}`;
|
|
314
|
+
} catch (err) {
|
|
315
|
+
logErr("message.error prefix failed", err);
|
|
316
|
+
}
|
|
317
|
+
if (detail) text += `: ${truncate(detail, 160)}`;
|
|
318
|
+
await showToastSafe(client, text.trim(), "warning", TOAST_MS_ERROR);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// session.compacted (and compaction parts): auto-compaction usually means the
|
|
322
|
+
// context window was exceeded. MUST run BEFORE clearSessionTracking — it reads
|
|
323
|
+
// the last known input/model, which are wiped right after.
|
|
324
|
+
async function toastCompactedSignal(client, sessionID) {
|
|
325
|
+
if (!sessionID) return;
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
const prev = lastCompactedToast.get(sessionID);
|
|
328
|
+
if (prev && now - prev < COMPACTED_TOAST_DEDUP_MS) return;
|
|
329
|
+
lastCompactedToast.set(sessionID, now);
|
|
330
|
+
pruneMap(lastCompactedToast, MAX_TRACKED_SESSIONS);
|
|
331
|
+
|
|
332
|
+
let text = "session compacted (auto) — the context may have overflowed";
|
|
333
|
+
try {
|
|
334
|
+
const prefix = await sessionPrefix(client, sessionID);
|
|
335
|
+
const ctx = lastKnownCtx(sessionID) || lastKnownInput(sessionID);
|
|
336
|
+
const limit = getModelLimit(lastKnownProvider(sessionID), lastKnownModel(sessionID));
|
|
337
|
+
if (ctx > 0) text += ` (ctx ${fmt(ctx)}${limit ? ` / ${fmt(limit)}` : ""})`;
|
|
338
|
+
if (prefix) text = `${prefix} ${text}`;
|
|
339
|
+
} catch (err) {
|
|
340
|
+
logErr("compacted message build failed", err);
|
|
341
|
+
}
|
|
342
|
+
await showToastSafe(client, text.trim(), "warning", TOAST_MS_ERROR);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Best-effort mid-turn hints from message.part.updated: compaction parts
|
|
346
|
+
// mirror session.compacted; retry parts surface provider errors when they
|
|
347
|
+
// carry one. Never blocks the main path.
|
|
348
|
+
async function maybeToastPartSignal(client, event) {
|
|
349
|
+
const part = event.properties?.part;
|
|
350
|
+
if (!part || !part.type) return;
|
|
351
|
+
const sessionID = part.sessionID || event.properties?.sessionID || undefined;
|
|
352
|
+
if (!sessionID) return;
|
|
353
|
+
|
|
354
|
+
if (part.type === "compaction") {
|
|
355
|
+
try {
|
|
356
|
+
await toastCompactedSignal(client, sessionID); // shared dedup window
|
|
357
|
+
} catch (err) {
|
|
358
|
+
logErr("compaction part toast failed", err);
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (part.type !== "retry") return;
|
|
364
|
+
const err = part.error ?? part.state?.error;
|
|
365
|
+
const detail = errMessageOf(err);
|
|
366
|
+
if (!detail) return;
|
|
367
|
+
const name = (err && (err.name || err?.data?.name)) || "retry";
|
|
368
|
+
const sig = `part|${name}|${String(detail).slice(0, 120)}`;
|
|
369
|
+
if (!errorToastDedup(sessionID, sig)) return;
|
|
370
|
+
let text = `${name}: ${truncate(detail, 160)}`;
|
|
371
|
+
try {
|
|
372
|
+
const prefix = await sessionPrefix(client, sessionID);
|
|
373
|
+
if (prefix) text = `${prefix} ${text}`;
|
|
374
|
+
} catch {
|
|
375
|
+
// attribution is optional here
|
|
376
|
+
}
|
|
377
|
+
await showToastSafe(client, text.trim(), "warning", TOAST_MS_ERROR);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// V1 fallback: fetch messages via client.session.messages (expensive, throttled).
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
async function fetchBreakdownFallback(client, sessionID) {
|
|
385
|
+
const now = Date.now();
|
|
386
|
+
const last = msgFetchTimes.get(sessionID) || 0;
|
|
387
|
+
if (now - last < MSG_FETCH_THROTTLE_MS) return null; // too soon -> skip
|
|
388
|
+
msgFetchTimes.set(sessionID, now);
|
|
389
|
+
pruneMap(msgFetchTimes, MAX_TRACKED_SESSIONS);
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
const res = await client.session.messages({ path: { id: sessionID } });
|
|
393
|
+
const msgs = Array.isArray(res) ? res : res?.data;
|
|
394
|
+
const estParts = estimateBreakdown(msgs);
|
|
395
|
+
return { ...estParts, capturedAt: now, source: "fallback" };
|
|
396
|
+
} catch (err) {
|
|
397
|
+
logErr(`session.messages fallback failed for ${sessionID}`, err);
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// V1: assemble the live breakdown lines for the log file.
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
async function buildLiveLines(client, sessionID, modelID, providerID, inputTokens, ctxTokens) {
|
|
407
|
+
const lines = [];
|
|
408
|
+
const ts = new Date().toISOString();
|
|
409
|
+
const limit = getModelLimit(providerID, modelID);
|
|
410
|
+
const usableInfo = getUsableContext(providerID, modelID);
|
|
411
|
+
// Total context = native overflow count (tokens.total, else input + output +
|
|
412
|
+
// cache.read + cache.write); pct has NO upper clamp — exceeding the window
|
|
413
|
+
// must stay visible in the log too. Denominator is the native usable window
|
|
414
|
+
// when derivable, else the raw context window.
|
|
415
|
+
const total = typeof ctxTokens === "number" && ctxTokens > 0 ? ctxTokens : inputTokens;
|
|
416
|
+
const denom =
|
|
417
|
+
usableInfo != null
|
|
418
|
+
? usableInfo.usable
|
|
419
|
+
: limit != null && limit > 0
|
|
420
|
+
? limit
|
|
421
|
+
: null;
|
|
422
|
+
const pct =
|
|
423
|
+
denom != null ? ` (${((total / denom) * 100).toFixed(1)}%)` : "";
|
|
424
|
+
|
|
425
|
+
lines.push(
|
|
426
|
+
`[${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)}`,
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
// --- breakdown from cache (transform hooks) or fallback fetch ---
|
|
430
|
+
let bd = breakdownCache.get(sessionID);
|
|
431
|
+
let source = bd?.source || "none";
|
|
432
|
+
|
|
433
|
+
if (!bd || Date.now() - bd.capturedAt > MSG_FETCH_THROTTLE_MS) {
|
|
434
|
+
// Try to refresh via fallback if we have nothing fresh.
|
|
435
|
+
const fb = await fetchBreakdownFallback(client, sessionID);
|
|
436
|
+
if (fb) {
|
|
437
|
+
bd = fb;
|
|
438
|
+
source = fb.source;
|
|
439
|
+
breakdownCache.set(sessionID, fb);
|
|
440
|
+
pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Tool schemas: cached per modelID (rarely change). Stored into the session
|
|
445
|
+
// breakdown so the estimate is stable across writes.
|
|
446
|
+
let toolSchemasChars = bd?.toolSchemasChars;
|
|
447
|
+
if (toolSchemasChars == null) {
|
|
448
|
+
try {
|
|
449
|
+
toolSchemasChars = await getToolSchemas(client, providerID, modelID);
|
|
450
|
+
if (bd) {
|
|
451
|
+
bd.toolSchemasChars = toolSchemasChars;
|
|
452
|
+
breakdownCache.set(sessionID, bd);
|
|
453
|
+
}
|
|
454
|
+
} catch (err) {
|
|
455
|
+
logErr("tool schema estimate failed", err);
|
|
456
|
+
toolSchemasChars = null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const sumEstimates = (bd?.userChars || 0) +
|
|
461
|
+
(bd?.assistantChars || 0) +
|
|
462
|
+
(bd?.reasoningChars || 0) +
|
|
463
|
+
(bd?.toolArgsChars || 0) +
|
|
464
|
+
(bd?.systemChars || 0) +
|
|
465
|
+
(bd?.toolSchemasChars || 0);
|
|
466
|
+
const other = Math.max(0, inputTokens - sumEstimates);
|
|
467
|
+
|
|
468
|
+
if (bd) {
|
|
469
|
+
lines.push(` user ${fmt(bd.userChars || 0)} (est tokens)`);
|
|
470
|
+
lines.push(` assistant ${fmt(bd.assistantChars || 0)} (est tokens)`);
|
|
471
|
+
const reasoningNote =
|
|
472
|
+
bd.reasoningTokens > 0
|
|
473
|
+
? ` (exact ${fmt(bd.reasoningTokens)})`
|
|
474
|
+
: "";
|
|
475
|
+
lines.push(` reasoning ${fmt(bd.reasoningChars || 0)} (est tokens)${reasoningNote}`);
|
|
476
|
+
lines.push(` tool args ${fmt(bd.toolArgsChars || 0)} (est tokens, input only)`);
|
|
477
|
+
lines.push(
|
|
478
|
+
bd.systemChars != null
|
|
479
|
+
? ` system ${fmt(bd.systemChars)} (est tokens)`
|
|
480
|
+
: ` system n/a (hook did not run)`,
|
|
481
|
+
);
|
|
482
|
+
lines.push(
|
|
483
|
+
bd.toolSchemasChars != null
|
|
484
|
+
? ` tool schemas ${fmt(bd.toolSchemasChars)} (est tokens)`
|
|
485
|
+
: ` tool schemas n/a`,
|
|
486
|
+
);
|
|
487
|
+
} else {
|
|
488
|
+
lines.push(` user n/a (no breakdown captured yet)`);
|
|
489
|
+
lines.push(` assistant n/a`);
|
|
490
|
+
lines.push(` reasoning n/a`);
|
|
491
|
+
lines.push(` tool args n/a`);
|
|
492
|
+
lines.push(` system n/a (hook did not run)`);
|
|
493
|
+
lines.push(` tool schemas n/a`);
|
|
494
|
+
}
|
|
495
|
+
lines.push(` other ${fmt(other)} (= input - sum of estimates)`);
|
|
496
|
+
lines.push(` source ${source}`);
|
|
497
|
+
|
|
498
|
+
// --- subagent aggregation (informational only, own context window) ---
|
|
499
|
+
try {
|
|
500
|
+
const sub = await aggregateSubagents(client, sessionID);
|
|
501
|
+
if (sub.count > 0) {
|
|
502
|
+
const ratios = (sub.perChild || [])
|
|
503
|
+
.map((c) => c?.ratio)
|
|
504
|
+
.filter((r) => typeof r === "number");
|
|
505
|
+
const worst =
|
|
506
|
+
ratios.length > 0
|
|
507
|
+
? ` worst=${(Math.max(...ratios) * 100).toFixed(0)}%`
|
|
508
|
+
: "";
|
|
509
|
+
lines.push(
|
|
510
|
+
` subagents ${sub.count} session(s): in=${fmt(sub.input)} out=${fmt(sub.output)} r=${fmt(sub.reasoning)}${worst}`,
|
|
511
|
+
);
|
|
512
|
+
} else {
|
|
513
|
+
lines.push(` subagents 0`);
|
|
514
|
+
}
|
|
515
|
+
} catch (err) {
|
|
516
|
+
logErr("subagent aggregation failed", err);
|
|
517
|
+
lines.push(` subagents n/a`);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return lines;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// V1 entrypoint (event handler + hooks), preserved unchanged.
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
function makeV1(client) {
|
|
528
|
+
// Fire-and-forget prefetch of effective model limits (fault-tolerant; the
|
|
529
|
+
// config() hook below stays as the fallback source).
|
|
530
|
+
ensureProviderLimits(client).catch((err) =>
|
|
531
|
+
logErr("provider limits prefetch failed", err),
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
config(cfg) {
|
|
536
|
+
try {
|
|
537
|
+
// Provider-agnostic: iterate EVERY provider in the config, regardless
|
|
538
|
+
// of name. V1 configs expose `provider`; some shapes use `providers`.
|
|
539
|
+
const buckets = [cfg?.provider, cfg?.providers].filter(
|
|
540
|
+
(b) => b && typeof b === "object",
|
|
541
|
+
);
|
|
542
|
+
if (buckets.length === 0) return;
|
|
543
|
+
for (const bucket of buckets) {
|
|
544
|
+
for (const [providerID, pdef] of Object.entries(bucket)) {
|
|
545
|
+
const models = pdef?.models;
|
|
546
|
+
if (!models || typeof models !== "object") continue;
|
|
547
|
+
for (const [modelID, def] of Object.entries(models)) {
|
|
548
|
+
const key = modelKey(providerID, modelID);
|
|
549
|
+
if (!key) continue;
|
|
550
|
+
const lim = def?.limit;
|
|
551
|
+
const context =
|
|
552
|
+
typeof lim?.context === "number" && lim.context > 0 ? lim.context : null;
|
|
553
|
+
const input =
|
|
554
|
+
typeof lim?.input === "number" && lim.input > 0 ? lim.input : null;
|
|
555
|
+
const output =
|
|
556
|
+
typeof lim?.output === "number" && lim.output > 0 ? lim.output : null;
|
|
557
|
+
if (context != null || input != null) {
|
|
558
|
+
const prevRec = modelLimits.get(key);
|
|
559
|
+
modelLimits.set(key, {
|
|
560
|
+
context,
|
|
561
|
+
input,
|
|
562
|
+
output,
|
|
563
|
+
reserved:
|
|
564
|
+
typeof def?.compaction?.reserved === "number"
|
|
565
|
+
? def.compaction.reserved
|
|
566
|
+
: (prevRec?.reserved ?? null),
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
// Capture explicit pricing ONLY when present (custom providers
|
|
570
|
+
// usually omit it).
|
|
571
|
+
const c = def?.cost;
|
|
572
|
+
if (
|
|
573
|
+
c &&
|
|
574
|
+
typeof c === "object" &&
|
|
575
|
+
(typeof c.input === "number" || typeof c.output === "number")
|
|
576
|
+
) {
|
|
577
|
+
modelPrices.set(key, {
|
|
578
|
+
input: typeof c.input === "number" ? c.input : 0,
|
|
579
|
+
output: typeof c.output === "number" ? c.output : 0,
|
|
580
|
+
cacheRead: typeof c?.cache?.read === "number" ? c.cache.read : 0,
|
|
581
|
+
cacheWrite: typeof c?.cache?.write === "number" ? c.cache.write : 0,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
} catch {
|
|
588
|
+
// keep whatever we already have
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
|
|
592
|
+
// Capture the final messages right before they are sent to the LLM.
|
|
593
|
+
// We only READ here — never mutate the outgoing messages.
|
|
594
|
+
"experimental.chat.messages.transform"(_input, { messages }) {
|
|
595
|
+
try {
|
|
596
|
+
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
597
|
+
const sid = messages[0]?.info?.sessionID;
|
|
598
|
+
if (!sid) return;
|
|
599
|
+
const estParts = estimateBreakdown(messages);
|
|
600
|
+
const prev = breakdownCache.get(sid) || {};
|
|
601
|
+
breakdownCache.set(sid, {
|
|
602
|
+
...prev,
|
|
603
|
+
...estParts,
|
|
604
|
+
capturedAt: Date.now(),
|
|
605
|
+
source: "transform",
|
|
606
|
+
});
|
|
607
|
+
pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
|
|
608
|
+
} catch (err) {
|
|
609
|
+
logErr("messages.transform failed", err);
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
|
|
613
|
+
// Capture the assembled system prompt (string[]) before send.
|
|
614
|
+
"experimental.chat.system.transform"({ sessionID }, { system }) {
|
|
615
|
+
try {
|
|
616
|
+
const sid = sessionID;
|
|
617
|
+
if (!sid) return;
|
|
618
|
+
let systemChars = 0;
|
|
619
|
+
if (Array.isArray(system)) {
|
|
620
|
+
for (const s of system) systemChars += estimateTokens(s);
|
|
621
|
+
}
|
|
622
|
+
const prev = breakdownCache.get(sid) || {};
|
|
623
|
+
breakdownCache.set(sid, {
|
|
624
|
+
...prev,
|
|
625
|
+
systemChars,
|
|
626
|
+
capturedAt: Date.now(),
|
|
627
|
+
source: prev.source || "system-transform",
|
|
628
|
+
});
|
|
629
|
+
pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
|
|
630
|
+
} catch (err) {
|
|
631
|
+
logErr("system.transform failed", err);
|
|
632
|
+
}
|
|
633
|
+
},
|
|
634
|
+
|
|
635
|
+
async event({ event }) {
|
|
636
|
+
if (!event) return;
|
|
637
|
+
|
|
638
|
+
// DEBUG: log every incoming event type (fault-tolerant, off via flag).
|
|
639
|
+
debugEvent(event);
|
|
640
|
+
|
|
641
|
+
// Cache parent links for [main]/[sub] attribution from lifecycle events
|
|
642
|
+
// (properties.info carries parentID when present).
|
|
643
|
+
if (
|
|
644
|
+
event.type === "session.created" ||
|
|
645
|
+
event.type === "session.updated"
|
|
646
|
+
) {
|
|
647
|
+
try {
|
|
648
|
+
const sinfo = event.properties?.info;
|
|
649
|
+
if (sinfo?.id && sinfo.parentID) {
|
|
650
|
+
sessionParentCache.set(sinfo.id, sinfo.parentID);
|
|
651
|
+
pruneMap(sessionParentCache, MAX_TRACKED_SESSIONS);
|
|
652
|
+
}
|
|
653
|
+
} catch (err) {
|
|
654
|
+
logErr("session parent tracking failed", err);
|
|
655
|
+
}
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Cleanup tracked sessions when they end / are deleted / are compacted.
|
|
660
|
+
if (
|
|
661
|
+
event.type === "session.idle" ||
|
|
662
|
+
event.type === "session.deleted" ||
|
|
663
|
+
event.type === "session.compacted"
|
|
664
|
+
) {
|
|
665
|
+
// session.idle / session.compacted -> properties.sessionID;
|
|
666
|
+
// session.deleted -> properties.info.id
|
|
667
|
+
const sid =
|
|
668
|
+
event.properties?.sessionID ?? event.properties?.info?.id;
|
|
669
|
+
if (!sid) return;
|
|
670
|
+
|
|
671
|
+
// session.compacted: toast BEFORE clearing tracking — it needs the
|
|
672
|
+
// last known input/model, which clearSessionTracking wipes below.
|
|
673
|
+
if (event.type === "session.compacted") {
|
|
674
|
+
try {
|
|
675
|
+
await toastCompactedSignal(client, sid);
|
|
676
|
+
} catch (err) {
|
|
677
|
+
logErr("session.compacted toast failed", err);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (event.type === "session.idle") {
|
|
682
|
+
// Final summary to the log (best effort; never breaks anything).
|
|
683
|
+
// Timestamp captured BEFORE the async build so the cross-instance
|
|
684
|
+
// dedup key is stable across plugin instances.
|
|
685
|
+
const idleAt = Date.now();
|
|
686
|
+
try {
|
|
687
|
+
const modelID = lastKnownModel(sid);
|
|
688
|
+
const providerID = lastKnownProvider(sid);
|
|
689
|
+
const inputTokens = lastKnownInput(sid);
|
|
690
|
+
const lines = await buildLiveLines(
|
|
691
|
+
client,
|
|
692
|
+
sid,
|
|
693
|
+
modelID,
|
|
694
|
+
providerID,
|
|
695
|
+
inputTokens,
|
|
696
|
+
lastKnownCtx(sid),
|
|
697
|
+
);
|
|
698
|
+
writeStateFile(
|
|
699
|
+
sid,
|
|
700
|
+
modelID,
|
|
701
|
+
providerID,
|
|
702
|
+
inputTokens,
|
|
703
|
+
lastKnownCtx(sid),
|
|
704
|
+
lastKnownReasoning(sid),
|
|
705
|
+
);
|
|
706
|
+
// Claim once across instances: without this, every plugin instance
|
|
707
|
+
// appends its own FINAL line. V1 events carry no `id`, so fall back
|
|
708
|
+
// to a short time bucket (same strategy as the error-toast dedup).
|
|
709
|
+
const eventKey = event.id ?? event.properties?.id;
|
|
710
|
+
const claimKey = eventKey
|
|
711
|
+
? `final:${eventKey}`
|
|
712
|
+
: `final:${sid}:${Math.floor(idleAt / 5000)}`;
|
|
713
|
+
pushFinalSummary(claimKey, [
|
|
714
|
+
`[${new Date().toISOString()}] FINAL session=${sid}`,
|
|
715
|
+
...lines.slice(1),
|
|
716
|
+
]);
|
|
717
|
+
} catch (err) {
|
|
718
|
+
logErr("final summary on idle failed", err);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// session.compacted keeps the toast-dedup entries: two consecutive
|
|
723
|
+
// session.compacted events must not produce two toasts (60s window).
|
|
724
|
+
clearSessionTracking(sid, event.type === "session.compacted");
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Session-level errors (provider failures etc.). sessionID is optional
|
|
729
|
+
// in the payload — handled gracefully inside.
|
|
730
|
+
if (event.type === "session.error") {
|
|
731
|
+
try {
|
|
732
|
+
await toastSessionError(client, event);
|
|
733
|
+
} catch (err) {
|
|
734
|
+
logErr("session.error handling failed", err);
|
|
735
|
+
}
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Mid-turn overflow / retry hints from compaction & retry parts.
|
|
740
|
+
if (event.type === "message.part.updated") {
|
|
741
|
+
try {
|
|
742
|
+
await maybeToastPartSignal(client, event);
|
|
743
|
+
} catch (err) {
|
|
744
|
+
logErr("message.part.updated signal failed", err);
|
|
745
|
+
}
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (event.type !== "message.updated") return;
|
|
750
|
+
|
|
751
|
+
const info = event?.properties?.info;
|
|
752
|
+
if (!info || info.role !== "assistant") return;
|
|
753
|
+
|
|
754
|
+
const sessionID = info.sessionID;
|
|
755
|
+
|
|
756
|
+
// Assistant message carrying an error (when present in this build):
|
|
757
|
+
// force a warning toast, bypassing the throttle gates. Same dedup as
|
|
758
|
+
// session.error.
|
|
759
|
+
if (info.error) {
|
|
760
|
+
try {
|
|
761
|
+
await toastMessageError(client, sessionID, info.error);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
logErr("info.error toast failed", err);
|
|
764
|
+
}
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const tokens = info.tokens;
|
|
769
|
+
if (!tokens || typeof tokens.input !== "number") return;
|
|
770
|
+
|
|
771
|
+
// Context size components. The "how full is the window" metric is the
|
|
772
|
+
// native opencode overflow count: tokens.total when present, else
|
|
773
|
+
// input + output + cache.read + cache.write (cache tokens included).
|
|
774
|
+
const cacheRead =
|
|
775
|
+
typeof tokens.cache?.read === "number"
|
|
776
|
+
? tokens.cache.read
|
|
777
|
+
: typeof tokens.cacheRead === "number"
|
|
778
|
+
? tokens.cacheRead
|
|
779
|
+
: 0;
|
|
780
|
+
const cacheWrite =
|
|
781
|
+
typeof tokens.cache?.write === "number"
|
|
782
|
+
? tokens.cache.write
|
|
783
|
+
: typeof tokens.cacheWrite === "number"
|
|
784
|
+
? tokens.cacheWrite
|
|
785
|
+
: 0;
|
|
786
|
+
const contextTokens = tokenCountOf(tokens);
|
|
787
|
+
|
|
788
|
+
// Remember last known model/provider/input for the idle final summary.
|
|
789
|
+
if (info.modelID) lastKnownModelCache.set(sessionID, info.modelID);
|
|
790
|
+
if (info.providerID) lastKnownProviderCache.set(sessionID, info.providerID);
|
|
791
|
+
lastKnownInputCache.set(sessionID, tokens.input);
|
|
792
|
+
if (typeof tokens.reasoning === "number") {
|
|
793
|
+
lastKnownReasoningCache.set(sessionID, tokens.reasoning);
|
|
794
|
+
}
|
|
795
|
+
lastKnownCtxCache.set(sessionID, contextTokens);
|
|
796
|
+
pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
|
|
797
|
+
pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
|
|
798
|
+
pruneMap(lastKnownInputCache, MAX_TRACKED_SESSIONS);
|
|
799
|
+
pruneMap(lastKnownReasoningCache, MAX_TRACKED_SESSIONS);
|
|
800
|
+
pruneMap(lastKnownCtxCache, MAX_TRACKED_SESSIONS);
|
|
801
|
+
|
|
802
|
+
// Resolve the limit for THIS message's model, keyed by
|
|
803
|
+
// providerID:modelID. Prefer the effective limits from
|
|
804
|
+
// client.config.providers(); the config() hook remains the fallback.
|
|
805
|
+
const mkey = modelKey(info.providerID, info.modelID);
|
|
806
|
+
let limit = getModelLimit(info.providerID, info.modelID);
|
|
807
|
+
let usableInfo = getUsableContext(info.providerID, info.modelID);
|
|
808
|
+
if (limit == null && usableInfo == null) {
|
|
809
|
+
try {
|
|
810
|
+
await ensureProviderLimits(client);
|
|
811
|
+
} catch {
|
|
812
|
+
// ignore — fall back to whatever the config() hook provided
|
|
813
|
+
}
|
|
814
|
+
limit = getModelLimit(info.providerID, info.modelID);
|
|
815
|
+
usableInfo = getUsableContext(info.providerID, info.modelID);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const now = Date.now();
|
|
819
|
+
const prev = lastShown.get(sessionID);
|
|
820
|
+
// Native usable window when derivable; else the raw context window; else
|
|
821
|
+
// no percentage at all (legacy behaviour).
|
|
822
|
+
const denom =
|
|
823
|
+
usableInfo != null
|
|
824
|
+
? usableInfo.usable
|
|
825
|
+
: limit != null && limit > 0
|
|
826
|
+
? limit
|
|
827
|
+
: null;
|
|
828
|
+
const ratio = denom != null ? contextTokens / denom : 0;
|
|
829
|
+
const critical = ratio > CRITICAL_RATIO;
|
|
830
|
+
// Throttle: suppress if NOT enough time has passed OR growth is small.
|
|
831
|
+
// Critical (>90%) toasts skip the growth gate and use a shorter gap so
|
|
832
|
+
// threshold crossings are not silenced by MIN_GROWTH_TOKENS.
|
|
833
|
+
if (
|
|
834
|
+
prev &&
|
|
835
|
+
(now - prev.at < (critical ? CRITICAL_THROTTLE_MS : THROTTLE_MS) ||
|
|
836
|
+
(!critical && contextTokens - prev.tokens < MIN_GROWTH_TOKENS))
|
|
837
|
+
) {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
lastShown.set(sessionID, { at: now, tokens: contextTokens });
|
|
842
|
+
pruneMap(lastShown, MAX_TRACKED_SESSIONS);
|
|
843
|
+
|
|
844
|
+
let message;
|
|
845
|
+
let variant = "info";
|
|
846
|
+
if (denom != null) {
|
|
847
|
+
// No upper clamp: exceeding the window must stay visible (>100%).
|
|
848
|
+
const pct = ((contextTokens / denom) * 100).toFixed(1);
|
|
849
|
+
message = `ctx ${fmt(contextTokens)} / ${fmt(denom)} (${pct}%)`;
|
|
850
|
+
if (ratio > 0.75) variant = "warning";
|
|
851
|
+
} else {
|
|
852
|
+
message = `ctx ${fmt(contextTokens)}`;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// F2: extend the toast with reasoning / cache.read when nonzero
|
|
856
|
+
// (raw reported values, unchanged).
|
|
857
|
+
const extras = [];
|
|
858
|
+
const reasoning = typeof tokens.reasoning === "number" ? tokens.reasoning : 0;
|
|
859
|
+
if (reasoning > 0) extras.push(`r ${fmt(reasoning)}`);
|
|
860
|
+
if (cacheRead > 0) extras.push(`c ${fmt(cacheRead)}`);
|
|
861
|
+
|
|
862
|
+
// Cost: only when the model config carries explicit pricing. Each token
|
|
863
|
+
// class is priced with its OWN rate; reasoning is already counted inside
|
|
864
|
+
// `output` (opencode reports output inclusive of reasoning), so it is NOT
|
|
865
|
+
// added separately. Absent rates are simply skipped.
|
|
866
|
+
const price = mkey ? modelPrices.get(mkey) : null;
|
|
867
|
+
if (price) {
|
|
868
|
+
const inputTok = typeof tokens.input === "number" ? tokens.input : 0;
|
|
869
|
+
const outputTok = typeof tokens.output === "number" ? tokens.output : 0;
|
|
870
|
+
const cost =
|
|
871
|
+
(inputTok / 1000) * (price.input || 0) +
|
|
872
|
+
(outputTok / 1000) * (price.output || 0) +
|
|
873
|
+
(cacheRead / 1000) * (price.cacheRead || 0) +
|
|
874
|
+
(cacheWrite / 1000) * (price.cacheWrite || 0);
|
|
875
|
+
if (cost > 0) extras.push(`${cost.toFixed(4)}$`);
|
|
876
|
+
}
|
|
877
|
+
if (extras.length > 0) {
|
|
878
|
+
message = `${message} · ${extras.join(" · ")}`;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// F3: compact subagent info in the toast (fresh cached value only).
|
|
882
|
+
// Per-child ratios come from the cached aggregation — no extra calls.
|
|
883
|
+
try {
|
|
884
|
+
const sub = subagentCache.get(sessionID);
|
|
885
|
+
if (sub && sub.count > 0 && now - sub.at < SUBAGENT_THROTTLE_MS) {
|
|
886
|
+
const perChild = Array.isArray(sub.perChild) ? sub.perChild : [];
|
|
887
|
+
const ratios = perChild
|
|
888
|
+
.map((c) => c?.ratio)
|
|
889
|
+
.filter((r) => typeof r === "number");
|
|
890
|
+
if (ratios.length > 0) {
|
|
891
|
+
const worst = Math.max(...ratios);
|
|
892
|
+
const worstPct = `${(worst * 100).toFixed(0)}%`;
|
|
893
|
+
const hot = perChild.filter(
|
|
894
|
+
(c) => typeof c?.ratio === "number" && c.ratio > CRITICAL_RATIO,
|
|
895
|
+
).length;
|
|
896
|
+
message = hot > 0
|
|
897
|
+
? `${message} · sub ${sub.count}, ${hot}⚠ (${worstPct})`
|
|
898
|
+
: `${message} · sub ${sub.count}, worst ${worstPct}`;
|
|
899
|
+
} else {
|
|
900
|
+
// No known limits for the children -> legacy cumulative total.
|
|
901
|
+
const subTotal = sub.input + sub.output + sub.reasoning;
|
|
902
|
+
message = `${message} · sub ${fmt(subTotal)}`;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
} catch (err) {
|
|
906
|
+
logErr("toast subagent suffix failed", err);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Attribution: [main]/[sub] by Session.parentID (cached; faults ignored).
|
|
910
|
+
try {
|
|
911
|
+
const prefix = await sessionPrefix(client, sessionID);
|
|
912
|
+
if (prefix) message = `${prefix} ${message}`;
|
|
913
|
+
} catch (err) {
|
|
914
|
+
logErr("attribution prefix failed", err);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
try {
|
|
918
|
+
await showToastSafe(client, message, variant, 3500);
|
|
919
|
+
} catch (err) {
|
|
920
|
+
logErr("toast error", err);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// F1: live breakdown to the log (throttled separately, fault-tolerant).
|
|
924
|
+
// Writes whenever enough wall-clock time has passed since the last write;
|
|
925
|
+
// the breakdown cache itself may be fresh (from transform hooks) or stale
|
|
926
|
+
// (then buildLiveLines refreshes it via the fallback fetch).
|
|
927
|
+
try {
|
|
928
|
+
const lastWrite = lastBdWrite.get(sessionID) || 0;
|
|
929
|
+
if (now - lastWrite >= BREAKDOWN_THROTTLE_MS) {
|
|
930
|
+
lastBdWrite.set(sessionID, now);
|
|
931
|
+
pruneMap(lastBdWrite, MAX_TRACKED_SESSIONS);
|
|
932
|
+
const lines = await buildLiveLines(
|
|
933
|
+
client,
|
|
934
|
+
sessionID,
|
|
935
|
+
info.modelID,
|
|
936
|
+
info.providerID,
|
|
937
|
+
tokens.input,
|
|
938
|
+
contextTokens,
|
|
939
|
+
);
|
|
940
|
+
writeStateFile(
|
|
941
|
+
sessionID,
|
|
942
|
+
info.modelID,
|
|
943
|
+
info.providerID,
|
|
944
|
+
tokens.input,
|
|
945
|
+
contextTokens,
|
|
946
|
+
reasoning,
|
|
947
|
+
);
|
|
948
|
+
writeLog(lines);
|
|
949
|
+
}
|
|
950
|
+
} catch (err) {
|
|
951
|
+
logErr("live breakdown write failed", err);
|
|
952
|
+
}
|
|
953
|
+
},
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
export { makeV1 };
|