@tekmidian/pai 0.47.0 → 0.49.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.
@@ -7,7 +7,7 @@ import "../helpers-crDEr6S2.mjs";
7
7
  import "../sync-Btcb6xA4.mjs";
8
8
  import "../embeddings-DvRKcQyt.mjs";
9
9
  import "../search-B69cQwDB.mjs";
10
- import { n as drainStdio, t as buildProgram } from "../program-BxpeczCx.mjs";
10
+ import { n as drainStdio, t as buildProgram } from "../program-D9-wfmkZ.mjs";
11
11
  import "../context-handover-cache-B9NKSjwR.mjs";
12
12
  import "../config-DCu8THKi.mjs";
13
13
  import "../config-DxMZjpvY.mjs";
@@ -6,7 +6,7 @@ import "../helpers-crDEr6S2.mjs";
6
6
  import "../sync-Btcb6xA4.mjs";
7
7
  import "../embeddings-DvRKcQyt.mjs";
8
8
  import "../search-B69cQwDB.mjs";
9
- import { t as buildProgram } from "../program-BxpeczCx.mjs";
9
+ import { t as buildProgram } from "../program-D9-wfmkZ.mjs";
10
10
  import "../context-handover-cache-B9NKSjwR.mjs";
11
11
  import "../config-DCu8THKi.mjs";
12
12
  import "../config-DxMZjpvY.mjs";
@@ -10076,6 +10076,24 @@ function parseCompactionEvent(line, turnIndex) {
10076
10076
  };
10077
10077
  }
10078
10078
  /**
10079
+ * `type:"system"` `subtype:"model_refusal_fallback"` lines mark an
10080
+ * automatic model switch fired by an API safety refusal (e.g. "cyber"
10081
+ * category), not a user or config choice — worth flagging separately since
10082
+ * it can rebuild the whole prompt cache mid-session.
10083
+ */
10084
+ function isModelFallback(line) {
10085
+ return line.type === "system" && line.subtype === "model_refusal_fallback";
10086
+ }
10087
+ function parseFallbackEvent(line, turnIndex) {
10088
+ return {
10089
+ turnIndex,
10090
+ from: line.originalModel ?? "unknown",
10091
+ to: line.fallbackModel ?? "unknown",
10092
+ category: line.apiRefusalCategory ?? "unknown",
10093
+ scope: line.scope ?? "unknown"
10094
+ };
10095
+ }
10096
+ /**
10079
10097
  * Fold one already-parsed JSONL line into a report being accumulated.
10080
10098
  * Exported separately so both the streaming file reader below and tests
10081
10099
  * (which build fixtures as arrays of objects, not files) share one path.
@@ -10098,13 +10116,24 @@ function foldAssistantLine(report, seenIds, line, threshold) {
10098
10116
  if (typeof v === "number") report.totals[key] += v;
10099
10117
  }
10100
10118
  const context = (Number(usage.cache_read_input_tokens) || 0) + (Number(usage.cache_creation_input_tokens) || 0) + (Number(usage.input_tokens) || 0);
10101
- if (report.firstTurnContext === null) report.firstTurnContext = context;
10119
+ if (report.firstTurnContext === null) {
10120
+ report.firstTurnContext = context;
10121
+ report.firstTurnAt = line.timestamp ?? null;
10122
+ }
10102
10123
  report.lastTurnContext = context;
10103
10124
  report.maxContext = report.maxContext === null ? context : Math.max(report.maxContext, context);
10104
10125
  if (context > threshold) report.turnsAboveThreshold++;
10105
10126
  if ((Number(usage.cache_creation_input_tokens) || 0) > 2e4) report.cacheRebuildTurns++;
10106
10127
  const model = message?.model ?? "unknown";
10107
10128
  report.models[model] = (report.models[model] ?? 0) + 1;
10129
+ if (report.lastModel !== null && report.lastModel !== model) report.modelSwitches.push({
10130
+ turnIndex: report.turns,
10131
+ from: report.lastModel,
10132
+ to: model,
10133
+ cacheRead: Number(usage.cache_read_input_tokens) || 0,
10134
+ cacheCreation: Number(usage.cache_creation_input_tokens) || 0
10135
+ });
10136
+ report.lastModel = model;
10108
10137
  const split = usage.cache_creation;
10109
10138
  if (split) {
10110
10139
  report.cacheCreationSplit.ephemeral5m += Number(split.ephemeral_5m_input_tokens) || 0;
@@ -10120,6 +10149,8 @@ function foldAssistantLine(report, seenIds, line, threshold) {
10120
10149
  */
