@agent-finops/core 0.5.4 → 0.5.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.
@@ -1,11 +1,19 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
- import { basename, join, resolve } from "node:path";
2
+ import { basename, isAbsolute, join, resolve, sep } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { estimateTokenCostUsd } from "./modelPricing.js";
5
+ import { redactSecrets } from "./discovery.js";
5
6
  /** Parse one Claude Code transcript (JSONL). Exported for tests. */
6
7
  export function parseClaudeCodeTranscript(content, filePath = "") {
7
8
  const calls = [];
8
9
  const seen = new Set();
10
+ const prompts = [];
11
+ const fileCounts = new Map();
12
+ let title;
13
+ let lastPrompt;
14
+ let toolCallCount = 0;
15
+ let isSubagent = filePath.split(sep).includes("subagents");
16
+ let parentSessionId;
9
17
  for (const line of content.split("\n")) {
10
18
  if (!line.trim())
11
19
  continue;
@@ -16,9 +24,34 @@ export function parseClaudeCodeTranscript(content, filePath = "") {
16
24
  catch {
17
25
  continue;
18
26
  }
19
- if (!isRecord(entry) || entry.type !== "assistant")
27
+ if (!isRecord(entry))
20
28
  continue;
29
+ if (entry.type === "ai-title") {
30
+ title = stringOf(entry.aiTitle) ?? title;
31
+ }
32
+ if (entry.type === "last-prompt") {
33
+ lastPrompt = stringOf(entry.lastPrompt) ?? lastPrompt;
34
+ }
35
+ if (entry.isSidechain === true)
36
+ isSubagent = true;
37
+ parentSessionId = stringOf(entry.parentSessionId) ?? parentSessionId;
21
38
  const message = isRecord(entry.message) ? entry.message : undefined;
39
+ if (entry.type === "user" && message) {
40
+ for (const prompt of textValues(message.content)) {
41
+ if (isHumanPrompt(prompt))
42
+ prompts.push(prompt);
43
+ }
44
+ }
45
+ if (entry.type === "assistant" && message) {
46
+ for (const item of recordValues(message.content)) {
47
+ if (item.type !== "tool_use")
48
+ continue;
49
+ toolCallCount += 1;
50
+ collectToolFiles(item.input, fileCounts);
51
+ }
52
+ }
53
+ if (entry.type !== "assistant")
54
+ continue;
22
55
  const usage = message && isRecord(message.usage) ? message.usage : undefined;
23
56
  if (!message || !usage)
24
57
  continue;
@@ -50,15 +83,37 @@ export function parseClaudeCodeTranscript(content, filePath = "") {
50
83
  }
51
84
  });
52
85
  }
86
+ const fallbackPrompt = lastPrompt && isHumanPrompt(lastPrompt) ? lastPrompt : undefined;
87
+ const activity = buildLocalAgentActivity({
88
+ title,
89
+ prompts: prompts.length > 0 ? prompts : fallbackPrompt ? [fallbackPrompt] : [],
90
+ files: fileCounts,
91
+ toolCallCount,
92
+ project: calls[0]?.project ?? projectFromTranscriptPath(filePath),
93
+ isSubagent,
94
+ parentSessionId
95
+ });
96
+ if (activity) {
97
+ for (const call of calls)
98
+ call.activity = activity;
99
+ }
53
100
  return calls;
54
101
  }
55
102
  /** Parse one Codex rollout file (JSONL event stream). Exported for tests. */
