@agent-finops/core 0.1.0 → 0.1.3

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,4 +1,5 @@
1
1
  import { createProviderConnectorStub, slugifySourceId } from "./sourceRegistry.js";
2
+ import { redactSecrets } from "./discovery.js";
2
3
  export function normalizeOpenAiCostResponse(response, options) {
3
4
  const data = isObject(response) && Array.isArray(response.data) ? response.data : [];
4
5
  const records = [];
@@ -257,12 +258,15 @@ export function normalizeCursorSpendResponse(response, options) {
257
258
  return [{
258
259
  id: slugifySourceId(["cursor-spend", options.accountId, userId].filter(Boolean).join("-")),
259
260
  timestamp,
260
- source: { id: options.sourceId, name: "Cursor Admin API", provider: "cursor", confidence: "verified", observedFrom: options.observedFrom },
261
+ // The Cursor connector is spec-built and not yet live-verified (beta),
262
+ // so its dollars are labeled estimated until reconciled against a real
263
+ // team's invoice. Never stamp "verified" on data we haven't verified.
264
+ source: { id: options.sourceId, name: "Cursor Admin API", provider: "cursor", confidence: "estimated", observedFrom: options.observedFrom },
261
265
  model: "cursor-team-usage",
262
266
  inputTokens: 0,
263
267
  outputTokens: 0,
264
268
  amountUsd: cents / 100,
265
- costConfidence: "verified",
269
+ costConfidence: "estimated",
266
270
  userId,
267
271
  projectId: options.accountId,
268
272
  providerCostType: "cursor_spend",
@@ -299,7 +303,7 @@ async function fetchOpenAi(input, token, fetcher, sourceId) {
299
303
  ...costFetch.pages.flatMap((page) => normalizeOpenAiCostResponse(page, { sourceId, observedFrom: "OpenAI organization costs API" })),
300
304
  ...usageFetch.pages.flatMap((page) => normalizeOpenAiUsageResponse(page, { sourceId, observedFrom: "OpenAI organization usage API" }))
301
305
  ];
302
- return providerResult("openai", sourceId, input.authReference, records, "verified", qaSummary("openai", [costFetch, usageFetch]));
306
+ return providerResult("openai", sourceId, input.authReference, records, qaSummary("openai", [costFetch, usageFetch]));
303
307
  }
304
308
  async function fetchAnthropic(input, token, fetcher, sourceId) {
305
309
  const costRequest = {
@@ -312,7 +316,7 @@ async function fetchAnthropic(input, token, fetcher, sourceId) {
312
316
  ...costFetch.pages.flatMap((page) => normalizeAnthropicCostResponse(page, { sourceId, observedFrom: "Anthropic Admin Cost Report" })),
313
317
  ...claudeCodeFetches.flatMap((fetchResult) => fetchResult.pages.flatMap((page) => normalizeAnthropicClaudeCodeUsageResponse(page, { sourceId, observedFrom: "Anthropic Claude Code Usage Report", accountId: input.accountId })))
314
318
  ];
315
- return providerResult("anthropic", sourceId, input.authReference, records, "verified", qaSummary("anthropic", [costFetch, ...claudeCodeFetches]));
319
+ return providerResult("anthropic", sourceId, input.authReference, records, qaSummary("anthropic", [costFetch, ...claudeCodeFetches]));
316
320
  }
317
321
  async function fetchGitHubCopilot(input, token, fetcher, sourceId) {
318
322
  const accountId = input.org ?? input.enterprise;
@@ -326,7 +330,7 @@ async function fetchGitHubCopilot(input, token, fetcher, sourceId) {
326
330
  const seatFetch = input.org ? await fetchPaginatedJson(fetcher, buildGitHubCopilotSeatsUrl(input.org), request, "github-copilot", "GitHub Copilot seats") : undefined;
327
331
  const metricsRecords = metricsFetch.pages.flatMap((page) => normalizeGitHubCopilotMetricsResponse(page, { sourceId, observedFrom: "GitHub Copilot metrics API", accountId }));
328
332
  const seatRecords = seatFetch ? seatFetch.pages.flatMap((page) => normalizeGitHubCopilotSeatResponse(page, { sourceId, observedFrom: "GitHub Copilot billing seats API", accountId })) : [];
329
- return providerResult("github-copilot", sourceId, input.authReference, [...metricsRecords, ...seatRecords], "verified", qaSummary("github-copilot", [metricsFetch, ...(seatFetch ? [seatFetch] : [])]));
333
+ return providerResult("github-copilot", sourceId, input.authReference, [...metricsRecords, ...seatRecords], qaSummary("github-copilot", [metricsFetch, ...(seatFetch ? [seatFetch] : [])]));
330
334
  }
331
335
  async function fetchCursor(input, token, fetcher, sourceId) {
332
336
  const accountId = input.accountId ?? input.org ?? "cursor-team";
@@ -337,13 +341,21 @@ async function fetchCursor(input, token, fetcher, sourceId) {
337
341
  }, "cursor", "Cursor Admin API spend");
338
342
  const page = response.payload;
339
343
  const records = normalizeCursorSpendResponse(page, { sourceId, observedFrom: "Cursor Admin API", accountId });
344
+ // The Cursor connector is matched to the published spec but not live-verified.
345
+ // If the API answered with content but no spend fields we recognize, say so
346
+ // loudly rather than silently report $0 (which reads as "you spent nothing").
347
+ if (records.length === 0 && isRecord(page) && Object.keys(page).length > 0) {
348
+ throw new Error("Cursor returned data but no spend fields this connector recognizes " +
349
+ `(saw: ${Object.keys(page).slice(0, 8).join(", ")}). The Cursor connector is beta — ` +
350
+ "please open an issue with this field list so we can map it: https://github.com/futurastudio/ai-spend-agent/issues");
351
+ }
340
352
  const singleFetch = {
341
353
  pages: [page],
342
354
  pagination: { label: "Cursor Admin API spend", pagesFetched: 1, stoppedBecause: "complete", maxPages: 1 },
343
355
  rateLimits: response.rateLimit ? [response.rateLimit] : [],
344
356
  responseDrift: detectResponseDrift(page, "cursor", "Cursor Admin API spend")
345
357
  };
346
- return providerResult("cursor", sourceId, input.authReference, records, "verified", qaSummary("cursor", [singleFetch]));
358
+ return providerResult("cursor", sourceId, input.authReference, records, qaSummary("cursor", [singleFetch]));
347
359
  }
348
360
  async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label) {
349
361
  const pages = [];
@@ -351,9 +363,24 @@ async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label)
351
363
  const responseDrift = [];
352
364
  let nextUrl = initialUrl;
353
365
  let stoppedBecause = "complete";
366
+ let note;
354
367
  const maxPages = 50;
355
368
  for (let pageCount = 0; nextUrl && pageCount < maxPages; pageCount += 1) {
356
- const response = await fetchJsonOrThrow(fetcher, nextUrl, request, provider, label);
369
+ let response;
370
+ try {
371
+ response = await fetchJsonOrThrow(fetcher, nextUrl, request, provider, label);
372
+ }
373
+ catch (error) {
374
+ // A mid-pagination failure (after retries) must not discard the pages
375
+ // already fetched — return partial results with an explicit QA note.
376
+ // A failure on the FIRST page (auth, bad scope) still throws.
377
+ if (pages.length === 0)
378
+ throw error;
379
+ stoppedBecause = "fetch_error";
380
+ note = `Stopped after ${pages.length} page(s): ${error instanceof Error ? error.message : String(error)}`;
381
+ nextUrl = undefined;
382
+ break;
383
+ }
357
384
  const page = response.payload;
358
385
  pages.push(page);
359
386
  if (response.rateLimit)
@@ -380,7 +407,7 @@ async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label)
380
407
  }
381
408
  return {
382
409
  pages,
383
- pagination: { label, pagesFetched: pages.length, stoppedBecause, maxPages, limitPerPage: limitPerPageFromUrl(initialUrl) },
410
+ pagination: { label, pagesFetched: pages.length, stoppedBecause, maxPages, limitPerPage: limitPerPageFromUrl(initialUrl), ...(note ? { note } : {}) },
384
411
  rateLimits,
385
412
  responseDrift
386
413
  };
@@ -390,17 +417,60 @@ async function fetchDateRangeJson(fetcher, buildUrl, startTime, endTime, request
390
417
  const daySeconds = 24 * 60 * 60;
391
418
  const finalTime = endTime ?? startTime;
392
419
  for (let cursor = startTime, count = 0; cursor <= finalTime && count < 370; cursor += daySeconds, count += 1) {
393
- results.push(await fetchPaginatedJson(fetcher, buildUrl(cursor), request, provider, label));
420
+ try {
421
+ results.push(await fetchPaginatedJson(fetcher, buildUrl(cursor), request, provider, label));
422
+ }
423
+ catch (error) {
424
+ // Persistent failure mid-range: keep the days already fetched and note
425
+ // where the sync stopped instead of discarding everything. First-day
426
+ // failures (bad auth/scope) still throw so the user sees the real error.
427
+ if (results.length === 0)
428
+ throw error;
429
+ results.push({
430
+ pages: [],
431
+ pagination: {
432
+ label,
433
+ pagesFetched: 0,
434
+ stoppedBecause: "fetch_error",
435
+ maxPages: 50,
436
+ note: `Day range stopped early after ${results.length} day(s): ${error instanceof Error ? error.message : String(error)}`
437
+ },
438
+ rateLimits: [],
439
+ responseDrift: []
440
+ });
441
+ break;
442
+ }
394
443
  }
395
444
  return results;
396
445
  }
446
+ /** Retries per request on 429/5xx before giving up (initial try + retries). */
447
+ const maxFetchRetries = 2;
448
+ /** Cap on how long a retry-after header can make us wait, per attempt. */
449
+ const maxRetryDelayMs = 30_000;
397
450
  async function fetchJsonOrThrow(fetcher, url, request, provider, label) {
398
- const response = await fetcher(url, request);
399
- const payload = await response.json().catch(() => undefined);
400
- if (!response.ok) {
401
- throw new Error(providerPermissionPrompt(provider, label, response, payload));
451
+ let lastError;
452
+ for (let attempt = 0; attempt <= maxFetchRetries; attempt += 1) {
453
+ const response = await fetcher(url, request);
454
+ const payload = await response.json().catch(() => undefined);
455
+ if (response.ok) {
456
+ return { payload, rateLimit: rateLimitFromHeaders(label, response.headers), headers: response.headers };
457
+ }
458
+ lastError = new Error(providerPermissionPrompt(provider, label, response, payload));
459
+ // 429 and 5xx are transient: honor retry-after when present, otherwise
460
+ // back off briefly and try again. 4xx auth/scope errors fail immediately.
461
+ const retryable = response.status === 429 || response.status >= 500;
462
+ if (!retryable || attempt === maxFetchRetries) {
463
+ break;
464
+ }
465
+ const retryAfterSeconds = headerNumber(response.headers, "retry-after");
466
+ const delayMs = typeof retryAfterSeconds === "number"
467
+ ? Math.min(Math.max(retryAfterSeconds, 0) * 1000, maxRetryDelayMs)
468
+ : 500 * 2 ** attempt;
469
+ if (delayMs > 0) {
470
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs));
471
+ }
402
472
  }
403
- return { payload, rateLimit: rateLimitFromHeaders(label, response.headers), headers: response.headers };
473
+ throw lastError ?? new Error(`${label} request failed.`);
404
474
  }
405
475
  function nextPageFromPayload(payload) {
406
476
  if (!isRecord(payload))
@@ -479,13 +549,31 @@ function walkProviderFields(value, path, visit) {
479
549
  }
480
550
  }
481
551
  function knownProviderFields(provider, label) {
482
- const common = ["data", "data[]", "has_more", "hasMore", "next_page", "nextPage"];
552
+ // Every provider MUST enumerate the fields its normalizer consumes; a
553
+ // fall-through to `common` alone flags every legitimate field of every page
554
+ // as drift, burying real drift signals in thousands of false positives.
555
+ const common = ["data", "data[]", "has_more", "hasMore", "next_page", "nextPage", "object", "links", "links.next"];
483
556
  if (provider === "openai" && label.includes("costs")) {
484
557
  return new Set([...common, "data[].object", "data[].start_time", "data[].end_time", "data[].results", "data[].results[]", "data[].results[].object", "data[].results[].amount", "data[].results[].amount.value", "data[].results[].amount.currency", "data[].results[].line_item", "data[].results[].project_id", "data[].results[].api_key_id", "data[].results[].quantity"]);
485
558
  }
486
559
  if (provider === "openai" && label.includes("usage")) {
487
560
  return new Set([...common, "data[].object", "data[].start_time", "data[].end_time", "data[].results", "data[].results[]", "data[].results[].object", "data[].results[].input_tokens", "data[].results[].output_tokens", "data[].results[].input_cached_tokens", "data[].results[].input_audio_tokens", "data[].results[].output_audio_tokens", "data[].results[].num_model_requests", "data[].results[].project_id", "data[].results[].user_id", "data[].results[].api_key_id", "data[].results[].model"]);
488
561
  }
562
+ if (provider === "anthropic" && label.toLowerCase().includes("cost")) {
563
+ return new Set([...common, "data[].starting_at", "data[].ending_at", "data[].results", "data[].results[]", "data[].results[].amount", "data[].results[].currency", "data[].results[].cost_type", "data[].results[].description", "data[].results[].model", "data[].results[].workspace_id", "data[].results[].token_type", "data[].results[].service_tier", "data[].results[].context_window"]);
564
+ }
565
+ if (provider === "anthropic" && label.toLowerCase().includes("claude code")) {
566
+ return new Set([...common, "data[].date", "data[].actor", "data[].actor.email_address", "data[].actor.api_key_name", "data[].actor.id", "data[].actor.type", "data[].organization_id", "data[].customer_type", "data[].terminal_type", "data[].subscription_type", "data[].core_metrics", "data[].core_metrics.num_sessions", "data[].core_metrics.lines_of_code", "data[].core_metrics.lines_of_code.added", "data[].core_metrics.lines_of_code.removed", "data[].core_metrics.commits_by_claude_code", "data[].core_metrics.pull_requests_by_claude_code", "data[].model_breakdown", "data[].model_breakdown[]", "data[].model_breakdown[].model", "data[].model_breakdown[].tokens", "data[].model_breakdown[].tokens.input", "data[].model_breakdown[].tokens.output", "data[].model_breakdown[].tokens.cache_read", "data[].model_breakdown[].tokens.cache_creation", "data[].model_breakdown[].estimated_cost", "data[].model_breakdown[].estimated_cost.currency", "data[].model_breakdown[].estimated_cost.amount", "data[].tool_actions", "data[].tool_actions[]"]);
567
+ }
568
+ if (provider === "github-copilot" && label.toLowerCase().includes("metrics")) {
569
+ return new Set([...common, "day_totals", "day_totals[]", "day_totals[].day", "day_totals[].daily_active_users", "day_totals[].totals_by_model_feature", "day_totals[].totals_by_model_feature[]", "day_totals[].totals_by_model_feature[].model", "day_totals[].totals_by_model_feature[].feature", "day_totals[].totals_by_model_feature[].engaged_users", "day_totals[].totals_by_model_feature[].total_requests", "day_totals[].totals_by_model_feature[].user_initiated_interaction_count", "day_totals[].totals_by_cli", "day_totals[].totals_by_cli.request_count", "day_totals[].totals_by_cli.token_usage", "day_totals[].totals_by_cli.token_usage.prompt_tokens_sum", "day_totals[].totals_by_cli.token_usage.output_tokens_sum", "day_totals[].totals_by_cli.engaged_users", "day_totals[].totals_by_cli.total_requests", "report_start_day", "report_end_day", "generated_at"]);
570
+ }
571
+ if (provider === "github-copilot" && label.toLowerCase().includes("seats")) {
572
+ return new Set([...common, "total_seats", "plan_type", "seats", "seats[]", "seats[].created_at", "seats[].updated_at", "seats[].pending_cancellation_date", "seats[].last_activity_at", "seats[].last_activity_editor", "seats[].plan_type", "seats[].login", "seats[].id", "seats[].assignee", "seats[].assignee.login", "seats[].assignee.email", "seats[].assignee.id", "seats[].assignee.node_id", "seats[].assignee.avatar_url", "seats[].assignee.html_url", "seats[].assignee.type", "seats[].assignee.site_admin", "seats[].assigning_team", "seats[].organization"]);
573
+ }
574
+ if (provider === "cursor") {
575
+ return new Set([...common, "users", "users[]", "users[].email", "users[].emailAddress", "users[].userId", "users[].id", "users[].name", "users[].role", "users[].spendCents", "users[].usageBasedCents", "users[].chargedCents", "users[].fastPremiumRequests", "users[].hardLimitOverrideDollars", "data[].email", "data[].emailAddress", "data[].userId", "data[].id", "data[].name", "data[].role", "data[].spendCents", "data[].usageBasedCents", "data[].chargedCents", "subscriptionCycleStart", "totalMembers", "totalPages"]);
576
+ }
489
577
  return new Set([...common]);
490
578
  }
491
579
  function qaSummary(provider, fetches) {
@@ -551,29 +639,48 @@ function extractProviderMessage(payload) {
551
639
  return stringValue(error?.message) ?? stringValue(payload.message) ?? "";
552
640
  }
553
641
  function sanitizeProviderMessage(message) {
554
- return message.replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]").replace(/gh[pousr]_[A-Za-z0-9_]+/g, "[REDACTED]");
642
+ // One redaction implementation for the whole product (discovery.ts owns it).
643
+ return redactSecrets(message).replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]").replace(/gh[pousr]_[A-Za-z0-9_]+/g, "[REDACTED]");
644
+ }
645
+ /**
646
+ * Result-level completeness is DERIVED from record-level confidence, never
647
+ * hardcoded: the label on the whole pull is the weakest confidence among the
648
+ * records that actually carry dollars (worst-wins, matching analyze.ts).
649
+ * Copilot seats and Cursor spend are estimated, so their results say so.
650
+ */
651
+ function completenessFromRecords(records) {
652
+ const rank = { verified: 0, estimated: 1, detected_unverified: 2, missing: 3 };
653
+ const costBearing = records.filter((record) => typeof record.amountUsd === "number");
654
+ if (costBearing.length === 0) {
655
+ return "missing";
656
+ }
657
+ return costBearing
658
+ .map((record) => record.costConfidence)
659
+ .reduce((worst, current) => (rank[current] > rank[worst] ? current : worst));
555
660
  }