10121
10150
  function isRealUserPrompt(line) {
10122
10151
  if (line.type !== "user") return false;
10152
+ if (line.isMeta) return false;
10153
+ if (line.origin?.kind && line.origin.kind !== "human") return false;
10123
10154
  const content = line.message?.content;
10124
10155
  if (typeof content === "string") return true;
10125
10156
  if (Array.isArray(content)) return !content.some((block) => block?.type === "tool_result");
@@ -10145,7 +10176,11 @@ async function parseSessionUsage(path, threshold = 2e5) {
10145
10176
  cacheRebuildTurns: 0,
10146
10177
  userPrompts: 0,
10147
10178
  promptExposure: 0,
10148
- compactions: []
10179
+ compactions: [],
10180
+ firstTurnAt: null,
10181
+ lastModel: null,
10182
+ modelSwitches: [],
10183
+ fallbacks: []
10149
10184
  };
10150
10185
  const seenIds = /* @__PURE__ */ new Set();
10151
10186
  let contextSum = 0;
@@ -10170,6 +10205,10 @@ async function parseSessionUsage(path, threshold = 2e5) {
10170
10205
  if (event) report.compactions.push(event);
10171
10206
  continue;
10172
10207
  }
10208
+ if (isModelFallback(obj)) {
10209
+ report.fallbacks.push(parseFallbackEvent(obj, report.turns));
10210
+ continue;
10211
+ }
10173
10212
  const context = foldAssistantLine(report, seenIds, obj, threshold);
10174
10213
  if (context !== null) {
10175
10214
  contextSum += context;
@@ -10183,6 +10222,272 @@ function totalUsageTokens(totals) {
10183
10222
  return totals.cache_read_input_tokens + totals.cache_creation_input_tokens + totals.input_tokens + totals.output_tokens;
10184
10223
  }
10185
10224
 
10225
+ //#endregion
10226
+ //#region src/audit/first-turn.ts
10227
+ /**
10228
+ * first-turn.ts — where a turn's context actually comes from, attributed
10229
+ * line by line from the transcript.
10230
+ *
10231
+ * The API reports one number per assistant turn (cache_read + cache_creation
10232
+ * + input). The transcript holds the pieces Claude Code injected since the
10233
+ * previous turn: SessionStart / UserPromptSubmit hook output, the skill
10234
+ * listing, the deferred-tool name list, MCP server instructions, the human
10235
+ * prompt, any isMeta expansion (a skill body, a pasted slash-command result),
10236
+ * tool results, and the previous turn's own visible output (re-sent as
10237
+ * context on every later turn). Each of those is counted in cl100k here;
10238
+ * what is left over is the system prompt, the built-in tool schemas, agents
10239
+ * and memory files, which never appear in the transcript.
10240
+ *
10241
+ * Only the live lineage counts. Lines chain by parentUuid, and a prompt the
10242
+ * user abandoned before a reply (Escape, re-submit) leaves a dead branch
10243
+ * whose lines were never sent — run 9 measured a session whose first
10244
+ * /Name was abandoned and read 24k where the true baseline is 30k, because
10245
+ * the 3.5k-token skill listing sat on the dead branch.
10246
+ *
10247
+ * `type:"system"` lines with subtype local_command hold a slash command's
10248
+ * raw stdout, and the model receives it verbatim in addition to the isMeta
10249
+ * markdown copy — run 11 measured both present on the same turn, so both are
10250
+ * counted.
10251
+ */
10252
+ function parseLines(path) {
10253
+ const lines = [];
10254
+ for (const raw of readFileSync(path, "utf8").split("\n")) {
10255
+ if (!raw.trim()) continue;
10256
+ try {
10257
+ lines.push(JSON.parse(raw));
10258
+ } catch {}
10259
+ }
10260
+ return lines;
10261
+ }
10262
+ function classify(line) {
10263
+ if (line.type === "attachment") {
10264
+ const a = line.attachment ?? {};
10265
+ switch (a.type) {
10266
+ case "hook_success": {
10267
+ const name = a.hookName ?? "";
10268
+ return {
10269
+ kind: name.startsWith("SessionStart") ? "hook:SessionStart" : name.startsWith("UserPromptSubmit") ? "hook:UserPromptSubmit" : "hook:other",
10270
+ tokens: countTokens(a.stdout ?? a.content ?? ""),
10271
+ label: name
10272
+ };
10273
+ }
10274
+ case "skill_listing": return {
10275
+ kind: "skill_listing",
10276
+ tokens: countTokens(a.content ?? ""),
10277
+ label: "skill_listing"
10278
+ };
10279
+ case "deferred_tools_delta": return {
10280
+ kind: "deferred_tools",
10281
+ tokens: countTokens((a.addedNames ?? []).join("\n")),
10282
+ label: "deferred_tools_delta"
10283
+ };
10284
+ case "mcp_instructions_delta": return {
10285
+ kind: "mcp_instructions",
10286
+ tokens: countTokens(JSON.stringify(a.instructions ?? a.addedInstructions ?? a)),
10287
+ label: "mcp_instructions_delta"
10288
+ };
10289
+ default: return {
10290
+ kind: "attachment:other",
10291
+ tokens: countTokens(JSON.stringify(a)),
10292
+ label: a.type ?? "attachment"
10293
+ };
10294
+ }
10295
+ }
10296
+ if (line.type === "user") {
10297
+ const c = line.message?.content;
10298
+ const isToolResult = Array.isArray(c) && c.some((b) => b?.type === "tool_result");
10299
+ const text = typeof c === "string" ? c : JSON.stringify(c ?? "");
10300
+ return {
10301
+ kind: isToolResult ? "tool_result" : line.isMeta ? "prompt:meta" : "prompt",
10302
+ tokens: countTokens(text),
10303
+ label: text.slice(0, 40).replace(/\s+/g, " ")
10304
+ };
10305
+ }
10306
+ if (line.type === "system") return {
10307
+ kind: line.subtype === "local_command" ? "prompt:local_command" : "system:display-only",
10308
+ tokens: countTokens(line.content ?? ""),
10309
+ label: line.subtype ?? "system"
10310
+ };
10311
+ return null;
10312
+ }
10313
+ function apiContextOf(usage) {
10314
+ if (!usage) return null;
10315
+ return (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.input_tokens ?? 0);
10316
+ }
10317
+ /**
10318
+ * Attribute API call `turn`'s context growth to the transcript lines
10319
+ * injected since the previous call. Turn numbering matches
10320
+ * session-usage.ts's `turnIndex`: several assistant lines sharing one
10321
+ * message.id (one line per streamed content block) are one call. Returns
10322
+ * null if the transcript has fewer than `turn` calls.
10323
+ */
10324
+ function turnBreakdown(path, turn) {
10325
+ if (!existsSync(path)) return null;
10326
+ const lines = parseLines(path);
10327
+ const byUuid = /* @__PURE__ */ new Map();
10328
+ for (const l of lines) if (l.uuid) byUuid.set(l.uuid, l);
10329
+ const seen = /* @__PURE__ */ new Set();
10330
+ let n = 0;
10331
+ let target = null;
10332
+ for (const l of lines) if (l.type === "assistant" && l.message?.usage) {
10333
+ const id = l.message.id ?? l.uuid ?? "";
10334
+ if (!seen.has(id)) {
10335
+ seen.add(id);
10336
+ n++;
10337
+ if (n === turn) {
10338
+ target = l;
10339
+ break;
10340
+ }
10341
+ }
10342
+ }
10343
+ if (!target) return null;
10344
+ const rawChain = [];
10345
+ let prevLine = null;
10346
+ let cur = target.parentUuid ? byUuid.get(target.parentUuid) : void 0;
10347
+ while (cur) {
10348
+ if (cur.type === "assistant" && cur.message?.usage) {
10349
+ prevLine = cur;
10350
+ break;
10351
+ }
10352
+ rawChain.unshift(cur);
10353
+ cur = cur.parentUuid ? byUuid.get(cur.parentUuid) : void 0;
10354
+ }
10355
+ const items = [];
10356
+ for (const l of rawChain) {
10357
+ const item = classify(l);
10358
+ if (item) items.push(item);
10359
+ }
10360
+ let prevApiContext = 0;
10361
+ let prevOutputTokens = 0;
10362
+ if (prevLine) {
10363
+ prevApiContext = apiContextOf(prevLine.message?.usage) ?? 0;
10364
+ prevOutputTokens = prevLine.message?.usage?.output_tokens ?? 0;
10365
+ const prevId = prevLine.message?.id ?? prevLine.uuid ?? "";
10366
+ const prevBlocks = [prevLine];
10367
+ let cur2 = prevLine.parentUuid ? byUuid.get(prevLine.parentUuid) : void 0;
10368
+ while (cur2 && cur2.type === "assistant" && (cur2.message?.id ?? cur2.uuid ?? "") === prevId) {
10369
+ prevBlocks.unshift(cur2);
10370
+ cur2 = cur2.parentUuid ? byUuid.get(cur2.parentUuid) : void 0;
10371
+ }
10372
+ let prevText = "";
10373
+ let thinkingCount = 0;
10374
+ for (const b of prevBlocks) {
10375
+ const content = b.message?.content;
10376
+ if (!Array.isArray(content)) continue;
10377
+ for (const block of content) if (block?.type === "text") prevText += block.text ?? "";
10378
+ else if (block?.type === "tool_use") prevText += JSON.stringify(block.input ?? {});
10379
+ else if (block?.type === "thinking") thinkingCount++;
10380
+ }
10381
+ const label = thinkingCount > 0 ? `previous call output (${thinkingCount} thinking block(s) redacted)` : "previous call output";
10382
+ items.push({
10383
+ kind: "assistant:prev",
10384
+ tokens: countTokens(prevText),
10385
+ label
10386
+ });
10387
+ }
10388
+ const byKind = {};
10389
+ let transcriptTokens = 0;
10390
+ let prevVisibleTokens = 0;
10391
+ for (const item of items) {
10392
+ if (item.kind === "system:display-only") continue;
10393
+ if (item.kind === "assistant:prev") {
10394
+ prevVisibleTokens = item.tokens;
10395
+ continue;
10396
+ }
10397
+ transcriptTokens += item.tokens;
10398
+ byKind[item.kind] = (byKind[item.kind] ?? 0) + item.tokens;
10399
+ }
10400
+ const apiContext = apiContextOf(target.message?.usage);
10401
+ const billedDelta = apiContext === null ? null : apiContext - prevApiContext;
10402
+ const residual = billedDelta === null ? null : billedDelta - transcriptTokens - prevOutputTokens;
10403
+ return {
10404
+ path,
10405
+ turn,
10406
+ apiContext,
10407
+ prevApiContext,
10408
+ billedDelta,
10409
+ prevOutputTokens,
10410
+ items,
10411
+ transcriptTokens,
10412
+ byKind,
10413
+ prevVisibleTokens,
10414
+ residual
10415
+ };
10416
+ }
10417
+ /** Attribute the first assistant turn's context to the transcript lines on its live lineage. */
10418
+ function firstTurnBreakdown(path) {
10419
+ const empty = {
10420
+ path,
10421
+ apiContext: null,
10422
+ items: [],
10423
+ transcriptTokens: 0,
10424
+ byKind: {},
10425
+ remainder: null,
10426
+ deadBranchTokens: 0,
10427
+ deadBranchLines: 0
10428
+ };
10429
+ if (!existsSync(path)) return empty;
10430
+ const tb = turnBreakdown(path, 1);
10431
+ if (!tb) return empty;
10432
+ const lines = parseLines(path);
10433
+ const byUuid = /* @__PURE__ */ new Map();
10434
+ let firstIndex = -1;
10435
+ for (let i = 0; i < lines.length; i++) {
10436
+ const l = lines[i];
10437
+ if (l.uuid) byUuid.set(l.uuid, l);
10438
+ if (l.type === "assistant" && l.message?.usage && firstIndex < 0) firstIndex = i;
10439
+ }
10440
+ if (firstIndex < 0) return empty;
10441
+ const first = lines[firstIndex];
10442
+ const live = /* @__PURE__ */ new Set();
10443
+ let cur = first.parentUuid ? byUuid.get(first.parentUuid) : void 0;
10444
+ while (cur && !live.has(cur)) {
10445
+ live.add(cur);
10446
+ cur = cur.parentUuid ? byUuid.get(cur.parentUuid) : void 0;
10447
+ }
10448
+ let deadBranchTokens = 0;
10449
+ let deadBranchLines = 0;
10450
+ for (let i = 0; i < firstIndex; i++) {
10451
+ const l = lines[i];
10452
+ if (!l.uuid || live.has(l)) continue;
10453
+ const item = classify(l);
10454
+ if (!item || item.kind === "system:display-only") continue;
10455
+ deadBranchLines++;
10456
+ deadBranchTokens += item.tokens;
10457
+ }
10458
+ return {
10459
+ path,
10460
+ apiContext: tb.apiContext,
10461
+ items: tb.items,
10462
+ transcriptTokens: tb.transcriptTokens,
10463
+ byKind: tb.byKind,
10464
+ remainder: tb.residual,
10465
+ deadBranchTokens,
10466
+ deadBranchLines
10467
+ };
10468
+ }
10469
+ /** One-line evidence string for the combined report's first-turn finding. */
10470
+ function firstTurnEvidence(b) {
10471
+ const parts = [];
10472
+ for (const k of [
10473
+ "skill_listing",
10474
+ "mcp_instructions",
10475
+ "deferred_tools",
10476
+ "hook:SessionStart",
10477
+ "hook:UserPromptSubmit",
10478
+ "hook:other",
10479
+ "prompt:meta",
10480
+ "prompt:local_command",
10481
+ "prompt",
10482
+ "attachment:other"
10483
+ ]) {
10484
+ const v = b.byKind[k];
10485
+ if (v) parts.push(`${k} ${v}`);
10486
+ }
10487
+ const dead = b.deadBranchTokens > 0 ? `; dead-branch ${b.deadBranchTokens} not sent` : "";
10488
+ return `transcript-side ${b.transcriptTokens} (${parts.join(", ")}), remainder ${b.remainder ?? "n/a"}${dead}`;
10489
+ }
10490
+
10186
10491
  //#endregion
10187
10492
  //#region src/audit/session.ts
10188
10493
  /**
@@ -10236,9 +10541,48 @@ async function auditSession(path, threshold) {
10236
10541
  return {
10237
10542
  ...report,
10238
10543
  totalTokens: total,
10239
- percentages
10544
+ percentages,
10545
+ firstTurn: firstTurnBreakdown(target)
10240
10546
  };
10241
10547
  }
10548
+ /**
10549
+ * First-turn context of every session transcript in this project's
10550
+ * `~/.claude/projects/<encoded-cwd>` directory, newest first — the readings
10551
+ * needed to see when a step change (model switch, hook growth) happened
10552
+ * across sessions without an ad-hoc script.
10553
+ */
10554
+ async function sessionHistory(cwd, limit = 20) {
10555
+ const dir = join(homedir(), ".claude", "projects", encodeDir(cwd));
10556
+ let entries;
10557
+ try {
10558
+ entries = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
10559
+ } catch {
10560
+ return [];
10561
+ }
10562
+ const rows = [];
10563
+ for (const entry of entries) {
10564
+ const path = join(dir, entry);
10565
+ const report = await parseSessionUsage(path);
10566
+ if (report.turns === 0) continue;
10567
+ rows.push({
10568
+ path,
10569
+ sessionId: entry.replace(/\.jsonl$/, ""),
10570
+ firstTurnAt: report.firstTurnAt,
10571
+ firstTurnContext: report.firstTurnContext,
10572
+ turns: report.turns,
10573
+ models: report.models,
10574
+ switches: report.modelSwitches.length,
10575
+ fallbacks: report.fallbacks.length
10576
+ });
10577
+ }
10578
+ rows.sort((a, b) => {
10579
+ if (a.firstTurnAt === null && b.firstTurnAt === null) return 0;
10580
+ if (a.firstTurnAt === null) return 1;
10581
+ if (b.firstTurnAt === null) return -1;
10582
+ return b.firstTurnAt.localeCompare(a.firstTurnAt);
10583
+ });
10584
+ return rows.slice(0, limit);
10585
+ }
10242
10586
 
10243
10587
  //#endregion
10244
10588
  //#region src/audit/spawn.ts
@@ -11026,6 +11370,7 @@ const FIRST_TURN_CONTEXT_LIMIT = 3e4;
11026
11370
  const DAEMON_FAILURE_RATE_LIMIT = .05;
11027
11371
  const CONTEXT_GROWTH_AVG_RED = 1e5;
11028
11372
  const CONTEXT_GROWTH_MAX_AMBER = 15e4;
11373
+ const MODEL_SWITCH_REBUILD_LIMIT = 2e4;
11029
11374
  const SEVERITY_RANK = {
11030
11375
  RED: 0,
11031
11376
  AMBER: 1,
@@ -11065,10 +11410,11 @@ function buildFindings(input) {
11065
11410
  });
11066
11411
  if (input.session) {
11067
11412
  const first = input.session.firstTurnContext ?? 0;
11413
+ const evidence = typeof input.session.firstTurn?.apiContext === "number" ? `${first} tokens: ${firstTurnEvidence(input.session.firstTurn)} (${input.session.path})` : `${first} tokens (${input.session.path})`;
11068
11414
  findings.push({
11069
11415
  finding: "first-turn context size",
11070
11416
  severity: first > FIRST_TURN_CONTEXT_LIMIT ? "AMBER" : "GREEN",
11071
- evidence: `${first} tokens (${input.session.path})`
11417
+ evidence
11072
11418
  });
11073
11419
  }
11074
11420
  const rate = input.daemon.kgParseFailureRate;
@@ -11152,6 +11498,22 @@ function buildFindings(input) {
11152
11498
  severity: overshoots.length > 0 ? "RED" : "GREEN",
11153
11499
  evidence: overshoots.length > 0 ? `override not honoured: ${overshoots.map((c) => `preTokens=${c.preTokens}`).join(", ")} vs configured ${input.ctxThreshold}` : compactions.length === 0 ? `0 compactions in ${input.session.turns} turns` : `${compactions.length} compaction(s), all within 25% of configured ${input.ctxThreshold}`
11154
11500
  });
11501
+ const switches = input.session.modelSwitches;
11502
+ const fallbacks = input.session.fallbacks;
11503
+ const rebuilds = switches.filter((s) => s.cacheCreation > MODEL_SWITCH_REBUILD_LIMIT);
11504
+ const short = (model) => model.replace(/^claude-/, "");
11505
+ let switchEvidence;
11506
+ if (switches.length === 0) switchEvidence = `0 switches in ${input.session.turns} turns, models ${JSON.stringify(input.session.models)}`;
11507
+ else {
11508
+ const list = switches.map((s) => `${short(s.from)}->${short(s.to)} at turn ${s.turnIndex} (cache_read ${s.cacheRead}, cache_creation ${s.cacheCreation})`).join("; ");
11509
+ switchEvidence = `${switches.length} switch(es): ${list}`;
11510
+ if (fallbacks.length > 0) switchEvidence += `; ${fallbacks.length} safeguard fallback(s): ${fallbacks.map((f) => f.category).join(",")}`;
11511
+ }
11512
+ findings.push({
11513
+ finding: "mid-session model switches",
11514
+ severity: switches.length === 0 ? "GREEN" : rebuilds.length > 0 ? "RED" : "AMBER",
11515
+ evidence: switchEvidence
11516
+ });
11155
11517
  }