56
103
  export function parseCodexRollout(content) {
57
104
  let model;
58
105
  let cwd;
106
+ const toolWorkdirs = new Map();
59
107
  let sessionId;
60
- let timestamp;
108
+ let startedAt;
109
+ let lastActivityAt;
61
110
  let lastTotal;
111
+ let lastRateLimits;
112
+ const prompts = [];
113
+ const fileCounts = new Map();
114
+ let toolCallCount = 0;
115
+ let isSubagent = false;
116
+ let parentSessionId;
62
117
  for (const line of content.split("\n")) {
63
118
  if (!line.trim())
64
119
  continue;
@@ -75,18 +130,42 @@ export function parseCodexRollout(content) {
75
130
  if (entry.type === "session_meta" && payload) {
76
131
  sessionId = stringOf(payload.id) ?? sessionId;
77
132
  cwd = stringOf(payload.cwd) ?? cwd;
78
- timestamp = toIso(stringOf(payload.timestamp) ?? stringOf(entry.timestamp)) ?? timestamp;
133
+ startedAt = toIso(stringOf(payload.timestamp) ?? stringOf(entry.timestamp)) ?? startedAt;
134
+ isSubagent = stringOf(payload.thread_source) === "subagent" || isRecord(payload.source) && "subagent" in payload.source;
135
+ parentSessionId = stringOf(payload.parent_thread_id) ?? parentSessionId;
79
136
  }
80
137
  if (entry.type === "turn_context" && payload) {
81
138
  model = stringOf(payload.model) ?? model;
82
139
  cwd = stringOf(payload.cwd) ?? cwd;
83
140
  }
141
+ if (payload?.type === "function_call" || payload?.type === "custom_tool_call") {
142
+ toolCallCount += 1;
143
+ const args = jsonRecord(stringOf(payload.arguments));
144
+ const workdir = stringOf(args?.workdir) ?? stringOf(args?.cwd);
145
+ if (workdir && isAbsolute(workdir)) {
146
+ const normalized = resolve(workdir);
147
+ toolWorkdirs.set(normalized, (toolWorkdirs.get(normalized) ?? 0) + 1);
148
+ }
149
+ collectToolFiles(args, fileCounts);
150
+ collectPatchFiles(stringOf(args?.patch) ?? stringOf(args?.input) ?? stringOf(payload.input), fileCounts);
151
+ }
152
+ if (payload?.type === "message" && payload.role === "user") {
153
+ for (const prompt of textValues(payload.content)) {
154
+ if (isHumanPrompt(prompt))
155
+ prompts.push(prompt);
156
+ }
157
+ }
84
158
  if (entry.type === "event_msg" && payload?.type === "token_count") {
159
+ const eventTimestamp = toIso(stringOf(entry.timestamp)) ?? lastActivityAt ?? startedAt;
85
160
  const info = isRecord(payload.info) ? payload.info : undefined;
86
161
  const total = info && isRecord(info.total_token_usage) ? info.total_token_usage : undefined;
87
162
  if (total) {
88
163
  lastTotal = total;
89
- timestamp = timestamp ?? toIso(stringOf(entry.timestamp));
164
+ lastActivityAt = eventTimestamp;
165
+ }
166
+ const rateLimits = parseCodexRateLimits(payload.rate_limits, eventTimestamp);
167
+ if (rateLimits) {
168
+ lastRateLimits = rateLimits;
90
169
  }
91
170
  }
92
171
  }
@@ -94,12 +173,24 @@ export function parseCodexRollout(content) {
94
173
  return [];
95
174
  const input = numberOf(lastTotal.input_tokens) ?? 0;
96
175
  const cached = numberOf(lastTotal.cached_input_tokens) ?? 0;
176
+ const project = projectFromCwd(dominantCodexCwd(cwd, toolWorkdirs));
177
+ const activity = buildLocalAgentActivity({
178
+ prompts,
179
+ files: fileCounts,
180
+ toolCallCount,
181
+ project,
182
+ isSubagent,
183
+ parentSessionId
184
+ });
97
185
  return [{
98
186
  agent: "codex",
99
187
  model: model ?? "codex",
100
- timestamp: timestamp ?? new Date(0).toISOString(),
101
- project: projectFromCwd(cwd),
188
+ timestamp: lastActivityAt ?? startedAt ?? new Date(0).toISOString(),
189
+ startedAt,
190
+ project,
102
191
  sessionId,
192
+ rateLimits: lastRateLimits,
193
+ activity,
103
194
  usage: {
104
195
  // Codex input_tokens INCLUDES cached tokens; split them out.
105
196
  inputTokens: Math.max(0, input - cached),
@@ -108,6 +199,52 @@ export function parseCodexRollout(content) {
108
199
  }
109
200
  }];
110
201
  }
202
+ function parseCodexRateLimits(value, observedAt) {
203
+ if (!isRecord(value) || !observedAt)
204
+ return undefined;
205
+ const windows = [value.primary, value.secondary]
206
+ .map((window) => parseCodexRateLimitWindow(window))
207
+ .filter((window) => Boolean(window));
208
+ if (windows.length === 0)
209
+ return undefined;
210
+ return {
211
+ observedAt,
212
+ limitId: stringOf(value.limit_id),
213
+ planType: stringOf(value.plan_type),
214
+ windows
215
+ };
216
+ }
217
+ function parseCodexRateLimitWindow(value) {
218
+ if (!isRecord(value))
219
+ return undefined;
220
+ const usedPercent = numberOf(value.used_percent);
221
+ const windowMinutes = numberOf(value.window_minutes);
222
+ const resetsAtSeconds = numberOf(value.resets_at);
223
+ if (usedPercent === undefined ||
224
+ windowMinutes === undefined ||
225
+ windowMinutes <= 0 ||
226
+ resetsAtSeconds === undefined) {
227
+ return undefined;
228
+ }
229
+ const resetMs = resetsAtSeconds < 1_000_000_000_000
230
+ ? resetsAtSeconds * 1_000
231
+ : resetsAtSeconds;
232
+ const resetsAt = toIso(new Date(resetMs).toISOString());
233
+ if (!resetsAt)
234
+ return undefined;
235
+ const kind = windowMinutes === 300
236
+ ? "five-hour"
237
+ : windowMinutes === 10_080
238
+ ? "weekly"
239
+ : "custom";
240
+ return {
241
+ kind,
242
+ name: kind === "custom" ? `${windowMinutes}-minute` : kind,
243
+ usedPercent: Math.min(100, Math.max(0, usedPercent)),
244
+ windowMinutes,
245
+ resetsAt
246
+ };
247
+ }
111
248
  /** Scan this machine's agent logs and return aggregated UsageRecords. */
112
249
  export async function loadLocalAgentUsage(options = {}) {
113
250
  const home = homedir();
@@ -215,6 +352,31 @@ function projectFromCwd(cwd) {
215
352
  const name = basename(cwd);
216
353
  return name.length > 0 ? name : undefined;
217
354
  }
355
+ /**
356
+ * Codex can be launched from HOME and do nearly all of its work through tools
357
+ * that declare a more specific working directory. Prefer that observed
358
+ * activity only when the session-level cwd is not already a real project.
359
+ * Nested tool directories roll up to the shallowest observed ancestor with
360
+ * the strongest descendant-weighted activity.
361
+ */
362
+ function dominantCodexCwd(sessionCwd, toolWorkdirs) {
363
+ const sessionProject = projectFromCwd(sessionCwd);
364
+ if (sessionProject && sessionProject !== "(home)")
365
+ return sessionCwd;
366
+ const home = resolve(homedir());
367
+ const candidates = [...toolWorkdirs.entries()]
368
+ .filter(([path]) => resolve(path) !== home);
369
+ const scored = candidates.map(([candidate]) => ({
370
+ candidate,
371
+ score: candidates.reduce((total, [path, count]) => (path === candidate || path.startsWith(`${candidate}${sep}`)
372
+ ? total + count
373
+ : total), 0)
374
+ }));
375
+ scored.sort((left, right) => (right.score - left.score ||
376
+ left.candidate.split(sep).length - right.candidate.split(sep).length ||
377
+ left.candidate.localeCompare(right.candidate)));
378
+ return scored[0]?.candidate ?? sessionCwd;
379
+ }
218
380
  /** Claude Code encodes the project path into the transcript's parent dir name. */
219
381
  function projectFromTranscriptPath(filePath) {
220
382
  if (!filePath)
@@ -223,6 +385,286 @@ function projectFromTranscriptPath(filePath) {
223
385
  const tail = parent.split("-").filter(Boolean).pop();
224
386
  return tail && tail.length > 0 ? tail : undefined;
225
387
  }
388
+ const FOCUS_STOP_WORDS = new Set([
389
+ "about", "after", "again", "also", "and", "are", "at", "been", "being", "but",
390
+ "can", "check", "could", "did", "does", "doing", "dont", "every", "from",
391
+ "for", "have", "here", "how", "in", "into", "its", "just", "like", "make", "more",
392
+ "need", "not", "now", "on", "only", "other", "our", "please", "really", "should",
393
+ "something", "sure", "than", "that", "the", "their", "them", "then", "there",
394
+ "these", "they", "thing", "think", "this", "through", "to", "too", "use", "user",
395
+ "users", "want", "was", "way", "we", "what", "when", "where", "which", "while",
396
+ "who", "why", "will", "with", "work", "working", "would", "you", "your",
397
+ "redacted"
398
+ ]);
399
+ const ACTION_WORDS = [
400
+ { action: "refining", words: new Set(["adjust", "change", "customize", "design", "edit", "improve", "refine", "revise", "update"]) },
401
+ { action: "fixing", words: new Set(["bug", "debug", "fix", "repair", "resolve"]) },
402
+ { action: "testing", words: new Set(["test", "testing", "validate", "verification", "verify"]) },
403
+ { action: "auditing", words: new Set(["audit", "review", "status"]) },
404
+ { action: "researching", words: new Set(["compare", "find", "investigate", "research"]) },
405
+ { action: "configuring", words: new Set(["configure", "connect", "setup"]) },
406
+ { action: "publishing", words: new Set(["deploy", "launch", "publish", "release"]) },
407
+ { action: "running", words: new Set(["automation", "monitor", "run", "schedule"]) },
408
+ { action: "building", words: new Set(["add", "build", "create", "develop", "implement", "include"]) }
409
+ ];
410
+ function buildLocalAgentActivity(input) {
411
+ // Prompt text is never retained, but derived topic tokens can still disclose
412
+ // a credential if redaction happens only at persistence/output boundaries.
413
+ // Sanitize before every title/topic/action derivation and again at output.
414
+ const prompts = input.prompts
415
+ .map(sanitizeLocalActivityText)
416
+ .filter(isHumanPrompt);
417
+ const topic = focusTopic(prompts);
418
+ const title = cleanTitle(sanitizeLocalActivityText(input.title ?? ""));
419
+ const files = [...input.files.entries()]
420
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
421
+ .map(([file]) => sanitizeLocalActivityText(file))
422
+ .filter(Boolean)
423
+ .slice(0, 5);
424
+ const action = inferAction(prompts, title);
425
+ let source;
426
+ let kind;
427
+ let subject;
428
+ if (topic) {
429
+ source = "user_prompts";
430
+ kind = inferActivityKind(topic, input.isSubagent);
431
+ subject = topic;
432
+ }
433
+ else if (title) {
434
+ source = "agent_title";
435
+ kind = inferActivityKind(title, input.isSubagent);
436
+ subject = stripLeadingAction(title);
437
+ }
438
+ else if (files[0]) {
439
+ source = "file_activity";
440
+ kind = "file";
441
+ subject = files[0];
442
+ }
443
+ else if (input.project) {
444
+ source = "project";
445
+ kind = "project";
446
+ subject = sanitizeLocalActivityText(input.project);
447
+ }
448
+ else {
449
+ return undefined;
450
+ }
451
+ if (!subject)
452
+ return undefined;
453
+ return {
454
+ summary: sanitizeLocalActivityText(activitySummary(action, subject, source)),
455
+ kind,
456
+ action,
457
+ source,
458
+ promptCount: prompts.length,
459
+ toolCallCount: input.toolCallCount,
460
+ files,
461
+ isSubagent: input.isSubagent,
462
+ parentSessionId: input.parentSessionId
463
+ };
464
+ }
465
+ function focusTopic(prompts) {
466
+ if (prompts.length === 0)
467
+ return undefined;
468
+ const recent = prompts.slice(-12);
469
+ const observedTopicTokens = new Set(recent.flatMap((prompt) => (topicTokens(prompt).filter((token) => !FOCUS_STOP_WORDS.has(token)))));
470
+ const tokenScores = new Map();
471
+ const pairScores = new Map();
472
+ recent.forEach((prompt, index) => {
473
+ const weight = 1 + index / Math.max(1, recent.length - 1);
474
+ const tokens = topicTokens(prompt).filter((token) => !FOCUS_STOP_WORDS.has(token));
475
+ const unique = [...new Set(tokens)];
476
+ for (const token of unique) {
477
+ tokenScores.set(token, (tokenScores.get(token) ?? 0) + weight);
478
+ }
479
+ for (let pairIndex = 0; pairIndex < tokens.length - 1; pairIndex += 1) {
480
+ const pair = `${tokens[pairIndex]} ${tokens[pairIndex + 1]}`;
481
+ pairScores.set(pair, (pairScores.get(pair) ?? 0) + weight);
482
+ }
483
+ });
484
+ const topPairs = [...pairScores.entries()]
485
+ .filter(([, score]) => score >= 2.5)
486
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
487
+ const topTokens = [...tokenScores.entries()]
488
+ .filter(([token]) => !isActionToken(token))
489
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
490
+ const candidateTokens = new Set();
491
+ for (const [pair] of topPairs.slice(0, 2)) {
492
+ pair.split(" ").forEach((token) => candidateTokens.add(token));
493
+ }
494
+ for (const [token] of topTokens) {
495
+ if (candidateTokens.size >= 3)
496
+ break;
497
+ candidateTokens.add(token);
498
+ }
499
+ if (candidateTokens.size === 0)
500
+ return undefined;
501
+ const tokens = [...candidateTokens].slice(0, 3);
502
+ if (tokens.includes("glance") && tokens.includes("hover")) {
503
+ return tokens.includes("ui") ? "Glance hover UI" : "Glance hover";
504
+ }
505
+ if (observedTopicTokens.has("glance") &&
506
+ ["action", "agent", "handoff", "prompt"].some((token) => observedTopicTokens.has(token))) {
507
+ return "Glance agent handoff";
508
+ }
509
+ if (tokens.includes("hover")) {
510
+ return tokens.includes("ui") ? "hover UI" : "hover interaction";
511
+ }
512
+ if (tokens.includes("landing") && tokens.includes("page"))
513
+ return "landing page";
514
+ if (tokens.includes("mcp"))
515
+ return tokens.includes("feature") ? "MCP feature" : "MCP";
516
+ if (tokens.includes("seo"))
517
+ return tokens.includes("strategy") ? "SEO strategy" : "SEO";
518
+ return tokens.map(displayToken).join(" ");
519
+ }
520
+ function inferAction(prompts, title) {
521
+ const scores = new Map();
522
+ const recent = prompts.slice(-12);
523
+ recent.forEach((prompt, index) => {
524
+ const weight = 1 + index / Math.max(1, recent.length - 1);
525
+ for (const token of promptTokens(prompt)) {
526
+ for (const group of ACTION_WORDS) {
527
+ if (group.words.has(token)) {
528
+ scores.set(group.action, (scores.get(group.action) ?? 0) + weight);
529
+ }
530
+ }
531
+ }
532
+ });
533
+ if (title) {
534
+ for (const token of promptTokens(title)) {
535
+ for (const group of ACTION_WORDS) {
536
+ if (group.words.has(token)) {
537
+ scores.set(group.action, (scores.get(group.action) ?? 0) + 0.75);
538
+ }
539
+ }
540
+ }
541
+ }
542
+ return [...scores.entries()]
543
+ .sort((left, right) => right[1] - left[1])[0]?.[0] ?? "working";
544
+ }
545
+ function inferActivityKind(subject, isSubagent) {
546
+ const tokens = new Set(promptTokens(subject));
547
+ if (tokens.has("automation") || tokens.has("workflow") || tokens.has("schedule"))
548
+ return "automation";
549
+ if (tokens.has("agent") || tokens.has("subagent") || isSubagent)
550
+ return "agent";
551
+ return "task";
552
+ }
553
+ function activitySummary(action, subject, source) {
554
+ if (source === "project")
555
+ return `Working in ${subject}`;
556
+ const actionLabel = action[0].toUpperCase() + action.slice(1);
557
+ return `${actionLabel} ${subject}`.replace(/\s+/g, " ").trim();
558
+ }
559
+ function cleanTitle(value) {
560
+ if (!value)
561
+ return undefined;
562
+ const clean = value.replace(/\s+/g, " ").trim().replace(/[.!?]+$/, "");
563
+ return clean.length >= 3 && clean.length <= 96 ? clean : undefined;
564
+ }
565
+ function stripLeadingAction(value) {
566
+ const words = value.split(/\s+/);
567
+ return isActionToken(words[0]?.toLowerCase() ?? "") && words.length > 1
568
+ ? words.slice(1).join(" ")
569
+ : value;
570
+ }
571
+ function isActionToken(token) {
572
+ return ACTION_WORDS.some((group) => group.words.has(token)) ||
573
+ ["building", "refining", "fixing", "testing", "auditing", "researching", "configuring", "publishing", "running"].includes(token);
574
+ }
575
+ function displayToken(token) {
576
+ const acronym = new Map([
577
+ ["api", "API"],
578
+ ["cli", "CLI"],
579
+ ["mcp", "MCP"],
580
+ ["seo", "SEO"],
581
+ ["ui", "UI"],
582
+ ["ux", "UX"]
583
+ ]).get(token);
584
+ return acronym ?? token;
585
+ }
586
+ function promptTokens(value) {
587
+ return (value.toLowerCase().match(/[a-z][a-z0-9+#.-]*/g) ?? [])
588
+ .map((token) => token.replace(/^[.-]+|[.-]+$/g, ""))
589
+ .filter((token) => token.length >= 2 && token !== "cz" && !/^\d+$/.test(token));
590
+ }
591
+ function topicTokens(value) {
592
+ // Absolute paths often appear in attached-image metadata and tool-oriented
593
+ // prompts. They are machine context, not the user's work topic, and can
594
+ // otherwise outrank meaningful words when only one recent prompt exists.
595
+ const withoutAbsolutePaths = value.replace(/(^|[\s("'=:])(?:file:\/\/)?\/[^\s)"']+/g, "$1");
596
+ return promptTokens(sanitizeLocalActivityText(withoutAbsolutePaths));
597
+ }
598
+ /**
599
+ * Remove known and assignment-shaped credentials from metadata before it can
600
+ * become a topic, title, Glance field, MCP result, or copy-ready handoff.
601
+ * This intentionally favors dropping a suspicious token over displaying it.
602
+ */
603
+ export function sanitizeLocalActivityText(value) {
604
+ return redactSecrets(value)
605
+ .replace(/\b(?:api[_ -]?key|access[_ -]?token|auth[_ -]?token|secret|password|credential)\s*[:=]\s*[^\s,;]+/gi, " ")
606
+ .replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, " ")
607
+ .replace(/\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|AUTH)=\[REDACTED\]/g, " ")
608
+ .replace(/\[REDACTED\]/g, " ")
609
+ .replace(/\s+/g, " ")
610
+ .trim();
611
+ }
612
+ function isHumanPrompt(value) {
613
+ const text = value.trim();
614
+ if (text.length < 3)
615
+ return false;
616
+ return ![
617
+ /^</,
618
+ /^>>>/,
619
+ /^\s*\[\d+\]\s+(tool|assistant|user)\b/i,
620
+ /^#\s*AGENTS\.md/i,
621
+ /^Assess the exact/i,
622
+ /^Planned action JSON/i,
623
+ /^Reviewed Codex/i,
624
+ /^Some conversation/i,
625
+ /^The Codex agent/i,
626
+ /^The following is the Codex/i,
627
+ /^You are .*primary agent/i,
628
+ /^You have \d+ weighted tokens left/i
629
+ ].some((pattern) => pattern.test(text));
630
+ }
631
+ function textValues(value) {
632
+ if (typeof value === "string")
633
+ return [value];
634
+ if (!Array.isArray(value))
635
+ return [];
636
+ return value.flatMap((item) => {
637
+ if (!isRecord(item))
638
+ return [];
639
+ return [stringOf(item.text) ?? stringOf(item.content)].filter((text) => Boolean(text));
640
+ });
641
+ }
642
+ function recordValues(value) {
643
+ return Array.isArray(value) ? value.filter(isRecord) : [];
644
+ }
645
+ function collectToolFiles(value, counts) {
646
+ if (!isRecord(value))
647
+ return;
648
+ for (const key of ["file_path", "path", "notebook_path"]) {
649
+ addFile(stringOf(value[key]), counts);
650
+ }
651
+ collectPatchFiles(stringOf(value.patch) ?? stringOf(value.input), counts);
652
+ }
653
+ function collectPatchFiles(value, counts) {
654
+ if (!value)
655
+ return;
656
+ for (const match of value.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)) {
657
+ addFile(match[1], counts);
658
+ }
659
+ }
660
+ function addFile(value, counts) {
661
+ if (!value)
662
+ return;
663
+ const file = basename(value.trim());
664
+ if (!file || file === "." || file === sep)
665
+ return;
666
+ counts.set(file, (counts.get(file) ?? 0) + 1);
667
+ }
226
668
  function toIso(value) {
227
669
  if (!value)
228
670
  return undefined;
@@ -241,6 +683,17 @@ function stringOf(value) {
241
683
  function isRecord(value) {
242
684
  return typeof value === "object" && value !== null;
243
685
  }
686
+ function jsonRecord(value) {
687
+ if (!value)
688
+ return undefined;
689
+ try {
690
+ const parsed = JSON.parse(value);
691
+ return isRecord(parsed) ? parsed : undefined;
692
+ }
693
+ catch {
694
+ return undefined;
695
+ }
696
+ }
244
697
  function slug(value) {
245
698
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "x";
246
699
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Published per-token API prices (mid-2026) used to estimate the
2
+ * Published per-token API prices used to estimate the
3
3
  * API-equivalent dollar value of locally observed usage (e.g. Claude Code /
4
4
  * Codex session logs, where the provider never reports a price).
5
5
  *
@@ -7,6 +7,7 @@
7
7
  * matched top-down; first match wins. Unknown models return undefined so
8
8
  * callers can label the record "missing" instead of inventing a number.
9
9
  */
10
+ export declare const PRICING_TABLE_AS_OF = "2026-07-28";
10
11
  export type TokenUsage = {
11
12
  /** Billable, uncached input tokens. */
12
13
  inputTokens: number;
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Published per-token API prices used to estimate the
3
+ * API-equivalent dollar value of locally observed usage (e.g. Claude Code /
4
+ * Codex session logs, where the provider never reports a price).
5
+ *
6
+ * Estimates only — always surfaced with costConfidence "estimated". Rules are
7
+ * matched top-down; first match wins. Unknown models return undefined so
8
+ * callers can label the record "missing" instead of inventing a number.
9
+ */
10
+ export const PRICING_TABLE_AS_OF = "2026-07-28";
1
11
  const pricingRules = [
2
12
  // Anthropic
3
13
  { match: /^claude-fable-5/i, inputPerM: 10, outputPerM: 50 },
@@ -7,9 +17,18 @@ const pricingRules = [
7
17
  { match: /^claude-haiku-4/i, inputPerM: 1, outputPerM: 5 },
8
18
  { match: /^claude-3-7-sonnet|^claude-3-5-sonnet/i, inputPerM: 3, outputPerM: 15 },
9
19
  { match: /^claude-3-5-haiku/i, inputPerM: 0.8, outputPerM: 4 },
10
- // OpenAI (codex CLI models first — more specific)
11
- { match: /^gpt-5(\.\d+)?-codex/i, inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 },
12
- { match: /^gpt-5(\.\d+)?-mini/i, inputPerM: 0.25, outputPerM: 2, cacheReadPerM: 0.025 },
20
+ // OpenAI (newer and more specific families must precede the GPT-5 fallback)
21
+ { match: /^gpt-5\.6(?:-sol)?$/i, inputPerM: 5, outputPerM: 30, cacheReadPerM: 0.5 },
22
+ { match: /^gpt-5\.6-terra/i, inputPerM: 2.5, outputPerM: 15, cacheReadPerM: 0.25 },
23
+ { match: /^gpt-5\.6-luna/i, inputPerM: 1, outputPerM: 6, cacheReadPerM: 0.1 },
24
+ { match: /^gpt-5\.5(?:-codex)?/i, inputPerM: 5, outputPerM: 30, cacheReadPerM: 0.5 },
25
+ { match: /^gpt-5\.4-mini/i, inputPerM: 0.75, outputPerM: 4.5, cacheReadPerM: 0.075 },
26
+ { match: /^gpt-5\.4-nano/i, inputPerM: 0.2, outputPerM: 1.25, cacheReadPerM: 0.02 },
27
+ { match: /^gpt-5\.4/i, inputPerM: 2.5, outputPerM: 15, cacheReadPerM: 0.25 },
28
+ { match: /^gpt-5\.3-codex/i, inputPerM: 1.75, outputPerM: 14, cacheReadPerM: 0.175 },
29
+ { match: /^gpt-5\.2(?:-codex)?/i, inputPerM: 1.75, outputPerM: 14, cacheReadPerM: 0.175 },
30
+ { match: /^gpt-5(?:\.1)?-codex/i, inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 },
31
+ { match: /^gpt-5(?:\.1)?-mini/i, inputPerM: 0.25, outputPerM: 2, cacheReadPerM: 0.025 },
13
32
  { match: /^gpt-5/i, inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 },
14
33
  { match: /^gpt-4\.1-nano/i, inputPerM: 0.1, outputPerM: 0.4 },
15
34
  { match: /^gpt-4\.1-mini/i, inputPerM: 0.4, outputPerM: 1.6 },
@@ -2,9 +2,9 @@ import type { DetectedPlan } from "./planDetection.js";
2
2
  import type { UsageRecord } from "./schema.js";
3
3
  /**
4
4
  * Plan-price math: compares API-equivalent usage (from local agent logs)
5
- * against published subscription plan prices — the arbitrage check no
6
- * provider will ever show, because it's the math that tells you to pay
7
- * them less.
5
+ * against published subscription plan prices. This is comparison context,
6
+ * not proof of entitlement, marginal cash cost, remaining capacity, or the
7
+ * cheapest option for a particular account.
8
8
  *
9
9
  * Prices are mid-2026 list prices. As of 2026-06-15, programmatic/Agent-SDK
10
10
  * usage on Claude plans is metered against a separate monthly credit pool at
@@ -17,7 +17,7 @@ export type SubscriptionPlan = {
17
17
  agent: "claude-code" | "codex";
18
18
  name: string;
19
19
  monthlyUsd: number;
20
- /** Rough API-equivalent monthly usage this plan comfortably covers. */
20
+ /** Rough API-equivalent comparison threshold; not an entitlement limit. */
21
21
  coversUpToUsd: number;
22
22
  };
23
23
  export declare const subscriptionPlans: SubscriptionPlan[];
@@ -27,19 +27,18 @@ export type PlanCheck = {
27
27
  apiEquivalentMonthlyUsd: number;
28
28
  /** Distinct days of observed usage the projection is based on. */
29
29
  windowDays: number;
30
- /** Cheapest plan that comfortably covers the projected usage (if any). */
30
+ /** Reference plan selected by a rough API-equivalent comparison (if any). */
31
31
  suggestedPlan?: SubscriptionPlan;
32
32
  /** apiEquivalentMonthlyUsd - plan price, when positive. */
33
33
  monthlySavingsVsApiUsd?: number;
34
34
  /**
35
- * API-equivalent usage ÷ plan price — "you're getting N× the plan price in
36
- * usage". The number a subscription user actually wants: am I getting my
37
- * money's worth? Present only when a plan covers the usage.
35
+ * API-equivalent usage ÷ plan price. This is a value comparison, not an
36
+ * account entitlement or ROI measurement.
38
37
  */
39
38
  valueMultiple?: number;
40
- /** The plan actually detected on this machine (or --plan override), if any. */
39
+ /** Plan label detected in local metadata (or supplied via --plan), if any. */
41
40
  detectedPlan?: DetectedPlan;
42
- /** Set when projected usage exceeds what the detected tier typically covers. */
41
+ /** Set when projection exceeds a rough comparison threshold or a limit signal exists. */
43
42
  upgradeHint?: string;
44
43
  /** One-line, render-ready verdict. */
45
44
  headline: string;
@@ -49,10 +48,9 @@ export type PlanCheck = {
49
48
  * from local agent logs participate (billing-API records already have real
50
49
  * prices and a real plan behind them).
51
50
  *
52
- * When `detectedPlans` carries a locally detected plan (or --plan override)
53
- * for an agent, the check speaks in facts ("you're on Claude Max 5x") instead
54
- * of guesses ("Max 20x likely covers this") — and warns when projected usage
55
- * exceeds what the detected tier typically covers.
51
+ * When `detectedPlans` carries a locally detected plan label (or --plan
52
+ * override), the result identifies that provenance and keeps comparison math
53
+ * separate from provider-reported limits.
56
54
  */
57
55
  export declare function computePlanChecks(records: UsageRecord[], detectedPlans?: DetectedPlan[]): PlanCheck[];
58
56
  //# sourceMappingURL=planMath.d.ts.map