556
- function providerResult(provider, sourceId, authReference, records, fallbackCompleteness, qa) {
661
+ function providerResult(provider, sourceId, authReference, records, qa) {
557
662
  const totalUsd = records.reduce((sum, record) => sum + (record.amountUsd ?? 0), 0);
663
+ const completeness = completenessFromRecords(records);
558
664
  return {
559
665
  provider,
560
- source: createProviderConnection({ provider, sourceId, authReference, verifiedRecordCount: records.length, totalUsd }),
666
+ source: createProviderConnection({ provider, sourceId, authReference, verifiedRecordCount: records.length, totalUsd, completeness }),
561
667
  records,
562
668
  fetchedAt: new Date().toISOString(),
563
- completeness: records.length > 0 ? fallbackCompleteness : "missing",
669
+ completeness,
564
670
  qa: qa ?? qaSummary(provider, [])
565
671
  };
566
672
  }
567
673
  export function createProviderConnection(input) {
568
674
  const source = createProviderConnectorStub(input.provider, "provider_api", input.fetchedAt);
569
675
  const total = `$${input.totalUsd.toFixed(2)}`;
676
+ const verification = input.completeness ?? "verified";
570
677
  return {
571
678
  ...source,
572
679
  id: input.sourceId ?? source.id,
573
- verification: "verified",
680
+ verification,
574
681
  authReference: input.authReference,
575
682
  fieldsMissing: [],
576
- scope: `${source.scope} Last successful pull produced ${input.verifiedRecordCount} verified records totaling ${total}.`
683
+ scope: `${source.scope} Last successful pull produced ${input.verifiedRecordCount} ${verification} records totaling ${total}.`
577
684
  };
578
685
  }
579
686
  export function resolveTokenReference(reference, env = process.env) {
@@ -0,0 +1,8 @@
1
+ export declare class UnsafeScanRootError extends Error {
2
+ readonly rootPath: string;
3
+ constructor(rootPath: string, reason: string);
4
+ }
5
+ export declare function unsafeScanRootReason(rootPath: string, home?: string): string | undefined;
6
+ /** Throws a typed error when the root is unsafe; callers map it to their UX. */
7
+ export declare function assertSafeScanRoot(rootPath: string, home?: string): void;
8
+ //# sourceMappingURL=scanGuard.d.ts.map
@@ -0,0 +1,65 @@
1
+ import { homedir } from "node:os";
2
+ import { resolve, sep } from "node:path";
3
+ /**
4
+ * Shared unsafe-scan-root policy for EVERY scan entrypoint (CLI `scan`, MCP
5
+ * `scan_ai_spend`, and any future surface). Scanning the home directory, the
6
+ * filesystem root, or a system directory is refused: the product's consent
7
+ * model is "one explicitly approved project folder", and anything broader can
8
+ * pull unrelated personal files into evidence output.
9
+ *
10
+ * Keep this the ONLY implementation — a CLI/MCP divergence here is exactly the
11
+ * class of bug that let MCP scan `~` while the CLI refused.
12
+ */
13
+ const systemRootDirectories = new Set([
14
+ "/etc",
15
+ "/usr",
16
+ "/bin",
17
+ "/sbin",
18
+ "/var",
19
+ "/opt",
20
+ "/private",
21
+ "/Library",
22
+ "/System",
23
+ "/Applications",
24
+ "/Volumes",
25
+ "/proc",
26
+ "/sys",
27
+ "/dev"
28
+ ]);
29
+ export class UnsafeScanRootError extends Error {
30
+ rootPath;
31
+ constructor(rootPath, reason) {
32
+ super(`Refusing to scan ${rootPath}: ${reason}. Choose a narrower approved folder.`);
33
+ this.name = "UnsafeScanRootError";
34
+ this.rootPath = rootPath;
35
+ }
36
+ }
37
+ export function unsafeScanRootReason(rootPath, home = homedir()) {
38
+ const resolved = resolve(rootPath);
39
+ const resolvedHome = resolve(home);
40
+ if (resolved === "/" || /^[A-Za-z]:[\\/]?$/.test(resolved)) {
41
+ return "the filesystem root is too broad for approved-source scanning";
42
+ }
43
+ if (resolved === resolvedHome) {
44
+ return "the home directory is too broad for approved-source scanning";
45
+ }
46
+ if (isAncestorPath(resolved, resolvedHome)) {
47
+ return "this directory contains your home directory and is too broad for approved-source scanning";
48
+ }
49
+ if (systemRootDirectories.has(resolved)) {
50
+ return "system directories are not valid approved-source scan targets";
51
+ }
52
+ return undefined;
53
+ }
54
+ /** Throws a typed error when the root is unsafe; callers map it to their UX. */
55
+ export function assertSafeScanRoot(rootPath, home = homedir()) {
56
+ const reason = unsafeScanRootReason(rootPath, home);
57
+ if (reason) {
58
+ throw new UnsafeScanRootError(resolve(rootPath), reason);
59
+ }
60
+ }
61
+ function isAncestorPath(candidateAncestor, path) {
62
+ const normalizedAncestor = candidateAncestor.endsWith(sep) ? candidateAncestor : candidateAncestor + sep;
63
+ return path.startsWith(normalizedAncestor);
64
+ }
65
+ //# sourceMappingURL=scanGuard.js.map
package/dist/schema.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
2
  export declare const costConfidenceValues: readonly ["verified", "estimated", "detected_unverified", "missing"];
3
3
  export declare const costConfidenceSchema: z.ZodEnum<{
4
- verified: "verified";
5
4
  estimated: "estimated";
5
+ verified: "verified";
6
6
  detected_unverified: "detected_unverified";
7
7
  missing: "missing";
8
8
  }>;
@@ -12,8 +12,8 @@ export declare const spendSourceSchema: z.ZodObject<{
12
12
  name: z.ZodString;
13
13
  provider: z.ZodString;
14
14
  confidence: z.ZodEnum<{
15
- verified: "verified";
16
15
  estimated: "estimated";
16
+ verified: "verified";
17
17
  detected_unverified: "detected_unverified";
18
18
  missing: "missing";
19
19
  }>;
@@ -28,8 +28,8 @@ export declare const usageRecordSchema: z.ZodObject<{
28
28
  name: z.ZodString;
29
29
  provider: z.ZodString;
30
30
  confidence: z.ZodEnum<{
31
- verified: "verified";
32
31
  estimated: "estimated";
32
+ verified: "verified";
33
33
  detected_unverified: "detected_unverified";
34
34
  missing: "missing";
35
35
  }>;
@@ -40,8 +40,8 @@ export declare const usageRecordSchema: z.ZodObject<{
40
40
  outputTokens: z.ZodNumber;
41
41
  amountUsd: z.ZodNullable<z.ZodNumber>;
42
42
  costConfidence: z.ZodEnum<{
43
- verified: "verified";
44
43
  estimated: "estimated";
44
+ verified: "verified";
45
45
  detected_unverified: "detected_unverified";
46
46
  missing: "missing";
47
47
  }>;
@@ -58,10 +58,10 @@ export declare const usageRecordSchema: z.ZodObject<{
58
58
  export type UsageRecord = z.infer<typeof usageRecordSchema>;
59
59
  export declare const attributionCandidateSchema: z.ZodObject<{
60
60
  entityType: z.ZodEnum<{
61
- client: "client";
61
+ user: "user";
62
62
  project: "project";
63
+ client: "client";
63
64
  agent: "agent";
64
- user: "user";
65
65
  workspace: "workspace";
66
66
  api_key: "api_key";
67
67
  }>;
@@ -74,10 +74,10 @@ export declare const attributionMappingSchema: z.ZodObject<{
74
74
  usageRecordId: z.ZodString;
75
75
  candidates: z.ZodArray<z.ZodObject<{
76
76
  entityType: z.ZodEnum<{
77
- client: "client";
77
+ user: "user";
78
78
  project: "project";
79
+ client: "client";
79
80
  agent: "agent";
80
- user: "user";
81
81
  workspace: "workspace";
82
82
  api_key: "api_key";
83
83
  }>;
@@ -87,10 +87,10 @@ export declare const attributionMappingSchema: z.ZodObject<{
87
87
  }, z.core.$strip>>;
88
88
  selected: z.ZodOptional<z.ZodObject<{
89
89
  entityType: z.ZodEnum<{
90
- client: "client";
90
+ user: "user";
91
91
  project: "project";
92
+ client: "client";
92
93
  agent: "agent";
93
- user: "user";
94
94
  workspace: "workspace";
95
95
  api_key: "api_key";
96
96
  }>;
@@ -112,8 +112,8 @@ export declare const spendBreakdownEntrySchema: z.ZodObject<{
112
112
  amountUsd: z.ZodNumber;
113
113
  recordCount: z.ZodNumber;
114
114
  confidence: z.ZodEnum<{
115
- verified: "verified";
116
115
  estimated: "estimated";
116
+ verified: "verified";
117
117
  detected_unverified: "detected_unverified";
118
118
  missing: "missing";
119
119
  }>;
@@ -129,8 +129,8 @@ export declare const spendAnomalySchema: z.ZodObject<{
129
129
  currentAmountUsd: z.ZodNumber;
130
130
  multiplier: z.ZodNumber;
131
131
  confidence: z.ZodEnum<{
132
- verified: "verified";
133
132
  estimated: "estimated";
133
+ verified: "verified";
134
134
  detected_unverified: "detected_unverified";
135
135
  missing: "missing";
136
136
  }>;
@@ -146,8 +146,8 @@ export declare const workflowWatchEntrySchema: z.ZodObject<{
146
146
  shareOfSpend: z.ZodNumber;
147
147
  recordCount: z.ZodNumber;
148
148
  confidence: z.ZodEnum<{
149
- verified: "verified";
150
149
  estimated: "estimated";
150
+ verified: "verified";
151
151
  detected_unverified: "detected_unverified";
152
152
  missing: "missing";
153
153
  }>;
@@ -171,8 +171,8 @@ export declare const recommendationSchema: z.ZodObject<{
171
171
  }>;
172
172
  estimatedImpactUsd: z.ZodNumber;
173
173
  confidence: z.ZodEnum<{
174
- verified: "verified";
175
174
  estimated: "estimated";
175
+ verified: "verified";
176
176
  detected_unverified: "detected_unverified";
177
177
  missing: "missing";
178
178
  }>;
@@ -216,8 +216,8 @@ export declare const spendInsightSchema: z.ZodObject<{
216
216
  affectedModels: z.ZodArray<z.ZodString>;
217
217
  estimatedImpactUsd: z.ZodNumber;
218
218
  confidence: z.ZodEnum<{
219
- verified: "verified";
220
219
  estimated: "estimated";
220
+ verified: "verified";
221
221
  detected_unverified: "detected_unverified";
222
222
  missing: "missing";
223
223
  }>;
@@ -229,14 +229,14 @@ export declare const spendSummarySchema: z.ZodObject<{
229
229
  totalUsd: z.ZodNumber;
230
230
  recordCount: z.ZodNumber;
231
231
  confidence: z.ZodEnum<{
232
- verified: "verified";
233
232
  estimated: "estimated";
233
+ verified: "verified";
234
234
  detected_unverified: "detected_unverified";
235
235
  missing: "missing";
236
236
  }>;
237
237
  confidenceBreakdown: z.ZodRecord<z.ZodEnum<{
238
- verified: "verified";
239
238
  estimated: "estimated";
239
+ verified: "verified";
240
240
  detected_unverified: "detected_unverified";
241
241
  missing: "missing";
242
242
  }>, z.ZodNumber>;
@@ -245,8 +245,8 @@ export declare const spendSummarySchema: z.ZodObject<{
245
245
  amountUsd: z.ZodNumber;
246
246
  recordCount: z.ZodNumber;
247
247
  confidence: z.ZodEnum<{
248
- verified: "verified";
249
248
  estimated: "estimated";
249
+ verified: "verified";
250
250
  detected_unverified: "detected_unverified";
251
251
  missing: "missing";
252
252
  }>;
@@ -256,8 +256,8 @@ export declare const spendSummarySchema: z.ZodObject<{
256
256
  amountUsd: z.ZodNumber;
257
257
  recordCount: z.ZodNumber;
258
258
  confidence: z.ZodEnum<{
259
- verified: "verified";
260
259
  estimated: "estimated";
260
+ verified: "verified";
261
261
  detected_unverified: "detected_unverified";
262
262
  missing: "missing";
263
263
  }>;
@@ -267,8 +267,8 @@ export declare const spendSummarySchema: z.ZodObject<{
267
267
  amountUsd: z.ZodNumber;
268
268
  recordCount: z.ZodNumber;
269
269
  confidence: z.ZodEnum<{
270
- verified: "verified";
271
270
  estimated: "estimated";
271
+ verified: "verified";
272
272
  detected_unverified: "detected_unverified";
273
273
  missing: "missing";
274
274
  }>;
@@ -278,8 +278,8 @@ export declare const spendSummarySchema: z.ZodObject<{
278
278
  amountUsd: z.ZodNumber;
279
279
  recordCount: z.ZodNumber;
280
280
  confidence: z.ZodEnum<{
281
- verified: "verified";
282
281
  estimated: "estimated";
282
+ verified: "verified";
283
283
  detected_unverified: "detected_unverified";
284
284
  missing: "missing";
285
285
  }>;
@@ -289,8 +289,8 @@ export declare const spendSummarySchema: z.ZodObject<{
289
289
  amountUsd: z.ZodNumber;
290
290
  recordCount: z.ZodNumber;
291
291
  confidence: z.ZodEnum<{
292
- verified: "verified";
293
292
  estimated: "estimated";
293
+ verified: "verified";
294
294
  detected_unverified: "detected_unverified";
295
295
  missing: "missing";
296
296
  }>;
@@ -300,8 +300,8 @@ export declare const spendSummarySchema: z.ZodObject<{
300
300
  amountUsd: z.ZodNumber;
301
301
  recordCount: z.ZodNumber;
302
302
  confidence: z.ZodEnum<{
303
- verified: "verified";
304
303
  estimated: "estimated";
304
+ verified: "verified";
305
305
  detected_unverified: "detected_unverified";
306
306
  missing: "missing";
307
307
  }>;
@@ -311,8 +311,8 @@ export declare const spendSummarySchema: z.ZodObject<{
311
311
  amountUsd: z.ZodNumber;
312
312
  recordCount: z.ZodNumber;
313
313
  confidence: z.ZodEnum<{
314
- verified: "verified";
315
314
  estimated: "estimated";
315
+ verified: "verified";
316
316
  detected_unverified: "detected_unverified";
317
317
  missing: "missing";
318
318
  }>;
@@ -322,8 +322,8 @@ export declare const spendSummarySchema: z.ZodObject<{
322
322
  amountUsd: z.ZodNumber;
323
323
  recordCount: z.ZodNumber;
324
324
  confidence: z.ZodEnum<{
325
- verified: "verified";
326
325
  estimated: "estimated";
326
+ verified: "verified";
327
327
  detected_unverified: "detected_unverified";
328
328
  missing: "missing";
329
329
  }>;
@@ -338,8 +338,8 @@ export declare const spendSummarySchema: z.ZodObject<{
338
338
  shareOfSpend: z.ZodNumber;
339
339
  recordCount: z.ZodNumber;
340
340
  confidence: z.ZodEnum<{
341
- verified: "verified";
342
341
  estimated: "estimated";
342
+ verified: "verified";
343
343
  detected_unverified: "detected_unverified";
344
344
  missing: "missing";
345
345
  }>;
@@ -359,8 +359,8 @@ export declare const spendSummarySchema: z.ZodObject<{
359
359
  currentAmountUsd: z.ZodNumber;
360
360
  multiplier: z.ZodNumber;
361
361
  confidence: z.ZodEnum<{
362
- verified: "verified";
363
362
  estimated: "estimated";
363
+ verified: "verified";
364
364
  detected_unverified: "detected_unverified";
365
365
  missing: "missing";
366
366
  }>;
@@ -378,8 +378,8 @@ export declare const spendSummarySchema: z.ZodObject<{
378
378
  }>;
379
379
  estimatedImpactUsd: z.ZodNumber;
380
380
  confidence: z.ZodEnum<{
381
- verified: "verified";
382
381
  estimated: "estimated";
382
+ verified: "verified";
383
383
  detected_unverified: "detected_unverified";
384
384
  missing: "missing";
385
385
  }>;
@@ -416,8 +416,8 @@ export declare const spendSummarySchema: z.ZodObject<{
416
416
  affectedModels: z.ZodArray<z.ZodString>;
417
417
  estimatedImpactUsd: z.ZodNumber;
418
418
  confidence: z.ZodEnum<{
419
- verified: "verified";
420
419
  estimated: "estimated";
420
+ verified: "verified";
421
421
  detected_unverified: "detected_unverified";
422
422
  missing: "missing";
423
423
  }>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Read-only ingestion of which tools were actually INVOKED in Claude Code
3
+ * sessions, plus session/turn counts for cache-aware pricing.
4
+ *
5
+ * This is the counterpart to agentInventory.ts (what's LOADED into context):
6
+ * comparing loaded-but-never-invoked tools against this "actually invoked" set
7
+ * is the "dead-context cost" signal.
8
+ *
9
+ * Transcript format (~/.claude/projects/** /*.jsonl, one JSON object per line):
10
+ * - type:"assistant" lines carry message.content = array of blocks; blocks
11
+ * with type:"tool_use" have `name` (the invoked tool) and `input`.
12
+ * - Built-ins: Read/Edit/Bash/Glob/Grep/...; MCP: "mcp__<server>__<tool>".
13
+ * - The `Skill` tool's input.skill names the invoked skill.
14
+ * - The `Agent` (or `Task`) tool's input.subagent_type names the subagent.
15
+ * - Slash commands surface in type:"user" lines as
16
+ * "<command-name>/foo</command-name>".
17
+ *
18
+ * A "turn" = one assistant message that produced an API call, deduped by
19
+ * message.id + requestId (streaming/retries write the same response on
20
+ * multiple lines), matching parseClaudeCodeTranscript in localAgentLogs.ts.
21
+ */
22
+ export type ToolInvocationCount = {
23
+ name: string;
24
+ count: number;
25
+ };
26
+ export type InvocationSummary = {
27
+ /** aggregated counts by raw tool name across all parsed transcripts */
28
+ invocations: ToolInvocationCount[];
29
+ /** distinct mcp tool names invoked, formatted "mcp__<server>__<tool>" */
30
+ invokedMcpTools: string[];
31
+ /** distinct skill names invoked (resolved from the Skill tool input) */
32
+ invokedSkills: string[];
33
+ /** distinct subagent types invoked (resolved from Task/Agent input) */
34
+ invokedSubagents: string[];
35
+ /** distinct slash-command names invoked, if detectable; else [] */
36
+ invokedCommands: string[];
37
+ /** number of transcript files parsed (≈ sessions) */
38
+ sessions: number;
39
+ /** total assistant turns across all sessions (post-dedupe) */
40
+ totalAssistantTurns: number;
41
+ /** assistant-turn count per session, for cache-read pricing */
42
+ sessionTurnCounts: number[];
43
+ };
44
+ export type ToolInvocationOptions = {
45
+ /** default: join(homedir(), ".claude", "projects") */
46
+ claudeProjectsDir?: string;
47
+ /** optional: only count turns at/after this time */
48
+ sinceIso?: string;
49
+ };
50
+ /** Parse ONE transcript's content. Exported for tests. Returns the per-file pieces the aggregator needs. */
51
+ export declare function parseClaudeCodeInvocations(content: string, sinceMs?: number): {
52
+ invocations: ToolInvocationCount[];
53
+ invokedMcpTools: string[];
54
+ invokedSkills: string[];
55
+ invokedSubagents: string[];
56
+ invokedCommands: string[];
57
+ assistantTurns: number;
58
+ };
59
+ /** Scan this machine's Claude Code transcripts and aggregate tool invocations. */
60
+ export declare function loadToolInvocations(options?: ToolInvocationOptions): Promise<InvocationSummary>;
61
+ //# sourceMappingURL=toolInvocations.d.ts.map