11156
11518
  return sortFindings(findings);
11157
11519
  }
@@ -11695,14 +12057,98 @@ function printSession(report) {
11695
12057
  "share"
11696
12058
  ], rows));
11697
12059
  console.log(`context on first turn: ${report.firstTurnContext ?? "n/a"}`);
12060
+ if (report.firstTurn && report.firstTurn.apiContext !== null) {
12061
+ const ft = report.firstTurn;
12062
+ console.log(header("first-turn breakdown (live lineage, cl100k)"));
12063
+ const ftRows = ft.items.filter((i) => i.tokens > 0).map((i) => [
12064
+ i.kind,
12065
+ String(i.tokens),
12066
+ i.label
12067
+ ]);
12068
+ console.log(renderTable([
12069
+ "kind",
12070
+ "tokens",
12071
+ "label"
12072
+ ], ftRows));
12073
+ console.log(`transcript-side: ${ft.transcriptTokens}`);
12074
+ console.log(`remainder (system prompt + tool schemas + agents + memory): ${ft.remainder}`);
12075
+ console.log(`dead-branch: ${ft.deadBranchTokens} tokens on ${ft.deadBranchLines} lines, persisted but never sent`);
12076
+ }
11698
12077
  console.log(`context on last turn: ${report.lastTurnContext ?? "n/a"}`);
