agent-accelerator 0.2.0 → 0.4.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.
@@ -0,0 +1,1285 @@
1
+ /**
2
+ * Conversation history + telemetry persistence for Agent Accelerator.
3
+ *
4
+ * Replaces the ~170 lines of manual JSONL / totals / cost code previously
5
+ * copied into `examples/05-chat.ts`. Devs now do:
6
+ *
7
+ * ```ts
8
+ * import { Agent, loadSessionFile, saveSessionFile, SessionTelemetry } from "agent-accelerator";
9
+ * const saved = loadSessionFile(".session.json");
10
+ * const telemetry = SessionTelemetry.fromSaved(saved);
11
+ * const agent = new Agent({ model: saved?.model ?? MODEL, sessionId: saved?.sessionId, ... });
12
+ * if (saved) agent.importSession(saved);
13
+ * const res = await agent.run(q);
14
+ * telemetry.add(res.usage, agent.modelStringOrSpec as string);
15
+ * saveSessionFile(".session.json", agent, telemetry);
16
+ * ```
17
+ *
18
+ * For binary media (images, audio, video, files as bytes / data URLs /
19
+ * base64), prefer session folders — `saveSessionDir("sessions", agent,
20
+ * telemetry)` writes `sessions/<sessionId>/{session.json, media/*}` with
21
+ * parts rewritten to relative `media/…` paths, and
22
+ * `loadSessionDir(dir)` resolves them back.
23
+ */
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+ import type { Message, ContentPart } from "../types/message.ts";
27
+ import type { TokenUsage, CacheConfig, ServiceTier } from "../types/core.ts";
28
+ import type { ModelSpec } from "../types/model.ts";
29
+ import type { AgentResponse } from "../types/response.ts";
30
+ import { base64ToBytes } from "../utils/base64.ts";
31
+ import { getModelFromCatalog } from "../models/catalog.ts";
32
+ import type { SubAgentStep } from "../types/response.ts";
33
+
34
+ /** Cumulative per-session token + cost totals (JSON-safe). */
35
+ export interface SessionTotals {
36
+ input: number;
37
+ output: number;
38
+ cacheRead: number;
39
+ cacheWrite: number;
40
+ reasoning: number;
41
+ cost: number;
42
+ }
43
+
44
+ /** Persisted per-worker trace inside the same session file (dedicated section). */
45
+ export interface PersistedSubAgentTrace {
46
+ trackingId: string;
47
+ name: string;
48
+ sessionId?: string;
49
+ parentSessionId?: string;
50
+ status: "running" | "done" | "error";
51
+ task: string;
52
+ role?: string;
53
+ model?: string;
54
+ provider?: string;
55
+ turns: number;
56
+ usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
57
+ steps: SubAgentStep[];
58
+ text?: string;
59
+ error?: string;
60
+ }
61
+
62
+ /** Single-file session snapshot (v1). Written as one JSON object. */
63
+ export interface PersistedAgentSession {
64
+ version: 1;
65
+ sessionId: string;
66
+ model: string;
67
+ subagentModel?: string;
68
+ thinkingLevel?: string;
69
+ cache?: CacheConfig;
70
+ instructions?: string;
71
+ systemPrompt?: string;
72
+ cachedContentId?: string;
73
+ messages: Message[];
74
+ totals?: SessionTotals;
75
+ /** Parent session this snapshot was spawned from, when applicable. */
76
+ parentSessionId?: string;
77
+ /** Dedicated sub-agent traces, keyed by worker TrackingID (32-char hex). */
78
+ subagents?: Record<string, PersistedSubAgentTrace>;
79
+ }
80
+
81
+ export function emptyTotals(): SessionTotals {
82
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0 };
83
+ }
84
+
85
+ function toNumber(n: unknown): number {
86
+ const v = typeof n === "number" ? n : Number(n);
87
+ return Number.isFinite(v) && v >= 0 ? v : 0;
88
+ }
89
+
90
+ /** Best-effort model resolution without importing the registry (avoids cycles). */
91
+ function specFor(modelStr: string): ReturnType<typeof getModelFromCatalog> {
92
+ try {
93
+ const raw = String(modelStr ?? "").trim();
94
+ if (!raw) return undefined;
95
+ if (raw.includes("/")) {
96
+ const [prefix, ...rest] = raw.split("/");
97
+ const id = rest.join("/");
98
+ return (
99
+ getModelFromCatalog(prefix!, id) ||
100
+ getModelFromCatalog(prefix!.toLowerCase(), id) ||
101
+ getModelFromCatalog(id, id)
102
+ );
103
+ }
104
+ return (
105
+ getModelFromCatalog("google", raw) ||
106
+ getModelFromCatalog("openai", raw) ||
107
+ getModelFromCatalog("openrouter", raw) ||
108
+ getModelFromCatalog(raw, raw)
109
+ );
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ /** Context window for telemetry display. Falls back to 1M like the chat CLI.
116
+ * @deprecated (Q-34) Kept for compat; new code should use
117
+ * `getSessionContextWindowOrUndefined` and handle `undefined` explicitly.
118
+ */
119
+ export function getSessionContextWindow(modelStr: string): number {
120
+ try {
121
+ const spec = specFor(modelStr);
122
+ if (spec?.limit?.context) return spec.limit.context;
123
+ if (spec?.contextWindow) return spec.contextWindow;
124
+ } catch {}
125
+ return 1_048_576;
126
+ }
127
+
128
+ /** Turn cost: prefers provider-reported total, else catalog pricing. */
129
+ export function computeSessionTurnCost(usage: TokenUsage, modelStr: string): number {
130
+ const reported = (usage as TokenUsage)?.cost?.totalCost ?? 0;
131
+ if (reported > 0) return reported;
132
+ try {
133
+ const spec = specFor(modelStr);
134
+ const priced = priceUsage(usage, spec);
135
+ if (priced && typeof priced.totalCost === "number" && priced.totalCost > 0) return priced.totalCost;
136
+ return 0;
137
+ } catch {
138
+ // why: cost is observability-only — pricing failures must never break turns.
139
+ return 0;
140
+ }
141
+ }
142
+
143
+ export function formatSessionTokens(n: number): string {
144
+ const v = toNumber(n);
145
+ if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
146
+ if (v >= 1e3) return `${(v / 1e3).toFixed(1)}k`;
147
+ return `${Math.floor(v)}`;
148
+ }
149
+
150
+ export function formatSessionCost(c?: number): string {
151
+ if (!c || c <= 0) return "$0.00";
152
+ if (c < 0.0001) return `$${c.toFixed(6)}`;
153
+ if (c < 0.01) return `$${c.toFixed(4)}`;
154
+ if (c < 1.0) return `$${c.toFixed(3)}`;
155
+ return `$${c.toFixed(2)}`;
156
+ }
157
+
158
+ /**
159
+ * Cumulative usage tracker for one chat session.
160
+ *
161
+ * @example `const t = SessionTelemetry.fromSaved(saved); t.add(res.usage, model);`
162
+ */
163
+ export class SessionTelemetry {
164
+ totals: SessionTotals;
165
+
166
+ constructor(totals?: SessionTotals | null) {
167
+ this.totals = {
168
+ input: toNumber(totals?.input),
169
+ output: toNumber(totals?.output),
170
+ cacheRead: toNumber(totals?.cacheRead),
171
+ cacheWrite: toNumber(totals?.cacheWrite),
172
+ reasoning: toNumber(totals?.reasoning),
173
+ cost: toNumber(totals?.cost),
174
+ };
175
+ }
176
+
177
+ /** Restores totals from a loaded session (tolerates legacy `metrics` shape). */
178
+ static fromSaved(saved?: PersistedAgentSession | { totals?: SessionTotals; metrics?: Record<string, number> } | null): SessionTelemetry {
179
+ const raw: any = (saved as any)?.totals ?? (saved as any)?.metrics ?? null;
180
+ if (!raw) return new SessionTelemetry();
181
+ // Legacy metrics files used {input,output,cacheRead,cacheWrite,reasoning,cost}
182
+ // with identical keys — accept as-is.
183
+ return new SessionTelemetry(raw as SessionTotals);
184
+ }
185
+
186
+ /** Adds one turn's usage; returns the turn cost. */
187
+ add(usage: TokenUsage, modelStr: string): number {
188
+ this.totals.input += usage.inputTokens ?? 0;
189
+ this.totals.output += usage.outputTokens ?? 0;
190
+ this.totals.cacheRead += usage.cachedTokens ?? usage.cacheReadTokens ?? 0;
191
+ this.totals.cacheWrite += usage.cacheWriteTokens ?? 0;
192
+ this.totals.reasoning += usage.thinkingTokens ?? 0;
193
+ const turnCost = computeSessionTurnCost(usage, modelStr);
194
+ this.totals.cost += turnCost;
195
+ return turnCost;
196
+ }
197
+
198
+ reset(): void {
199
+ this.totals = emptyTotals();
200
+ }
201
+
202
+ toJSON(): SessionTotals {
203
+ return { ...this.totals };
204
+ }
205
+
206
+ /** Per-turn cache-hit % (0-100) for one usage snapshot. */
207
+ turnHitRate(usage: TokenUsage): number {
208
+ const input = usage.inputTokens ?? 0;
209
+ if (input <= 0) return 0;
210
+ const cr = usage.cachedTokens ?? usage.cacheReadTokens ?? 0;
211
+ return Math.min(100, Math.max(0, (cr / input) * 100));
212
+ }
213
+
214
+ /** Session-total cache-hit % (0-100) across all added turns. */
215
+ totalHitRate(): number {
216
+ if (this.totals.input <= 0) return 0;
217
+ return Math.min(100, Math.max(0, (this.totals.cacheRead / this.totals.input) * 100));
218
+ }
219
+
220
+ /**
221
+ * One-line status bar with BOTH turn and session hit rates (03-multi_agent style).
222
+ * Shows per-turn tokens + `turn-CH%`, session totals + `total-CH%`, cost, context %.
223
+ * `thinkingLevel` defaults to the agent's level when omitted.
224
+ */
225
+ formatBar(
226
+ res: AgentResponse,
227
+ opts?: { model?: string; thinkingLevel?: string }
228
+ ): string {
229
+ const inTok = res.usage.inputTokens ?? 0;
230
+ const outTok = res.usage.outputTokens ?? 0;
231
+ const crTok = res.usage.cachedTokens ?? res.usage.cacheReadTokens ?? 0;
232
+ const cwTurnTok = res.usage.cacheWriteTokens ?? 0;
233
+ const turnRate = (this.turnHitRate(res.usage)).toFixed(1);
234
+ const totalRate = this.totalHitRate().toFixed(1);
235
+ const cwTurnLabel = cwTurnTok > 0 ? ` CW${formatSessionTokens(cwTurnTok)}` : "";
236
+ const totCW = this.totals.cacheWrite;
237
+ const totCWLabel = totCW > 0 ? ` CW${formatSessionTokens(totCW)}` : "";
238
+ const modelStr = opts?.model ?? `${res.provider}/${res.model}`;
239
+ const window = getSessionContextWindow(modelStr);
240
+ const usedTokens = this.totals.input + this.totals.output + this.totals.cacheRead;
241
+ const pct = window > 0 ? ((usedTokens / window) * 100).toFixed(1) : "0.0";
242
+ const turnCost = computeSessionTurnCost(res.usage, modelStr);
243
+ const costDelta = turnCost > 0 ? ` (+${formatSessionCost(turnCost)})` : "";
244
+ const level = opts?.thinkingLevel ?? "none";
245
+ return `\x1b[35m↑${formatSessionTokens(inTok)} ↓${formatSessionTokens(outTok)} CR${formatSessionTokens(crTok)}${cwTurnLabel} turn-CH${turnRate}% | ↑${formatSessionTokens(this.totals.input)} ↓${formatSessionTokens(this.totals.output)} CR${formatSessionTokens(this.totals.cacheRead)}${totCWLabel} total-CH${totalRate}% ${formatSessionCost(this.totals.cost)}${costDelta} ${pct}%/${formatSessionTokens(window)} • ${res.provider}/${res.model} • ${level} ${res.durationMs}ms ${res.finishReason ?? "STOP"}\x1b[0m`;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Startup banner for the chat CLI: distinguishes resumed vs fresh sessions
251
+ * and surfaces the session-total hit rate immediately on resume.
252
+ *
253
+ * @example `console.log(formatSessionBanner(saved, telemetry, SESSION_FILE))`
254
+ */
255
+ export function formatSessionBanner(
256
+ saved: PersistedAgentSession | null,
257
+ telemetry: SessionTelemetry,
258
+ filePath: string
259
+ ): string {
260
+ const shortFile = filePath.split("/").pop() ?? filePath;
261
+ if (saved) {
262
+ const msgCount = saved.messages.length;
263
+ const msgLabel = `${msgCount} message${msgCount === 1 ? "" : "s"}`;
264
+ const modelLabel = saved.model || "unknown model";
265
+ const levelLabel = saved.thinkingLevel ? ` • ${saved.thinkingLevel}` : "";
266
+ const totalRate = telemetry.totalHitRate().toFixed(1);
267
+ const costLabel = formatSessionCost(telemetry.totals.cost);
268
+ const shortId = (saved.sessionId ?? "").slice(0, 8);
269
+ return `\x1b[32m↺ Previous session loaded\x1b[0m \x1b[90m• ${shortId}… (${shortFile}) • ${msgLabel} • ${modelLabel}${levelLabel} • total-CH${totalRate}% • ${costLabel} total\x1b[0m`;
270
+ }
271
+ return `\x1b[90mNew session • ${shortFile} (no prior history found)\x1b[0m`;
272
+ }
273
+
274
+ /** Minimal agent surface needed for persistence (avoids an Agent import cycle). */
275
+ export interface SessionAgentLike {
276
+ sessionId: string;
277
+ modelStringOrSpec: unknown;
278
+ subagentModel?: unknown;
279
+ thinkingConfig?: { level?: string };
280
+ cacheConfig?: CacheConfig;
281
+ instructions?: string;
282
+ parentSessionId?: unknown;
283
+ subagentTraces?: Record<string, PersistedSubAgentTrace>;
284
+ context: { systemPrompt?: string; messages: Message[]; cachedContentId?: string };
285
+ }
286
+
287
+ function snapshotSubagents(traces: Record<string, PersistedSubAgentTrace> | undefined): Record<string, PersistedSubAgentTrace> | undefined {
288
+ if (!traces) return undefined;
289
+ const keys = Object.keys(traces);
290
+ if (keys.length === 0) return undefined;
291
+ // Key by raw TrackingID (32-hex): the live registry also holds display keys
292
+ // (`NAME-{id}`), but the file keeps one lean key per worker.
293
+ const byId = new Map<string, PersistedSubAgentTrace>();
294
+ for (const k of keys) {
295
+ const t = traces[k]!;
296
+ if (!t || typeof t !== "object") continue;
297
+ let id = (typeof t.trackingId === "string" && t.trackingId) || k;
298
+ // Normalize display ids (`NAME-{32hex}`) to the raw TrackingID so the
299
+ // live trace and its merged copy collapse to one entry.
300
+ const tail = id.split("-").pop() ?? "";
301
+ if (/^[0-9a-f]{32}$/.test(tail)) id = tail;
302
+ else if (/^[0-9a-f]{32}$/.test(k)) id = k;
303
+ if (!byId.has(id)) byId.set(id, t);
304
+ }
305
+ if (byId.size === 0) return undefined;
306
+ const lean: Record<string, PersistedSubAgentTrace> = {};
307
+ for (const [id, t] of byId) {
308
+ // Drop assistant step text that merely repeats the final answer: the
309
+ // step marker (turn/type/timestamp) stays, the full text lives on trace.text.
310
+ const steps = (t.steps ?? []).map((s) =>
311
+ s.type === "assistant" && typeof s.text === "string" && typeof t.text === "string" && s.text === t.text
312
+ ? { turn: s.turn, type: s.type, timestamp: s.timestamp }
313
+ : { ...s }
314
+ );
315
+ lean[id] = { ...t, steps };
316
+ }
317
+ try {
318
+ return JSON.parse(JSON.stringify(lean)) as Record<string, PersistedSubAgentTrace>;
319
+ } catch {
320
+ return lean;
321
+ }
322
+ }
323
+
324
+ /** Canonical JSON for deep comparison (sorted keys; safe values only). */
325
+ function stableStringify(value: unknown): string {
326
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
327
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
328
+ const entries = Object.keys(value as Record<string, unknown>)
329
+ .sort()
330
+ .map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`);
331
+ return `{${entries.join(",")}}`;
332
+ }
333
+
334
+ /**
335
+ * Drops `rawArguments` from `tool_call` parts when it re-parses to the
336
+ * stored `arguments` (pure duplicate, the common case). Divergent or
337
+ * unparseable wire strings are kept — they carry info parsing lost.
338
+ */
339
+ function stripRedundantRawArguments(messages: Message[]): Message[] {
340
+ return messages.map((m) => {
341
+ if (!Array.isArray((m as Message).content)) return m;
342
+ let changed = false;
343
+ const content = (m as Message).content as ContentPart[];
344
+ const next = content.map((p: any) => {
345
+ if (!p || p.type !== "tool_call" || typeof p.rawArguments !== "string") return p;
346
+ try {
347
+ const parsed: unknown = JSON.parse(p.rawArguments);
348
+ if (stableStringify(parsed) === stableStringify(p.arguments ?? {})) {
349
+ changed = true;
350
+ const { rawArguments: _dropped, ...rest } = p;
351
+ return rest;
352
+ }
353
+ } catch {
354
+ // unparseable wire — keep it
355
+ }
356
+ return p;
357
+ });
358
+ return changed ? { ...m, content: next } : m;
359
+ });
360
+ }
361
+
362
+ /** Builds a storable snapshot from a live agent + optional telemetry. */
363
+ export function buildSessionData(agent: SessionAgentLike, telemetry?: SessionTelemetry | SessionTotals | null): PersistedAgentSession {
364
+ const rawModel = agent.modelStringOrSpec as any;
365
+ const model = typeof rawModel === "string" ? rawModel : (rawModel?.id ?? String(rawModel ?? ""));
366
+ const rawSub = agent.subagentModel as any;
367
+ const subagentModel =
368
+ typeof rawSub === "string" ? rawSub : rawSub?.model ?? rawSub?.id ?? undefined;
369
+ const totals = telemetry instanceof SessionTelemetry
370
+ ? telemetry.toJSON()
371
+ : telemetry ?? undefined;
372
+ const parentSessionId =
373
+ typeof (agent as any)?.parentSessionId === "string" ? (agent as any).parentSessionId as string : undefined;
374
+ const subagents = snapshotSubagents((agent as any)?.subagentTraces as Record<string, PersistedSubAgentTrace> | undefined);
375
+ const messages = stripRedundantRawArguments(agent.context.messages);
376
+ return {
377
+ version: 1,
378
+ sessionId: agent.sessionId,
379
+ model,
380
+ ...(subagentModel ? { subagentModel: String(subagentModel) } : {}),
381
+ ...(agent.thinkingConfig?.level ? { thinkingLevel: agent.thinkingConfig.level as string } : {}),
382
+ ...(agent.cacheConfig ? { cache: agent.cacheConfig } : {}),
383
+ ...(agent.instructions ? { instructions: agent.instructions } : {}),
384
+ ...(agent.context.systemPrompt ? { systemPrompt: agent.context.systemPrompt } : {}),
385
+ ...(agent.context.cachedContentId ? { cachedContentId: agent.context.cachedContentId } : {}),
386
+ messages,
387
+ ...(totals ? { totals } : {}),
388
+ ...(parentSessionId ? { parentSessionId } : {}),
389
+ ...(subagents ? { subagents } : {}),
390
+ };
391
+ }
392
+
393
+ /** Serializes a snapshot to a pretty-printed `.json` document (2-space indent). */
394
+ export function serializeSession(data: PersistedAgentSession): string {
395
+ return JSON.stringify({ ...data, version: 1 }, null, 2);
396
+ }
397
+
398
+ /** Parses new pretty `.session.json` snapshots AND legacy `.session.jsonl` documents. */
399
+ export function deserializeSession(raw: string): PersistedAgentSession | null {
400
+ const text = (raw ?? "").trim();
401
+ if (!text) return null;
402
+ // Q-10: reject oversized payloads (>10MB JSON) — return null, no console.
403
+ if (text.length > MAX_SESSION_JSON_BYTES) return null;
404
+ try {
405
+ // Legacy JSONL: lines shaped {type: session|mainModel|subagentModel|metrics|message|...}
406
+ if (text.startsWith('{"type"')) {
407
+ let sessionId = "";
408
+ let model = "";
409
+ let subagentModel: string | undefined;
410
+ let thinkingLevel: string | undefined;
411
+ let cache: CacheConfig | undefined;
412
+ let cachedContentId: string | undefined;
413
+ let systemPrompt: string | undefined;
414
+ let instructions: string | undefined;
415
+ let parentSessionId: string | undefined;
416
+ let subagents: Record<string, PersistedSubAgentTrace> | undefined;
417
+ const messages: Message[] = [];
418
+ let totals: SessionTotals | undefined;
419
+ for (const line of text.split("\n")) {
420
+ const t = line.trim();
421
+ if (!t) continue;
422
+ try {
423
+ const obj: any = JSON.parse(t);
424
+ if (obj.type === "session") {
425
+ sessionId = obj.id ?? obj.sessionId ?? sessionId;
426
+ cache = obj.cache ?? cache;
427
+ cachedContentId = obj.cachedContentId ?? cachedContentId;
428
+ model = obj.model ?? model;
429
+ thinkingLevel = obj.thinkingLevel ?? thinkingLevel;
430
+ parentSessionId = obj.parentSessionId ?? parentSessionId;
431
+ subagents = obj.subagents ?? subagents;
432
+ } else if (obj.type === "mainModel") {
433
+ model = obj.id ?? obj.model ?? model;
434
+ thinkingLevel = obj.thinkingLevel ?? thinkingLevel;
435
+ } else if (obj.type === "subagentModel") {
436
+ subagentModel = obj.id ?? obj.model ?? subagentModel;
437
+ } else if (obj.type === "metrics" || obj.type === "totals") {
438
+ totals = (obj.metrics ?? obj.totals ?? totals) as SessionTotals | undefined;
439
+ cache = obj.cache ?? cache;
440
+ cachedContentId = obj.cachedContentId ?? cachedContentId;
441
+ } else if (obj.type === "modelChange") {
442
+ model = obj.modelId ?? obj.model ?? model;
443
+ } else if (obj.type === "thinkingLevelChange") {
444
+ thinkingLevel = obj.thinkingLevel;
445
+ } else if (obj.type === "message" && obj.message) {
446
+ messages.push(obj.message as Message);
447
+ } else if (obj.type === "context") {
448
+ if (obj.systemPrompt) systemPrompt = obj.systemPrompt;
449
+ if (Array.isArray(obj.messages)) messages.push(...(obj.messages as Message[]));
450
+ }
451
+ } catch {
452
+ // skip corrupt lines — one bad turn must not drop the session
453
+ }
454
+ }
455
+ if (!sessionId && messages.length === 0) return null;
456
+ // Q-10: oversize guard — return null, no console.
457
+ if (messages.length > MAX_SESSION_MESSAGES) return null;
458
+ return {
459
+ version: 1,
460
+ sessionId: sessionId || `accel-${Date.now()}`,
461
+ model,
462
+ ...(subagentModel ? { subagentModel } : {}),
463
+ ...(thinkingLevel ? { thinkingLevel } : {}),
464
+ ...(cache ? { cache } : {}),
465
+ ...(instructions ? { instructions } : {}),
466
+ ...(systemPrompt ? { systemPrompt } : {}),
467
+ ...(cachedContentId ? { cachedContentId } : {}),
468
+ messages,
469
+ ...(totals ? { totals } : {}),
470
+ ...(parentSessionId ? { parentSessionId } : {}),
471
+ ...(subagents ? { subagents } : {}),
472
+ };
473
+ }
474
+ const obj: any = JSON.parse(text);
475
+ if (!obj || typeof obj !== "object") return null;
476
+ // Q-10: version check — reject unknown versions, accept legacy shapes
477
+ // that predate versioning (no version field).
478
+ if ("version" in obj && (obj as { version?: unknown }).version !== 1) return null;
479
+ // Very old single-JSON shape without version: {sessionId, model, context:{messages}}
480
+ if (obj.context && Array.isArray(obj.context.messages) && !Array.isArray(obj.messages)) {
481
+ if ((obj.context.messages as unknown[]).length > MAX_SESSION_MESSAGES) return null;
482
+ return {
483
+ version: 1,
484
+ sessionId: obj.sessionId ?? obj.id ?? `accel-${Date.now()}`,
485
+ model: obj.model ?? "",
486
+ subagentModel: obj.subagentModel,
487
+ thinkingLevel: obj.thinkingLevel ?? obj.thinkingLevelChange,
488
+ cache: obj.cache,
489
+ instructions: obj.instructions,
490
+ systemPrompt: obj.context.systemPrompt,
491
+ cachedContentId: obj.cachedContentId ?? obj.context.cachedContentId,
492
+ messages: obj.context.messages as Message[],
493
+ totals: obj.totals ?? obj.metrics,
494
+ parentSessionId: obj.parentSessionId,
495
+ subagents: obj.subagents,
496
+ };
497
+ }
498
+ if (!obj.sessionId && !Array.isArray(obj.messages)) return null;
499
+ if (Array.isArray(obj.messages) && (obj.messages as unknown[]).length > MAX_SESSION_MESSAGES) return null;
500
+ if ("sessionId" in obj && obj.sessionId !== undefined && typeof obj.sessionId !== "string") return null;
501
+ if ("messages" in obj && obj.messages !== undefined && !Array.isArray(obj.messages)) return null;
502
+ return {
503
+ version: 1,
504
+ sessionId: obj.sessionId ?? `accel-${Date.now()}`,
505
+ model: obj.model ?? "",
506
+ subagentModel: obj.subagentModel,
507
+ thinkingLevel: obj.thinkingLevel,
508
+ cache: obj.cache,
509
+ instructions: obj.instructions,
510
+ systemPrompt: obj.systemPrompt,
511
+ cachedContentId: obj.cachedContentId,
512
+ messages: Array.isArray(obj.messages) ? (obj.messages as Message[]) : [],
513
+ totals: obj.totals,
514
+ parentSessionId: obj.parentSessionId,
515
+ subagents: obj.subagents,
516
+ };
517
+ } catch {
518
+ return null;
519
+ }
520
+ }
521
+
522
+ /** Loads a session file (pretty `.json` or legacy `.jsonl`). Returns null when missing/corrupt. */
523
+ export function loadSessionFile(filePath: string): PersistedAgentSession | null {
524
+ try {
525
+ const resolved = path.resolve(filePath);
526
+ if (!fs.existsSync(resolved)) return null;
527
+ return deserializeSession(fs.readFileSync(resolved, "utf8"));
528
+ } catch {
529
+ return null;
530
+ }
531
+ }
532
+
533
+ /** Atomic file write (temp + rename) so a crash never leaves half-written JSON. */
534
+ export function writeFileAtomic(filePath: string, content: string): void {
535
+ const dir = path.dirname(filePath);
536
+ try {
537
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
538
+ } catch {}
539
+ let tag = "tmp";
540
+ try {
541
+ const pid = (globalThis as any)?.process?.pid;
542
+ const rand = (globalThis as any)?.crypto?.randomUUID?.().slice(0, 8) ?? Math.random().toString(36).slice(2, 8);
543
+ tag = `${typeof pid === "number" ? pid : "np"}-${rand}`;
544
+ } catch {
545
+ tag = Math.random().toString(36).slice(2, 8);
546
+ }
547
+ const tmp = `${filePath}.tmp-${tag}`;
548
+ fs.writeFileSync(tmp, content, { encoding: "utf8", mode: 0o600 });
549
+ try { fs.chmodSync(tmp, 0o600); } catch {}
550
+ fs.renameSync(tmp, filePath);
551
+ }
552
+
553
+ /** Session id grammar: no traversal, no slashes, no hidden files. */
554
+ export function assertValidSessionId(sessionId: string): void {
555
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(sessionId)) {
556
+ throw new Error(`[Agent Accelerator] Invalid sessionId "${sessionId}": must match /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/`);
557
+ }
558
+ }
559
+
560
+ /** Saves an agent + telemetry snapshot as a pretty-printed `.json` document (2-space indent). */
561
+ export function saveSessionFile(
562
+ filePath: string,
563
+ agent: SessionAgentLike,
564
+ telemetry?: SessionTelemetry | SessionTotals | null
565
+ ): void {
566
+ const resolved = path.resolve(filePath);
567
+ // Q-28-partial: never overwrite a corrupt file in place — quarantine it
568
+ // as `*.corrupt-<ts>` first (load returns null for corrupt payloads).
569
+ try {
570
+ if (fs.existsSync(resolved)) {
571
+ try {
572
+ const raw = fs.readFileSync(resolved, "utf8");
573
+ if (raw.trim().length > 0 && deserializeSession(raw) === null) {
574
+ try { fs.renameSync(resolved, `${resolved}.corrupt-${Date.now()}`); } catch {}
575
+ }
576
+ } catch {}
577
+ }
578
+ } catch {}
579
+ const data = buildSessionData(agent, telemetry);
580
+ try {
581
+ writeFileAtomic(resolved, serializeSession(data) + "\n");
582
+ } catch {
583
+ // persistence must never crash a chat turn
584
+ }
585
+ }
586
+
587
+ // ---------------------------------------------------------------------------
588
+ // Session directories: sessions/<sessionId>/{session.json, media/*}
589
+ // ---------------------------------------------------------------------------
590
+
591
+ const MEDIA_PART_KEYS = ["image", "audio", "video", "file"] as const;
592
+
593
+ function isHttpRef(value: string): boolean {
594
+ return value.startsWith("http://") || value.startsWith("https://");
595
+ }
596
+
597
+ function parseDataUrl(value: string): { mimeType: string; base64: string } | undefined {
598
+ const m = value.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,(.+)$/s);
599
+ if (m && m[2]) return { mimeType: m[1] || "application/octet-stream", base64: m[2] };
600
+ return undefined;
601
+ }
602
+
603
+ /** Same embedded-bytes heuristic as the media normalizer (paths fail it on their own). */
604
+ function looksLikeEmbeddedBase64(value: string): boolean {
605
+ if (value.includes("\\") || value.length <= 100) return false;
606
+ if (value.length % 4 !== 0) return false;
607
+ return /^[A-Za-z0-9+/=\n\r]+$/.test(value.slice(0, 500));
608
+ }
609
+
610
+ function extForMedia(mimeType?: string, filename?: string): string {
611
+ const mime = (mimeType || "").toLowerCase().split(";")[0]!.trim();
612
+ const known: Record<string, string> = {
613
+ "image/png": "png",
614
+ "image/jpeg": "jpg",
615
+ "image/webp": "webp",
616
+ "image/gif": "gif",
617
+ "audio/mpeg": "mp3",
618
+ "audio/mp3": "mp3",
619
+ "audio/wav": "wav",
620
+ "audio/x-wav": "wav",
621
+ "audio/ogg": "ogg",
622
+ "video/mp4": "mp4",
623
+ "video/webm": "webm",
624
+ "video/quicktime": "mov",
625
+ "application/pdf": "pdf",
626
+ "text/csv": "csv",
627
+ "text/plain": "txt",
628
+ };
629
+ if (mime && known[mime]) return known[mime]!;
630
+ if (filename) {
631
+ const ext = filename.split("?")[0]!.split(".").pop()?.toLowerCase() ?? "";
632
+ if (/^[a-z0-9]{1,5}$/.test(ext)) return ext;
633
+ }
634
+ if (mime) {
635
+ const sub = mime.split("/").pop()?.toLowerCase() ?? "";
636
+ if (/^[a-z0-9]{1,5}$/.test(sub)) return sub;
637
+ }
638
+ return "bin";
639
+ }
640
+
641
+ function cloneMessages(messages: Message[]): Message[] {
642
+ try {
643
+ const g: any = globalThis as any;
644
+ if (typeof g.structuredClone === "function") return g.structuredClone(messages);
645
+ } catch {}
646
+ try {
647
+ return JSON.parse(JSON.stringify(messages));
648
+ } catch {
649
+ return [...messages];
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Extracts embedded media bytes (binary, `data:` URLs, raw base64) into
655
+ * `<sessionDir>/media/` and rewrites those parts to relative `media/…`
656
+ * paths. Remote URLs and filesystem paths are left as references.
657
+ */
658
+ export function extractMediaToDir(
659
+ messages: Message[],
660
+ sessionDir: string
661
+ ): { messages: Message[]; wrote: string[] } {
662
+ const cloned = cloneMessages(messages);
663
+ const root = path.resolve(sessionDir);
664
+ const mediaDir = path.resolve(root, "media");
665
+ if (!mediaDir.startsWith(root + path.sep) && mediaDir !== root) {
666
+ throw new Error("[Agent Accelerator] Media dir escapes session dir");
667
+ }
668
+ try {
669
+ fs.mkdirSync(mediaDir, { recursive: true, mode: 0o700 });
670
+ } catch {}
671
+ // Never rm -rf: remove only previously written media files matching our pattern.
672
+ try {
673
+ for (const f of fs.readdirSync(mediaDir)) {
674
+ if (/^(image|audio|video|file)-\d+\.[a-z0-9]{1,5}$/.test(f)) {
675
+ try { fs.unlinkSync(path.join(mediaDir, f)); } catch {}
676
+ }
677
+ }
678
+ } catch {}
679
+ const wrote: string[] = [];
680
+ const counters: Record<string, number> = {};
681
+ let hasBinary = false;
682
+
683
+ for (const msg of cloned) {
684
+ if (!Array.isArray((msg as Message).content)) continue;
685
+ for (const part of (msg as Message).content as ContentPart[]) {
686
+ const p: any = part as any;
687
+ if (!p || typeof p.type !== "string") continue;
688
+ if (!(MEDIA_PART_KEYS as readonly string[]).includes(p.type)) continue;
689
+ const key = p.type as (typeof MEDIA_PART_KEYS)[number];
690
+ const raw = p[key] as unknown;
691
+ let bytes: Uint8Array | undefined;
692
+ let mimeType: string | undefined = typeof p.mimeType === "string" ? p.mimeType : undefined;
693
+ if (raw instanceof Uint8Array) bytes = raw;
694
+ else if (raw instanceof ArrayBuffer) bytes = new Uint8Array(raw);
695
+ else if (typeof raw === "string") {
696
+ if (isHttpRef(raw)) continue; // remote reference — provider fetches per turn
697
+ const dataUrl = raw.startsWith("data:") ? parseDataUrl(raw) : undefined;
698
+ if (dataUrl) {
699
+ bytes = base64ToBytes(dataUrl.base64);
700
+ mimeType = mimeType ?? dataUrl.mimeType;
701
+ } else if (looksLikeEmbeddedBase64(raw)) {
702
+ bytes = base64ToBytes(raw.replace(/\s/g, ""));
703
+ } else continue; // local path or short label — keep as reference
704
+ } else continue;
705
+
706
+ hasBinary = true;
707
+ const n = (counters[key] = (counters[key] ?? 0) + 1);
708
+ const ext = extForMedia(mimeType, typeof p.filename === "string" ? p.filename : undefined);
709
+ const file = `${key}-${String(n).padStart(3, "0")}.${ext}`;
710
+ try {
711
+ fs.mkdirSync(mediaDir, { recursive: true, mode: 0o700 });
712
+ const dest = path.resolve(mediaDir, file);
713
+ if (!dest.startsWith(mediaDir + path.sep)) continue;
714
+ fs.writeFileSync(dest, bytes, { mode: 0o600 });
715
+ try { fs.chmodSync(dest, 0o600); } catch {}
716
+ } catch {
717
+ continue;
718
+ }
719
+ p[key] = `media/${file}`;
720
+ if (mimeType) p.mimeType = mimeType;
721
+ wrote.push(`media/${file}`);
722
+ }
723
+ }
724
+
725
+ return { messages: cloned, wrote };
726
+ }
727
+
728
+ /**
729
+ * Resolves `media/…` relative refs to absolute paths anchored at the session
730
+ * folder, so a resumed session runs from any cwd. Absolute paths, URLs, and
731
+ * non-media strings pass through untouched. Rejects traversal outside dir.
732
+ */
733
+ export function resolveMediaPaths(messages: Message[], sessionDir: string): Message[] {
734
+ const cloned = cloneMessages(messages);
735
+ const root = path.resolve(sessionDir);
736
+ for (const msg of cloned) {
737
+ if (!Array.isArray((msg as Message).content)) continue;
738
+ for (const part of (msg as Message).content as ContentPart[]) {
739
+ const p: any = part as any;
740
+ if (!p || typeof p.type !== "string") continue;
741
+ if (!(MEDIA_PART_KEYS as readonly string[]).includes(p.type)) continue;
742
+ const v = p[p.type] as unknown;
743
+ if (typeof v === "string" && (v === "media" || v.startsWith("media/"))) {
744
+ if (v.includes("..") || path.isAbsolute(v)) continue;
745
+ const resolved = path.resolve(root, v);
746
+ if (!resolved.startsWith(root + path.sep) && resolved !== root) continue;
747
+ p[p.type] = resolved;
748
+ }
749
+ }
750
+ }
751
+ return cloned;
752
+ }
753
+
754
+ /** `<rootDir>/<sessionId>` — one folder per session. Validates traversal. */
755
+ export function sessionDirFor(rootDir: string, sessionId: string): string {
756
+ assertValidSessionId(sessionId);
757
+ const root = path.resolve(rootDir);
758
+ const dir = path.resolve(root, sessionId);
759
+ const rel = path.relative(root, dir);
760
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
761
+ throw new Error(`[Agent Accelerator] Session dir escapes root: ${sessionId}`);
762
+ }
763
+ return dir;
764
+ }
765
+
766
+ /**
767
+ * Saves `sessions/<sessionId>/session.json` (pretty, 2-space) plus
768
+ * `media/*` for embedded bytes. Returns the session folder. Re-saving the
769
+ * same session id rewrites the folder (no orphan accumulation).
770
+ */
771
+ export function saveSessionDir(
772
+ rootDir: string,
773
+ agent: SessionAgentLike,
774
+ telemetry?: SessionTelemetry | SessionTotals | null
775
+ ): string {
776
+ // Q-28-partial: sessionDirFor already enforces assertValidSessionId.
777
+ const sessionDir = sessionDirFor(rootDir, agent.sessionId);
778
+ try {
779
+ fs.mkdirSync(sessionDir, { recursive: true });
780
+ } catch {}
781
+ const { messages } = extractMediaToDir(agent.context.messages, sessionDir);
782
+ const data = buildSessionData(
783
+ { ...agent, context: { ...agent.context, messages } },
784
+ telemetry
785
+ );
786
+ try {
787
+ const target = path.join(sessionDir, "session.json");
788
+ // Q-28-partial: quarantine corrupt targets instead of overwriting.
789
+ try {
790
+ if (fs.existsSync(target)) {
791
+ try {
792
+ const raw = fs.readFileSync(target, "utf8");
793
+ if (raw.trim().length > 0 && deserializeSession(raw) === null) {
794
+ try { fs.renameSync(target, `${target}.corrupt-${Date.now()}`); } catch {}
795
+ }
796
+ } catch {}
797
+ }
798
+ } catch {}
799
+ writeFileAtomic(target, serializeSession(data) + "\n");
800
+ } catch {
801
+ // persistence must never crash a chat turn
802
+ }
803
+ return sessionDir;
804
+ }
805
+
806
+ /** A loaded session folder: snapshot (media paths resolved) + folder location. */
807
+ export interface LoadedSessionDir {
808
+ session: PersistedAgentSession;
809
+ dir: string;
810
+ }
811
+
812
+ /**
813
+ * Loads a session folder (`<dir>/` or `<dir>/session.json`, plus legacy
814
+ * single files). Relative `media/…` refs resolve against the folder.
815
+ * Returns null when nothing loadable exists.
816
+ */
817
+ export function loadSessionDir(sessionPath: string): LoadedSessionDir | null {
818
+ try {
819
+ const resolved = path.resolve(sessionPath);
820
+ let dir = resolved;
821
+ try {
822
+ const st = fs.statSync(resolved);
823
+ if (st.isFile()) {
824
+ const single = loadSessionFile(resolved);
825
+ if (!single) return null;
826
+ dir = path.dirname(resolved);
827
+ if (single.messages) {
828
+ single.messages = resolveMediaPaths(single.messages, dir);
829
+ }
830
+ return { session: single, dir };
831
+ }
832
+ } catch {
833
+ // path does not exist yet — fall through to session.json lookup
834
+ }
835
+ const file = path.join(dir, "session.json");
836
+ if (!fs.existsSync(file)) return null;
837
+ const data = deserializeSession(fs.readFileSync(file, "utf8"));
838
+ if (!data) return null;
839
+ data.messages = resolveMediaPaths(data.messages, dir);
840
+ return { session: data, dir };
841
+ } catch {
842
+ return null;
843
+ }
844
+ }
845
+
846
+ /** Most-recently-modified `sessions/<id>` folder, for resume-latest CLIs. */
847
+ export function findLatestSessionDir(rootDir: string): string | null {
848
+ try {
849
+ const root = path.resolve(rootDir);
850
+ if (!fs.existsSync(root)) return null;
851
+ let best: { dir: string; mtime: number } | null = null;
852
+ for (const entry of fs.readdirSync(root)) {
853
+ const file = path.join(root, entry, "session.json");
854
+ try {
855
+ const st = fs.statSync(file);
856
+ if (!st.isFile()) continue;
857
+ if (!best || st.mtimeMs > best.mtime) best = { dir: path.join(root, entry), mtime: st.mtimeMs };
858
+ } catch {
859
+ continue;
860
+ }
861
+ }
862
+ return best?.dir ?? null;
863
+ } catch {
864
+ return null;
865
+ }
866
+ }
867
+
868
+ // ---------------------------------------------------------------------------
869
+ // Q-10: session validation (append-only; deserializeSession above only gained
870
+ // size/version guards, no behavior change otherwise).
871
+ // ---------------------------------------------------------------------------
872
+
873
+ /** Max messages accepted in a session snapshot (Q-10). */
874
+ export const MAX_SESSION_MESSAGES = 5000;
875
+ /** Max raw JSON bytes accepted in deserializeSession (Q-10). */
876
+ export const MAX_SESSION_JSON_BYTES = 10 * 1024 * 1024;
877
+
878
+ function sessionLoadError(message: string): Error {
879
+ const e = new Error(`[Agent Accelerator] Invalid session: ${message}`);
880
+ e.name = "SessionLoadError";
881
+ return e;
882
+ }
883
+
884
+ /**
885
+ * Manual Zod-like validation for session snapshots (Q-10, no new deps).
886
+ * Checks `version===1`, string `sessionId`, array `messages`, and oversize
887
+ * (>5000 messages or >10MB JSON). Throws a `SessionLoadError`-named Error
888
+ * on failure; returns the typed session on success.
889
+ */
890
+ export function validateSessionData(data: unknown): PersistedAgentSession {
891
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
892
+ throw sessionLoadError("expected an object");
893
+ }
894
+ const obj = data as Record<string, unknown>;
895
+ if (obj.version !== 1) {
896
+ throw sessionLoadError(`unsupported version ${String((obj as { version?: unknown }).version)} (expected 1)`);
897
+ }
898
+ if (typeof obj.sessionId !== "string" || obj.sessionId.length === 0) {
899
+ throw sessionLoadError("sessionId must be a non-empty string");
900
+ }
901
+ if (!Array.isArray(obj.messages)) {
902
+ throw sessionLoadError("messages must be an array");
903
+ }
904
+ if ((obj.messages as unknown[]).length > MAX_SESSION_MESSAGES) {
905
+ throw sessionLoadError(`too many messages (${(obj.messages as unknown[]).length} > ${MAX_SESSION_MESSAGES})`);
906
+ }
907
+ let approxBytes = 0;
908
+ try {
909
+ approxBytes = JSON.stringify(data)?.length ?? 0;
910
+ } catch {
911
+ throw sessionLoadError("session is not JSON-serializable");
912
+ }
913
+ if (approxBytes > MAX_SESSION_JSON_BYTES) {
914
+ throw sessionLoadError(`session JSON too large (${approxBytes} > ${MAX_SESSION_JSON_BYTES} bytes)`);
915
+ }
916
+ return data as PersistedAgentSession;
917
+ }
918
+
919
+ // ---------------------------------------------------------------------------
920
+ // Q-34: context-window lookup without the legacy 1M fallback.
921
+ // ---------------------------------------------------------------------------
922
+
923
+ /**
924
+ * Returns the catalog context window for a model, or `undefined` when unknown
925
+ * (Q-34). Prefer this over `getSessionContextWindow` for new code.
926
+ */
927
+ export function getSessionContextWindowOrUndefined(modelStr: string): number | undefined {
928
+ try {
929
+ const spec = specFor(modelStr);
930
+ if (spec?.limit?.context) return spec.limit.context;
931
+ if (spec?.contextWindow) return spec.contextWindow;
932
+ } catch {
933
+ // why: catalog lookup is best-effort — unknown models yield undefined.
934
+ }
935
+ return undefined;
936
+ }
937
+
938
+ // ---------------------------------------------------------------------------
939
+ // Q-41: telemetry bus (no console.* in library code).
940
+ // ---------------------------------------------------------------------------
941
+
942
+ /** Telemetry event emitted via the in-process bus (Q-41). */
943
+ export interface TelemetryEvent {
944
+ code: string;
945
+ message: string;
946
+ level?: "info" | "warn" | "error";
947
+ data?: unknown;
948
+ timestamp: number;
949
+ }
950
+
951
+ /** Listener subscribed via `telemetryBus.onEvent`. */
952
+ export type TelemetryListener = (event: TelemetryEvent) => void;
953
+
954
+ interface TelemetryBus {
955
+ /** Subscribes to telemetry events. Returns an unsubscribe function. Default: no-op delivery (no listeners, no console). */
956
+ onEvent(listener: TelemetryListener): () => void;
957
+ /** Emits a warning (level `warn`). Never throws, never logs to console. */
958
+ emitWarning(code: string, message: string, data?: unknown): void;
959
+ /** Emits a generic event. Never throws, never logs to console. */
960
+ emit(event: Omit<TelemetryEvent, "timestamp"> & { timestamp?: number }): void;
961
+ /** Removes all listeners (useful in tests). */
962
+ clear(): void;
963
+ }
964
+
965
+ /**
966
+ * In-process telemetry bus (Q-41). Library code routes warnings/anomalies
967
+ * here instead of `console.*`. Default has zero listeners so emission is a
968
+ * no-op; CLIs/tests attach a pretty logger via `onEvent`.
969
+ */
970
+ export const telemetryBus: TelemetryBus = (() => {
971
+ const listeners = new Set<TelemetryListener>();
972
+ const deliver = (event: TelemetryEvent): void => {
973
+ for (const l of [...listeners]) {
974
+ try { l(event); } catch {
975
+ // why: a faulty telemetry listener must not break the emitting run.
976
+ }
977
+ }
978
+ };
979
+ return {
980
+ onEvent(listener: TelemetryListener): () => void {
981
+ listeners.add(listener);
982
+ return () => { listeners.delete(listener); };
983
+ },
984
+ emitWarning(code: string, message: string, data?: unknown): void {
985
+ try {
986
+ deliver({ code, message, level: "warn", data, timestamp: Date.now() });
987
+ } catch {
988
+ // why: telemetry emission is observability-only.
989
+ }
990
+ },
991
+ emit(event: Omit<TelemetryEvent, "timestamp"> & { timestamp?: number }): void {
992
+ try {
993
+ deliver({ level: "info", ...event, timestamp: event.timestamp ?? Date.now() });
994
+ } catch {
995
+ // why: telemetry emission is observability-only.
996
+ }
997
+ },
998
+ clear(): void {
999
+ listeners.clear();
1000
+ },
1001
+ };
1002
+ })();
1003
+
1004
+ /** Reports a usage/cost anomaly via the global hook + telemetry bus (Q-35). Never logs. */
1005
+ export function reportUsageAnomaly(detail: { type: string; [k: string]: unknown }): void {
1006
+ try {
1007
+ (globalThis as unknown as { __agentAccelAnomaly?: (d: unknown) => void }).__agentAccelAnomaly?.(detail);
1008
+ } catch {
1009
+ // why: anomaly hooks are third-party — must never throw into the loop.
1010
+ }
1011
+ try {
1012
+ telemetryBus.emitWarning(detail.type, `usage anomaly: ${detail.type}`, detail);
1013
+ } catch {
1014
+ // why: telemetry emission is observability-only.
1015
+ }
1016
+ }
1017
+
1018
+ // ---------------------------------------------------------------------------
1019
+ // Q-35: canonical usage invariants.
1020
+ // ---------------------------------------------------------------------------
1021
+ /**
1022
+ * Canonical usage invariants (provider-agnostic, Q-35):
1023
+ * - all token counts are finite integers >= 0;
1024
+ * - `cachedTokens` / `cacheReadTokens` are a SUBSET of `inputTokens`
1025
+ * (cache hits cannot exceed input; aggregates clamp — see `accumulateUsage`
1026
+ * in `agent/loop.ts`), so hit rates never exceed 100%;
1027
+ * - `thinkingTokens` is a SUBSET of `outputTokens` when both are reported
1028
+ * (reasoning is generated output); providers that omit one side are
1029
+ * accepted, only impossible `thinking > output` with both present fails.
1030
+ *
1031
+ * Test helper: throws an Error describing the first violation, returns void
1032
+ * when invariants hold. Not called in hot paths (validation only).
1033
+ */
1034
+ export function assertUsageInvariants(usage: TokenUsage): void {
1035
+ const fail = (msg: string): never => {
1036
+ throw new Error(`[Agent Accelerator] Invalid usage: ${msg}`);
1037
+ };
1038
+ const ints: Array<[string, unknown]> = [
1039
+ ["inputTokens", usage.inputTokens],
1040
+ ["outputTokens", usage.outputTokens],
1041
+ ["totalTokens", usage.totalTokens],
1042
+ ];
1043
+ for (const [k, v] of ints) {
1044
+ if (typeof v !== "number" || !Number.isFinite(v as number) || (v as number) < 0) {
1045
+ fail(`${k} must be a finite number >= 0 (got ${String(v)})`);
1046
+ }
1047
+ }
1048
+ const optional: Array<[string, unknown]> = [
1049
+ ["cachedTokens", usage.cachedTokens],
1050
+ ["cacheReadTokens", usage.cacheReadTokens],
1051
+ ["cacheWriteTokens", usage.cacheWriteTokens],
1052
+ ["thinkingTokens", usage.thinkingTokens],
1053
+ ];
1054
+ for (const [k, v] of optional) {
1055
+ if (v === undefined) continue;
1056
+ if (typeof v !== "number" || !Number.isFinite(v as number) || (v as number) < 0) {
1057
+ fail(`${k} must be a finite number >= 0 when present (got ${String(v)})`);
1058
+ }
1059
+ }
1060
+ const input = usage.inputTokens ?? 0;
1061
+ for (const k of ["cachedTokens", "cacheReadTokens"] as const) {
1062
+ const v = usage[k] ?? 0;
1063
+ if (v > input) fail(`${k} (${v}) exceeds inputTokens (${input})`);
1064
+ }
1065
+ if (usage.thinkingTokens !== undefined && usage.outputTokens !== undefined) {
1066
+ if (usage.thinkingTokens > usage.outputTokens) {
1067
+ fail(`thinkingTokens (${usage.thinkingTokens}) exceeds outputTokens (${usage.outputTokens})`);
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ // ---------------------------------------------------------------------------
1073
+ // Q-36: one cost model — integer micro-USD accumulation.
1074
+ // ---------------------------------------------------------------------------
1075
+
1076
+ /** Where a priced cost came from (Q-36). Unknown stays unknown, never 0. */
1077
+ export type CostSource = "provider" | "catalog" | "unknown";
1078
+
1079
+ /** Integer micro-USD breakdown behind a priced cost (Q-36). */
1080
+ export interface CostBreakdown {
1081
+ inputMicroUSD: number;
1082
+ outputMicroUSD: number;
1083
+ cacheReadMicroUSD: number;
1084
+ cacheWriteMicroUSD: number;
1085
+ audioMicroUSD: number;
1086
+ totalMicroUSD: number;
1087
+ /** Which price tier applied (`base` or e.g. `context_over_200k` / `tiers:<size>`). */
1088
+ tier: string;
1089
+ /** Multiplier applied for the service tier (estimate — see below). */
1090
+ serviceTierMultiplier: number;
1091
+ serviceTier?: string;
1092
+ }
1093
+
1094
+ /** Cost result of `priceUsage`: compat cost fields + source + breakdown. */
1095
+ export type PricedCost = NonNullable<TokenUsage["cost"]> & {
1096
+ source: CostSource;
1097
+ breakdown: CostBreakdown;
1098
+ };
1099
+
1100
+ /**
1101
+ * Service-tier price multipliers (Q-36). ESTIMATES, not vendor guarantees:
1102
+ * `flex` batches trade latency for ~0.5x price, `priority` reserves capacity
1103
+ * at ~1.5x. Standard/default is 1.0. Documented as estimates because vendors
1104
+ * do not publish exact tier multipliers in the catalog.
1105
+ */
1106
+ const SERVICE_TIER_MULTIPLIERS: Record<string, number> = {
1107
+ flex: 0.5,
1108
+ priority: 1.5,
1109
+ };
1110
+
1111
+ function serviceTierMultiplier(serviceTier?: string): { multiplier: number; tier?: string } {
1112
+ if (!serviceTier) return { multiplier: 1 };
1113
+ const m = SERVICE_TIER_MULTIPLIERS[serviceTier.toLowerCase()];
1114
+ return m !== undefined ? { multiplier: m, tier: serviceTier } : { multiplier: 1, tier: serviceTier };
1115
+ }
1116
+
1117
+ function toDollars(microUSD: number): number {
1118
+ return microUSD / 1_000_000;
1119
+ }
1120
+
1121
+ /**
1122
+ * Resolves effective per-1M pricing for a usage snapshot (Q-36). When
1123
+ * `usage.inputTokens > 200_000` and the catalog carries `cost.tiers` or
1124
+ * `cost.context_over_200k`, the over-200k tier prices apply; otherwise base
1125
+ * `pricing.*` (falling back to legacy `cost.*`) applies.
1126
+ */
1127
+ function resolveEffectivePricing(
1128
+ spec: ModelSpec | undefined,
1129
+ inputTokens: number
1130
+ ): { input: number; output: number; cacheRead: number; cacheWrite: number; audio: number; tier: string } {
1131
+ const base = {
1132
+ input: spec?.pricing?.inputPerMillion ?? (spec?.cost?.input as number | undefined) ?? 0,
1133
+ output: spec?.pricing?.outputPerMillion ?? (spec?.cost?.output as number | undefined) ?? 0,
1134
+ cacheRead: spec?.pricing?.cacheReadPerMillion ?? (spec?.cost?.cache_read as number | undefined) ?? 0,
1135
+ cacheWrite: spec?.pricing?.cacheWritePerMillion ?? (spec?.cost?.cache_write as number | undefined) ?? 0,
1136
+ audio:
1137
+ spec?.pricing?.inputAudioPerMillion ?? (spec?.cost as { input_audio?: number } | undefined)?.input_audio ?? 0,
1138
+ };
1139
+ if (!spec || inputTokens <= 200_000) return { ...base, tier: "base" };
1140
+ const over = spec.cost?.context_over_200k as { input?: number; output?: number; cache_read?: number } | undefined;
1141
+ if (over && (over.input || over.output || over.cache_read)) {
1142
+ return {
1143
+ input: over.input ?? base.input,
1144
+ output: over.output ?? base.output,
1145
+ cacheRead: over.cache_read ?? base.cacheRead,
1146
+ cacheWrite: base.cacheWrite,
1147
+ audio: base.audio,
1148
+ tier: "context_over_200k",
1149
+ };
1150
+ }
1151
+ const tiers = spec.cost?.tiers;
1152
+ if (Array.isArray(tiers) && tiers.length > 0) {
1153
+ let best: { input: number; output: number; cache_read?: number; size: number } | undefined;
1154
+ for (const t of tiers as Array<{ tier?: { type?: string; size?: number }; input?: number; output?: number; cache_read?: number }>) {
1155
+ const size = typeof t?.tier?.size === "number" ? t.tier.size : undefined;
1156
+ if (size === undefined || size > inputTokens) continue;
1157
+ const type = String(t?.tier?.type ?? "").toLowerCase();
1158
+ if (type && type !== "context" && !type.includes("context")) continue;
1159
+ if (!best || size > best.size) {
1160
+ best = { input: t.input ?? base.input, output: t.output ?? base.output, cache_read: t.cache_read, size };
1161
+ }
1162
+ }
1163
+ if (best) {
1164
+ return {
1165
+ input: best.input,
1166
+ output: best.output,
1167
+ cacheRead: best.cache_read ?? base.cacheRead,
1168
+ cacheWrite: base.cacheWrite,
1169
+ audio: base.audio,
1170
+ tier: `tiers:${best.size}`,
1171
+ };
1172
+ }
1173
+ }
1174
+ return { ...base, tier: "base" };
1175
+ }
1176
+
1177
+ /**
1178
+ * Prices one usage snapshot with integer micro-USD accumulation (Q-36).
1179
+ *
1180
+ * - Provider-reported `usage.cost.totalCost > 0` wins (source `provider`).
1181
+ * - Else catalog pricing via `resolveEffectivePricing` (source `catalog`).
1182
+ * - Else returns `undefined` (source `unknown` is implicit) — unknown stays
1183
+ * unknown, never a fabricated 0 object.
1184
+ * - Audio tokens (`usage.audioTokens` / `usage.inputAudioTokens` when present)
1185
+ * price at `pricing.inputAudioPerMillion` / `cost.input_audio` and fold into
1186
+ * the total (separately itemized in `breakdown.audioMicroUSD`).
1187
+ * - `opts.serviceTier` (`flex` ~0.5x, `priority` ~1.5x, estimates) scales the
1188
+ * catalog total; provider-reported costs are never rescaled.
1189
+ */
1190
+ export function priceUsage(
1191
+ usage: TokenUsage,
1192
+ spec?: ModelSpec,
1193
+ opts?: { serviceTier?: ServiceTier | string }
1194
+ ): PricedCost | undefined {
1195
+ const reported = usage?.cost;
1196
+ if (reported && typeof reported.totalCost === "number" && reported.totalCost > 0) {
1197
+ const micro = (d?: number): number => (typeof d === "number" && d > 0 ? Math.round(d * 1_000_000) : 0);
1198
+ const inputMicroUSD = micro(reported.inputCost);
1199
+ const outputMicroUSD = micro(reported.outputCost);
1200
+ const cacheReadMicroUSD = micro(reported.cacheReadCost);
1201
+ const cacheWriteMicroUSD = micro(reported.cacheWriteCost);
1202
+ const totalMicroUSD = Math.round(reported.totalCost * 1_000_000);
1203
+ return {
1204
+ inputCost: reported.inputCost,
1205
+ outputCost: reported.outputCost,
1206
+ cacheReadCost: reported.cacheReadCost,
1207
+ cacheWriteCost: reported.cacheWriteCost,
1208
+ totalCost: reported.totalCost,
1209
+ source: "provider",
1210
+ breakdown: {
1211
+ inputMicroUSD,
1212
+ outputMicroUSD,
1213
+ cacheReadMicroUSD,
1214
+ cacheWriteMicroUSD,
1215
+ audioMicroUSD: 0,
1216
+ totalMicroUSD,
1217
+ tier: "provider-reported",
1218
+ serviceTierMultiplier: 1,
1219
+ },
1220
+ };
1221
+ }
1222
+ if (!spec) return undefined;
1223
+ const inputTokens = usage.inputTokens ?? 0;
1224
+ const outputTokens = usage.outputTokens ?? 0;
1225
+ const cachedTokens = usage.cachedTokens ?? usage.cacheReadTokens ?? 0;
1226
+ const cacheWriteTokens = usage.cacheWriteTokens ?? 0;
1227
+ const audioTokens =
1228
+ (usage as { audioTokens?: unknown }).audioTokens ??
1229
+ (usage as { inputAudioTokens?: unknown }).inputAudioTokens ??
1230
+ 0;
1231
+ const audioCount = typeof audioTokens === "number" && Number.isFinite(audioTokens) && audioTokens > 0 ? Math.round(audioTokens) : 0;
1232
+ const pricing = resolveEffectivePricing(spec, inputTokens);
1233
+ if (!pricing.input && !pricing.output && !pricing.cacheRead && !pricing.cacheWrite && !pricing.audio) {
1234
+ return undefined;
1235
+ }
1236
+ const { multiplier, tier: serviceTier } = serviceTierMultiplier(opts?.serviceTier);
1237
+ const nonCachedInput = Math.max(0, inputTokens - cachedTokens);
1238
+ // Integer micro-USD accumulation: microUSD = tokens * pricePerMillion
1239
+ // ($P per 1M tokens == $P/1e6 per token == P micro-USD per token).
1240
+ const inputMicroUSD = Math.round(nonCachedInput * pricing.input * multiplier);
1241
+ const cacheReadMicroUSD = Math.round(cachedTokens * pricing.cacheRead * multiplier);
1242
+ const cacheWriteMicroUSD = Math.round(cacheWriteTokens * pricing.cacheWrite * multiplier);
1243
+ const outputMicroUSD = Math.round(outputTokens * pricing.output * multiplier);
1244
+ const audioMicroUSD = Math.round(audioCount * pricing.audio * multiplier);
1245
+ const totalMicroUSD = inputMicroUSD + cacheReadMicroUSD + cacheWriteMicroUSD + outputMicroUSD + audioMicroUSD;
1246
+ return {
1247
+ inputCost: toDollars(inputMicroUSD),
1248
+ outputCost: toDollars(outputMicroUSD + audioMicroUSD),
1249
+ cacheReadCost: toDollars(cacheReadMicroUSD),
1250
+ cacheWriteCost: toDollars(cacheWriteMicroUSD),
1251
+ totalCost: toDollars(totalMicroUSD),
1252
+ source: "catalog",
1253
+ breakdown: {
1254
+ inputMicroUSD,
1255
+ outputMicroUSD,
1256
+ cacheReadMicroUSD,
1257
+ cacheWriteMicroUSD,
1258
+ audioMicroUSD,
1259
+ totalMicroUSD,
1260
+ tier: pricing.tier,
1261
+ serviceTierMultiplier: multiplier,
1262
+ ...(serviceTier ? { serviceTier } : {}),
1263
+ },
1264
+ };
1265
+ }
1266
+
1267
+ /**
1268
+ * Computes and attaches catalog pricing to a usage snapshot (Q-36).
1269
+ * Keeps provider-reported costs untouched; returns the cost (or undefined
1270
+ * when unknown). Never fabricates a 0 object for unknown pricing.
1271
+ */
1272
+ export function addCostToUsage(
1273
+ usage: TokenUsage,
1274
+ spec?: ModelSpec,
1275
+ opts?: { serviceTier?: ServiceTier | string }
1276
+ ): TokenUsage["cost"] | undefined {
1277
+ if (usage?.cost && typeof usage.cost.totalCost === "number" && usage.cost.totalCost > 0) {
1278
+ return usage.cost;
1279
+ }
1280
+ const priced = priceUsage(usage, spec, opts);
1281
+ if (!priced) return usage.cost;
1282
+ const { source: _source, breakdown: _breakdown, ...cost } = priced;
1283
+ usage.cost = cost;
1284
+ return usage.cost;
1285
+ }