11699
12078
  console.log(`cache_creation split: ephemeral_5m=${report.cacheCreationSplit.ephemeral5m}, ephemeral_1h=${report.cacheCreationSplit.ephemeral1h}`);
11700
12079
  console.log(dim$1(`models: ${JSON.stringify(report.models)}`));
11701
12080
  console.log(`avg context: ${report.avgContext ?? "n/a"}, max context: ${report.maxContext ?? "n/a"}, turns above threshold: ${report.turnsAboveThreshold}, cache-rebuild turns: ${report.cacheRebuildTurns}, user prompts: ${report.userPrompts}, prompt exposure: ${report.promptExposure}`);
11702
12081
  console.log(`compactions: ${report.compactions.length}`);
11703
12082
  for (const c of report.compactions) console.log(dim$1(` ${c.trigger} at turn ${c.turnIndex}: preTokens=${c.preTokens}`));
12083
+ console.log(`model switches: ${report.modelSwitches.length}`);
12084
+ for (const s of report.modelSwitches) console.log(dim$1(` ${s.from} -> ${s.to} at turn ${s.turnIndex}: cache_read=${s.cacheRead}, cache_creation=${s.cacheCreation}`));
12085
+ if (report.fallbacks.length > 0) {
12086
+ console.log(`safeguard fallbacks: ${report.fallbacks.length}`);
12087
+ for (const f of report.fallbacks) console.log(dim$1(` ${f.from} -> ${f.to} before turn ${f.turnIndex}: ${f.category} (${f.scope})`));
12088
+ }
12089
+ }
12090
+ function printTurnBreakdown(tb) {
12091
+ console.log(header(`turn ${tb.turn} attribution (live lineage, cl100k)`));
12092
+ const rows = tb.items.filter((i) => i.tokens > 0).map((i) => [
12093
+ String(i.tokens),
12094
+ i.kind,
12095
+ i.label.replace(/\s+/g, " ").slice(0, 60)
12096
+ ]);
12097
+ console.log(renderTable([
12098
+ "tokens",
12099
+ "kind",
12100
+ "label"
12101
+ ], rows));
12102
+ console.log(`transcript-side ${tb.transcriptTokens}`);
12103
+ console.log(`billed delta ${tb.billedDelta ?? "n/a"} (context ${tb.apiContext ?? "n/a"} after ${tb.prevApiContext})`);
12104
+ console.log(`previous call output ${tb.prevOutputTokens} (includes thinking, re-sent on tool turns)`);
12105
+ console.log(`previous call visible output ${tb.prevVisibleTokens} (cl100k, listed as assistant:prev, not in transcript-side)`);
12106
+ console.log(`residual ${tb.residual ?? "n/a"}: positive = cl100k undercount vs the billing tokenizer (1.2-1.45x measured on tool output and markdown tables); negative = the previous call's thinking was not re-sent (only re-sent inside a tool loop)`);
12107
+ }
12108
+ function printSessionHistory(rows) {
12109
+ console.log(header("Session history (newest first)"));
12110
+ const tableRows = rows.map((r) => [
12111
+ r.firstTurnAt ?? "n/a",
12112
+ r.sessionId,
12113
+ r.firstTurnContext === null ? "n/a" : String(r.firstTurnContext),
12114
+ String(r.turns),
12115
+ String(r.switches),
12116
+ Object.entries(r.models).map(([name, count]) => `${name}:${count}`).join(",")
12117
+ ]);
12118
+ console.log(renderTable([
12119
+ "first turn (UTC)",
12120
+ "session",
12121
+ "first-turn ctx",
12122
+ "turns",
12123
+ "switches",
12124
+ "models"
12125
+ ], tableRows));
11704
12126
  }
11705
12127
  async function cmdSession(path, opts) {
12128
+ if (opts.history !== void 0) {
12129
+ const limit = typeof opts.history === "string" ? parseInt(opts.history, 10) : 20;
12130
+ const rows = await sessionHistory(process.cwd(), limit);
12131
+ if (opts.json) return printJson(rows);
12132
+ printSessionHistory(rows);
12133
+ return;
12134
+ }
12135
+ if (opts.turn !== void 0) {
12136
+ const target = path ?? newestSessionLog();
12137
+ if (!target) {
12138
+ console.error(err(" No session transcript found."));
12139
+ process.exitCode = 1;
12140
+ return;
12141
+ }
12142
+ const tb = turnBreakdown(target, parseInt(opts.turn, 10));
12143
+ if (!tb) {
12144
+ console.error(err(` Turn ${opts.turn} not found in ${target}.`));
12145
+ process.exitCode = 1;
12146
+ return;
12147
+ }
12148
+ if (opts.json) return printJson(tb);
12149
+ printTurnBreakdown(tb);
12150
+ return;
12151
+ }
11706
12152
  const report = await auditSession(path, opts.ctxThreshold ? parseInt(opts.ctxThreshold, 10) : void 0);
11707
12153
  if (!report) {
11708
12154
  console.error(err(" No session transcript found."));
@@ -12186,7 +12632,7 @@ function registerAuditCommands(auditCmd) {
12186
12632
  await cmdFiles(paths, jsonOpt());
12187
12633
  });
12188
12634
  tokensCmd.command("hooks").description("Token cost of every SessionStart / UserPromptSubmit hook").action(() => cmdHooks(jsonOpt()));
12189
- tokensCmd.command("session").description("Cache/input/output token split for a session transcript (default: newest)").argument("[path]", "Session JSONL path (default: newest under ~/.claude/projects)").option("--ctx-threshold <n>", "Per-turn context size above which a turn counts as 'above threshold' (default 200000)").action(async (path, opts) => {
12635
+ tokensCmd.command("session").description("Cache/input/output token split for a session transcript (default: newest)").argument("[path]", "Session JSONL path (default: newest under ~/.claude/projects)").option("--ctx-threshold <n>", "Per-turn context size above which a turn counts as 'above threshold' (default 200000)").option("--history [n]", "List first-turn context of the newest n sessions in this project's transcript directory (default 20)").option("--turn <n>", "Attribute API call n's context growth to the transcript lines injected since the previous call").action(async (path, opts) => {
12190
12636
  await cmdSession(path, {
12191
12637
  ...jsonOpt(),
12192
12638
  ...opts
@@ -20064,4 +20510,4 @@ claude() {
20064
20510
 
20065
20511
  //#endregion
20066
20512
  export { drainStdio as n, buildProgram as t };
20067
- //# sourceMappingURL=program-BxpeczCx.mjs.map
20513
+ //# sourceMappingURL=program-D9-wfmkZ.mjs.map