@mnemom/mnemom 0.17.38-next.1 → 0.17.38-next.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.
Files changed (82) hide show
  1. package/dist/commands/agent-run.d.ts +10 -2
  2. package/dist/commands/agent-run.js +18 -7
  3. package/dist/commands/scan.d.ts +1 -1
  4. package/dist/commands/scan.js +7 -14
  5. package/dist/index.js +1 -0
  6. package/dist/lib/runner/executor-client.d.ts +6 -0
  7. package/dist/lib/runner/executor-client.js +6 -0
  8. package/dist/lib/runner/job.d.ts +12 -3
  9. package/dist/lib/runner/job.js +30 -2
  10. package/dist/lib/runner/loop.d.ts +7 -2
  11. package/dist/lib/runner/loop.js +15 -3
  12. package/dist/lib/runner/options.d.ts +7 -0
  13. package/dist/lib/runner/options.js +11 -0
  14. package/dist/lib/scan/caps.d.ts +1 -35
  15. package/dist/lib/scan/caps.js +2 -67
  16. package/dist/lib/scan/cost-per-pr.d.ts +1 -93
  17. package/dist/lib/scan/cost-per-pr.js +2 -101
  18. package/dist/lib/scan/credential.d.ts +2 -26
  19. package/dist/lib/scan/credential.js +5 -58
  20. package/dist/lib/scan/failure.d.ts +1 -10
  21. package/dist/lib/scan/failure.js +2 -43
  22. package/dist/lib/scan/pr-source.d.ts +1 -57
  23. package/dist/lib/scan/pr-source.js +2 -132
  24. package/dist/lib/scan/report-email.d.ts +7 -104
  25. package/dist/lib/scan/report-email.js +25 -97
  26. package/dist/lib/scan/scan.d.ts +1 -69
  27. package/dist/lib/scan/scan.js +2 -195
  28. package/dist/lib/scan/share.d.ts +3 -1
  29. package/dist/lib/scan/spend/ai-tool-detect.d.ts +1 -6
  30. package/dist/lib/scan/spend/ai-tool-detect.js +2 -18
  31. package/dist/lib/scan/spend/anthropic-usage.d.ts +1 -76
  32. package/dist/lib/scan/spend/anthropic-usage.js +2 -269
  33. package/dist/lib/scan/spend/prices.d.ts +1 -33
  34. package/dist/lib/scan/spend/prices.js +2 -57
  35. package/dist/lib/scan/spend/spend-estimate.d.ts +1 -30
  36. package/dist/lib/scan/spend/spend-estimate.js +2 -70
  37. package/dist/lib/scan/spend/spend-tiers.d.ts +1 -16
  38. package/dist/lib/scan/spend/spend-tiers.js +6 -26
  39. package/dist/lib/scan/spend/token-rate.d.ts +1 -30
  40. package/dist/lib/scan/spend/token-rate.js +2 -54
  41. package/dist/vendor/durable-contract/index.js +19 -2
  42. package/dist/vendor/shared/durable-contract/cli-surface.js +2 -1
  43. package/dist/vendor/shared/durable-contract/codes.d.ts +12 -0
  44. package/dist/vendor/shared/durable-contract/codes.js +11 -0
  45. package/dist/vendor/shared/durable-contract/constants.d.ts +7 -1
  46. package/dist/vendor/shared/durable-contract/constants.js +7 -1
  47. package/dist/vendor/shared/durable-contract/doors.d.ts +9 -1
  48. package/dist/vendor/shared/durable-contract/doors.js +13 -0
  49. package/dist/vendor/shared/durable-contract/freeze-record.d.ts +1 -1
  50. package/dist/vendor/shared/durable-contract/schema.d.ts +9 -0
  51. package/dist/vendor/shared/durable-contract/schema.js +11 -0
  52. package/dist/vendor/shared/org-scan-core/caps.d.ts +35 -0
  53. package/dist/vendor/shared/org-scan-core/caps.js +68 -0
  54. package/dist/vendor/shared/org-scan-core/cost-per-pr.d.ts +93 -0
  55. package/dist/vendor/shared/org-scan-core/cost-per-pr.js +101 -0
  56. package/dist/vendor/shared/org-scan-core/credential.d.ts +43 -0
  57. package/dist/vendor/shared/org-scan-core/credential.js +74 -0
  58. package/dist/vendor/shared/org-scan-core/failure.d.ts +10 -0
  59. package/dist/vendor/shared/org-scan-core/failure.js +43 -0
  60. package/dist/vendor/shared/org-scan-core/index.d.ts +18 -0
  61. package/dist/vendor/shared/org-scan-core/index.js +19 -0
  62. package/dist/vendor/shared/org-scan-core/pr-source.d.ts +61 -0
  63. package/dist/vendor/shared/org-scan-core/pr-source.js +138 -0
  64. package/dist/vendor/shared/org-scan-core/report-build.d.ts +120 -0
  65. package/dist/vendor/shared/org-scan-core/report-build.js +119 -0
  66. package/dist/vendor/shared/org-scan-core/scan.d.ts +75 -0
  67. package/dist/vendor/shared/org-scan-core/scan.js +202 -0
  68. package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.d.ts +6 -0
  69. package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.js +18 -0
  70. package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.d.ts +76 -0
  71. package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.js +270 -0
  72. package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.js +1 -0
  73. package/dist/vendor/shared/org-scan-core/spend/prices.d.ts +33 -0
  74. package/dist/vendor/shared/org-scan-core/spend/prices.js +58 -0
  75. package/dist/vendor/shared/org-scan-core/spend/spend-estimate.d.ts +30 -0
  76. package/dist/vendor/shared/org-scan-core/spend/spend-estimate.js +71 -0
  77. package/dist/vendor/shared/org-scan-core/spend/spend-select.d.ts +16 -0
  78. package/dist/vendor/shared/org-scan-core/spend/spend-select.js +26 -0
  79. package/dist/vendor/shared/org-scan-core/spend/token-rate.d.ts +30 -0
  80. package/dist/vendor/shared/org-scan-core/spend/token-rate.js +55 -0
  81. package/package.json +1 -1
  82. /package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.d.ts +0 -0
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Capped scan core, shared by the CLI and the Mnemom-side run (MNE-9752). Port of the window path of runCappedScan (mnemom-api capped-scan.ts, PR #4208)
3
+ * plus the spend estimate (#4222). Everything outside is injected (credential, PR source, clock), so the
4
+ * core touches no network or disk, and the report exists only in memory.
5
+ */
6
+ import { type GitHubCredential } from "./credential.js";
7
+ import { type ScanCaps, type StopReason } from "./caps.js";
8
+ import { type FailureReason } from "./failure.js";
9
+ import { type PrSource } from "./pr-source.js";
10
+ import type { PrSize } from "./cost-per-pr.js";
11
+ import { type SpendEstimate } from "./spend/spend-estimate.js";
12
+ export interface ScanInput {
13
+ repos: string[];
14
+ /** Inclusive dates, YYYY-MM-DD. */
15
+ period: {
16
+ start: string;
17
+ end: string;
18
+ };
19
+ }
20
+ export interface ScanDeps {
21
+ credential: GitHubCredential;
22
+ prs?: PrSource;
23
+ fetchImpl?: typeof fetch;
24
+ now?: () => number;
25
+ caps?: Partial<ScanCaps>;
26
+ /** The exact read-only permission set the credential must hold. Defaults to the CLI's set. */
27
+ permissions?: Readonly<Record<string, "read">>;
28
+ /** Aborts every GitHub read (the caller's run deadline). */
29
+ signal?: AbortSignal;
30
+ /** user-agent for the GitHub reads; defaults to the CLI's. */
31
+ userAgent?: string;
32
+ }
33
+ export interface Coverage {
34
+ complete: boolean;
35
+ stoppedBy: StopReason | null;
36
+ prsExamined: number;
37
+ prsSkippedLarge: number;
38
+ prsUnreadable: number;
39
+ reposRead: number;
40
+ reposTotal: number;
41
+ reads: number;
42
+ windowStart: string;
43
+ windowEnd: string;
44
+ /** Set only when the window held more merged PRs than the quota and an even sample was taken. */
45
+ windowSampledFrom?: number;
46
+ }
47
+ export interface RepoTotals {
48
+ repo: string;
49
+ prs: number;
50
+ reverts: number;
51
+ ciGreen: number;
52
+ /** PRs read and counted (not reverts, not skipped); the per-repo base of the CI-green share. */
53
+ examined: number;
54
+ }
55
+ export interface ScanResult {
56
+ coverage: Coverage;
57
+ totals: {
58
+ prs: number;
59
+ reverts: number;
60
+ ciGreen: number;
61
+ examined: number;
62
+ };
63
+ byRepo: RepoTotals[];
64
+ spend: SpendEstimate;
65
+ /** Every merged PR read in full (reverts and unreadable/oversize PRs excluded): merge day and size. In memory only. */
66
+ prSizes: PrSize[];
67
+ }
68
+ export type ScanOutcome = {
69
+ status: "done";
70
+ result: ScanResult;
71
+ } | {
72
+ status: "withheld";
73
+ reason: FailureReason;
74
+ };
75
+ export declare function runLocalScan(deps: ScanDeps, input: ScanInput): Promise<ScanOutcome>;
@@ -0,0 +1,202 @@
1
+ // GENERATED from shared/org-scan-core/src/scan.ts by cli/scripts/sync-org-scan-core.mjs; do not edit.
2
+ /**
3
+ * Capped scan core, shared by the CLI and the Mnemom-side run (MNE-9752). Port of the window path of runCappedScan (mnemom-api capped-scan.ts, PR #4208)
4
+ * plus the spend estimate (#4222). Everything outside is injected (credential, PR source, clock), so the
5
+ * core touches no network or disk, and the report exists only in memory.
6
+ */
7
+ import { assertReadOnlyCredential, isValidRepoSlug } from "./credential.js";
8
+ import { Budget, COVERAGE_MONTHS, evenSample, monthsBefore, sanitiseCaps, } from "./caps.js";
9
+ import { classifyFailure } from "./failure.js";
10
+ import { createGithubPrSource } from "./pr-source.js";
11
+ import { estimateSpend } from "./spend/spend-estimate.js";
12
+ const validDay = (d) => typeof d === "string" &&
13
+ /^\d{4}-\d{2}-\d{2}$/.test(d) &&
14
+ !Number.isNaN(Date.parse(`${d}T00:00:00Z`));
15
+ export async function runLocalScan(deps, input) {
16
+ if (!validDay(input.period.start) ||
17
+ !validDay(input.period.end) ||
18
+ input.repos.length === 0 ||
19
+ !input.repos.every(isValidRepoSlug)) {
20
+ return { status: "withheld", reason: "invalid_input" };
21
+ }
22
+ try {
23
+ await assertReadOnlyCredential(deps.credential, deps.permissions);
24
+ }
25
+ catch (e) {
26
+ return { status: "withheld", reason: classifyFailure(e) };
27
+ }
28
+ const prs = deps.prs ??
29
+ createGithubPrSource({
30
+ credential: deps.credential,
31
+ fetchImpl: deps.fetchImpl,
32
+ signal: deps.signal,
33
+ userAgent: deps.userAgent,
34
+ });
35
+ const now = deps.now ?? Date.now;
36
+ const caps = sanitiseCaps(deps.caps ?? {});
37
+ const budget = new Budget(caps, now, now());
38
+ let stoppedBy = null;
39
+ const stop = (r) => {
40
+ if (r && !stoppedBy)
41
+ stoppedBy = r;
42
+ return stoppedBy !== null;
43
+ };
44
+ const periodStart = input.period.start;
45
+ const windowStart = periodStart < monthsBefore(input.period.end, COVERAGE_MONTHS)
46
+ ? periodStart
47
+ : monthsBefore(input.period.end, COVERAGE_MONTHS);
48
+ const endDay = input.period.end;
49
+ const pool = [];
50
+ let reposRead = 0;
51
+ let listingCut = false;
52
+ try {
53
+ for (const repo of input.repos) {
54
+ if (stoppedBy !== null || stop(budget.block()))
55
+ break;
56
+ reposRead += 1;
57
+ let cursor = null;
58
+ const days = [];
59
+ let oldestUpdated = "9999-99-99";
60
+ do {
61
+ if (stop(budget.block()))
62
+ break;
63
+ // Listing may use at most half the read budget, so it never starves the reads of the PRs it found.
64
+ if (budget.reads * 2 > caps.maxReads) {
65
+ listingCut = true;
66
+ break;
67
+ }
68
+ budget.charge(1);
69
+ const page = await prs.indexMerged(repo, cursor, budget.room() + 1);
70
+ budget.charge((Number.isFinite(page.reads) && page.reads >= 1 ? page.reads : 1) - 1);
71
+ const overrun = budget.overrun();
72
+ if (overrun) {
73
+ stop(overrun);
74
+ break;
75
+ }
76
+ for (const item of page.items) {
77
+ pool.push({ repo, item });
78
+ days.push(item.mergedDay);
79
+ if (item.updatedDay < oldestUpdated)
80
+ oldestUpdated = item.updatedDay;
81
+ }
82
+ cursor = page.next;
83
+ // Newest-activity first: once a seen PR was last touched before D, no later page holds a PR
84
+ // merged on or after D. Stop when D is both the window start and the merge day of this repo's
85
+ // maxPrs-th newest merged PR.
86
+ const kth = days.length >= caps.maxPrs ? [...days].sort().reverse()[caps.maxPrs - 1] : null;
87
+ if (kth !== null && oldestUpdated < (kth < windowStart ? kth : windowStart))
88
+ break;
89
+ } while (cursor !== null && stoppedBy === null);
90
+ if (listingCut) {
91
+ if (cursor === null && days.length === 0)
92
+ reposRead -= 1;
93
+ break;
94
+ }
95
+ }
96
+ // Newest first, ties by repo then number: the same pool always gives the same sample.
97
+ for (let i = pool.length - 1; i >= 0; i--)
98
+ if (pool[i].item.mergedDay > endDay)
99
+ pool.splice(i, 1);
100
+ pool.sort((a, b) => (a.item.mergedDay === b.item.mergedDay
101
+ ? 0
102
+ : a.item.mergedDay < b.item.mergedDay
103
+ ? 1
104
+ : -1) ||
105
+ (a.repo < b.repo ? -1 : a.repo > b.repo ? 1 : 0) ||
106
+ b.item.number - a.item.number);
107
+ const inWindow = pool.filter((p) => p.item.mergedDay >= windowStart);
108
+ const sampled = inWindow.length > caps.maxPrs;
109
+ const chosen = sampled ? evenSample(inWindow, caps.maxPrs) : pool.slice(0, caps.maxPrs);
110
+ const byRepo = new Map();
111
+ const bucket = (repo) => {
112
+ let b = byRepo.get(repo);
113
+ if (!b)
114
+ byRepo.set(repo, (b = { repo, prs: 0, reverts: 0, ciGreen: 0, examined: 0 }));
115
+ return b;
116
+ };
117
+ const totals = { prs: 0, reverts: 0, ciGreen: 0, examined: 0 };
118
+ const spendInputs = [];
119
+ const prSizes = [];
120
+ let skippedLarge = 0;
121
+ let unreadable = 0;
122
+ for (const { repo, item } of chosen) {
123
+ // A revert PR is never a candidate: counted from the listing, never read.
124
+ if (item.reverted) {
125
+ totals.prs += 1;
126
+ totals.reverts += 1;
127
+ bucket(repo).prs += 1;
128
+ bucket(repo).reverts += 1;
129
+ continue;
130
+ }
131
+ if (stop(budget.block(2)))
132
+ break;
133
+ budget.charge(1);
134
+ const res = await prs.readPr(repo, item, budget.room() + 1);
135
+ budget.charge((Number.isFinite(res.reads) && res.reads >= 1 ? res.reads : 1) - 1);
136
+ const overrun = budget.overrun();
137
+ if (overrun) {
138
+ stop(overrun);
139
+ break;
140
+ }
141
+ const rec = res.pr;
142
+ if (!rec) {
143
+ unreadable += 1;
144
+ continue;
145
+ }
146
+ if (rec.additions + rec.deletions > caps.maxPrSizeLines) {
147
+ skippedLarge += 1;
148
+ continue;
149
+ }
150
+ totals.prs += 1;
151
+ totals.examined += 1;
152
+ bucket(repo).examined += 1;
153
+ bucket(repo).prs += 1;
154
+ if (rec.ci === "success") {
155
+ totals.ciGreen += 1;
156
+ bucket(repo).ciGreen += 1;
157
+ }
158
+ prSizes.push({
159
+ mergedDay: item.mergedDay,
160
+ lines: rec.additions + rec.deletions,
161
+ aiAssisted: (rec.tools?.length ?? 0) > 0,
162
+ });
163
+ spendInputs.push({
164
+ additions: rec.additions,
165
+ deletions: rec.deletions,
166
+ markText: "",
167
+ tools: rec.tools,
168
+ });
169
+ }
170
+ const coverage = {
171
+ complete: stoppedBy === null &&
172
+ !listingCut &&
173
+ unreadable === 0 &&
174
+ reposRead === input.repos.length &&
175
+ !sampled,
176
+ stoppedBy: stoppedBy ?? (listingCut ? "read-cap" : null),
177
+ prsExamined: totals.examined,
178
+ prsSkippedLarge: skippedLarge,
179
+ prsUnreadable: unreadable,
180
+ reposRead,
181
+ reposTotal: input.repos.length,
182
+ reads: budget.reads,
183
+ windowStart,
184
+ windowEnd: endDay,
185
+ ...(sampled ? { windowSampledFrom: inWindow.length } : {}),
186
+ };
187
+ return {
188
+ status: "done",
189
+ result: {
190
+ coverage,
191
+ totals,
192
+ byRepo: [...byRepo.values()],
193
+ spend: estimateSpend(spendInputs),
194
+ prSizes,
195
+ },
196
+ };
197
+ }
198
+ catch (e) {
199
+ // Reads only; the raw error is dropped and a fixed reason returned.
200
+ return { status: "withheld", reason: classifyFailure(e) };
201
+ }
202
+ }
@@ -0,0 +1,6 @@
1
+ import type { AiTool } from "./prices.js";
2
+ /** Upper bound on text scanned per PR, so one huge body cannot dominate a scan. The TAIL is kept: trailers and "generated with" footers sit at the end. */
3
+ export declare const MAX_MARK_TEXT = 20000;
4
+ export declare const AI_TOOLS: AiTool[];
5
+ /** Tools whose mark appears in the text, in a fixed order. Non-string input yields none. */
6
+ export declare function detectAiTools(markText: unknown): AiTool[];
@@ -0,0 +1,18 @@
1
+ /** Upper bound on text scanned per PR, so one huge body cannot dominate a scan. The TAIL is kept: trailers and "generated with" footers sit at the end. */
2
+ export const MAX_MARK_TEXT = 20_000;
3
+ const TRAILER = String.raw `^[ \t>*-]*co-authored-by:[^\n]*?`;
4
+ const GEN = String.raw `generated (?:with|by|using)[^\n]{0,24}?`;
5
+ const MARKS = {
6
+ claude_code: new RegExp(`${TRAILER}(?:\\bclaude\\b|noreply@anthropic\\.com)|${GEN}\\bclaude code\\b`, "im"),
7
+ codex: new RegExp(`${TRAILER}\\bcodex\\b|${GEN}\\bcodex\\b`, "im"),
8
+ copilot: new RegExp(`${TRAILER}\\bcopilot\\b|${GEN}\\bgithub copilot\\b`, "im"),
9
+ gemini: new RegExp(`${TRAILER}\\bgemini\\b|${GEN}\\bgemini(?: cli)?\\b`, "im"),
10
+ };
11
+ export const AI_TOOLS = Object.keys(MARKS);
12
+ /** Tools whose mark appears in the text, in a fixed order. Non-string input yields none. */
13
+ export function detectAiTools(markText) {
14
+ if (typeof markText !== "string" || markText.length === 0)
15
+ return [];
16
+ const text = markText.length > MAX_MARK_TEXT ? markText.slice(-MAX_MARK_TEXT) : markText;
17
+ return AI_TOOLS.filter((t) => MARKS[t].test(text));
18
+ }
@@ -0,0 +1,76 @@
1
+ export declare const ANTHROPIC_ADMIN_ORIGIN = "https://api.anthropic.com";
2
+ export declare const ADMIN_KEY_ENV = "ANTHROPIC_ADMIN_KEY";
3
+ /** The only paths a usage credential is ever sent to (design T18: fenced to report endpoints). */
4
+ export declare const REPORT_PATHS: readonly string[];
5
+ /** The fence, as one exported check so it is directly testable. Throws AnthropicUsageError for any other path. */
6
+ export declare function assertReportPath(path: string): void;
7
+ /** The exact auth header to send; same shape as mnemom-api src/org-scan/usage/credential-source.ts. */
8
+ export type AnthropicAuthHeader = {
9
+ header: "x-api-key";
10
+ value: string;
11
+ } | {
12
+ header: "authorization";
13
+ value: string;
14
+ };
15
+ export declare const DEFAULT_MAX_PAGES = 20;
16
+ /** One shared deadline for a request including its retries (not N x timeout). */
17
+ export declare const DEFAULT_TIMEOUT_MS = 15000;
18
+ export declare const DEFAULT_MAX_RETRIES = 2;
19
+ /** Shape check only; the key is never echoed, even on a mismatch. */
20
+ export declare const isAdminKeyShape: (k: unknown) => k is string;
21
+ export type UsageFailure = "key_invalid" | "key_refused" | "token_refused" | "rate_limited" | "timeout" | "network" | "upstream" | "malformed" | "too_large";
22
+ /** Fixed sentence for a failure reason. */
23
+ export declare const usageFailureText: (r: UsageFailure) => string;
24
+ /** Carries only a reason tag. Its message is the fixed sentence: nothing upstream can reach it. */
25
+ export declare class AnthropicUsageError extends Error {
26
+ readonly reason: UsageFailure;
27
+ constructor(reason: UsageFailure);
28
+ }
29
+ export type ContextWindow = "0-200k" | "200k-1M";
30
+ export interface UsageRow {
31
+ tokens: number;
32
+ contextWindow: ContextWindow | null;
33
+ }
34
+ export interface Page<R> {
35
+ rows: R[];
36
+ truncated: boolean;
37
+ pages: number;
38
+ }
39
+ export interface CostRow {
40
+ /** Cents, as Anthropic returns it (a decimal string). */
41
+ cents: number;
42
+ currency: string;
43
+ }
44
+ export interface AnthropicUsageDeps {
45
+ fetchImpl?: typeof fetch;
46
+ sleep?: (ms: number) => Promise<void>;
47
+ maxPages?: number;
48
+ timeoutMs?: number;
49
+ maxRetries?: number;
50
+ }
51
+ export declare function parseUsageRow(raw: unknown): UsageRow;
52
+ export declare function parseCostRow(raw: unknown): CostRow;
53
+ /** Buckets are UTC-aligned: floor so a mid-day start reads the whole bucket it falls in. */
54
+ export declare function alignStartingAt(startingAt: string): string;
55
+ export interface MeasuredUsage {
56
+ /** Whole-org cost over the period, in USD. */
57
+ usd: number;
58
+ /** Fraction in [0, 1] of tokens from requests in the 200k-1M context window; null with no tokens. */
59
+ over200kShare: number | null;
60
+ }
61
+ /**
62
+ * The same two reads mnemom-api made: usage grouped by context_window (the exact >200k share) and cost
63
+ * (total spend). The key is shape-checked before any call so a regular API key never leaves the machine.
64
+ * Throws AnthropicUsageError only. A walk stopped by the page cap is an error, never a short total.
65
+ */
66
+ export declare function fetchMeasuredUsage(key: string | undefined, period: {
67
+ start: string;
68
+ end: string;
69
+ }, deps?: AnthropicUsageDeps): Promise<MeasuredUsage>;
70
+ /** A WIF access token as the bearer for the two report reads. Printable ASCII only, else key_invalid. */
71
+ export declare function bearerAuth(token: string): AnthropicAuthHeader;
72
+ /** The two reads with an already-resolved credential (pasted key or WIF bearer). Throws AnthropicUsageError only. */
73
+ export declare function fetchMeasuredUsageWithAuth(auth: AnthropicAuthHeader, period: {
74
+ start: string;
75
+ end: string;
76
+ }, deps?: AnthropicUsageDeps): Promise<MeasuredUsage>;
@@ -0,0 +1,270 @@
1
+ // GENERATED from shared/org-scan-core/src/spend/anthropic-usage.ts by cli/scripts/sync-org-scan-core.mjs; do not edit.
2
+ // MNE-9298 Tier 1: the CTO's own Anthropic Admin key, used LOCALLY, to read measured usage. Port of
3
+ // mnemom-api src/org-scan/usage/usage-client.ts (two read-only GETs: usage_report/messages grouped by
4
+ // context_window, and cost_report) with the server-only parts (egress allowlist) dropped.
5
+ //
6
+ // MNE-9706: the reads take either credential, as the server's credential-source.ts does: the pasted Admin
7
+ // key (x-api-key) or a short-lived WIF access token (Authorization: Bearer, see anthropic-wif.ts). Either
8
+ // one is sent ONLY to the two report paths below; walk() refuses any other path (the fence, design T18:
9
+ // Anthropic's WIF scope for these reports is org:admin, so the fence has to live here).
10
+ //
11
+ // The key is held in memory for the length of the two reads. It is never stored, logged, written to disk,
12
+ // echoed, put in argv, a URL or an error message, and is sent only to api.anthropic.com (a constant, not
13
+ // configurable) in the x-api-key header. Every failure surfaces as a fixed sentence: the thrown value, the
14
+ // response body and the key never reach the terminal.
15
+ export const ANTHROPIC_ADMIN_ORIGIN = "https://api.anthropic.com";
16
+ export const ADMIN_KEY_ENV = "ANTHROPIC_ADMIN_KEY";
17
+ const ANTHROPIC_VERSION = "2023-06-01";
18
+ const USAGE_PATH = "/v1/organizations/usage_report/messages";
19
+ const COST_PATH = "/v1/organizations/cost_report";
20
+ /** The only paths a usage credential is ever sent to (design T18: fenced to report endpoints). */
21
+ export const REPORT_PATHS = [USAGE_PATH, COST_PATH];
22
+ /** The fence, as one exported check so it is directly testable. Throws AnthropicUsageError for any other path. */
23
+ export function assertReportPath(path) {
24
+ if (!REPORT_PATHS.includes(path))
25
+ throw new AnthropicUsageError("malformed");
26
+ }
27
+ export const DEFAULT_MAX_PAGES = 20;
28
+ /** One shared deadline for a request including its retries (not N x timeout). */
29
+ export const DEFAULT_TIMEOUT_MS = 15_000;
30
+ export const DEFAULT_MAX_RETRIES = 2;
31
+ const MAX_RETRY_WAIT_MS = 5_000;
32
+ const ADMIN_KEY_RE = /^sk-ant-admin[A-Za-z0-9_-]{8,}$/;
33
+ /** Shape check only; the key is never echoed, even on a mismatch. */
34
+ export const isAdminKeyShape = (k) => typeof k === "string" && ADMIN_KEY_RE.test(k.trim());
35
+ const FAILURE_TEXT = {
36
+ key_invalid: "The Anthropic admin key is not an Admin API key (it should start with sk-ant-admin).",
37
+ key_refused: "Anthropic refused the admin key (401/403).",
38
+ token_refused: "Anthropic refused the workload identity token for the usage reports (401/403). The federation rule needs scope org:admin.",
39
+ rate_limited: "Anthropic rate limit reached.",
40
+ timeout: "Anthropic did not answer in time.",
41
+ network: "Anthropic was unreachable.",
42
+ upstream: "Anthropic usage could not be read.",
43
+ malformed: "Anthropic returned usage in a shape this CLI does not recognise.",
44
+ too_large: "Anthropic usage for this period is too large to read completely.",
45
+ };
46
+ /** Fixed sentence for a failure reason. */
47
+ export const usageFailureText = (r) => FAILURE_TEXT[r];
48
+ /** Carries only a reason tag. Its message is the fixed sentence: nothing upstream can reach it. */
49
+ export class AnthropicUsageError extends Error {
50
+ reason;
51
+ constructor(reason) {
52
+ super(FAILURE_TEXT[reason]);
53
+ this.reason = reason;
54
+ this.name = "AnthropicUsageError";
55
+ }
56
+ }
57
+ const bad = () => {
58
+ throw new AnthropicUsageError("malformed");
59
+ };
60
+ const rec = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : bad();
61
+ function reqNum(r, k) {
62
+ const v = r[k];
63
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : bad();
64
+ }
65
+ /** Optional counter inside an optional object: absent is 0, present-but-wrong is an error. */
66
+ function optNum(o, k) {
67
+ if (o === undefined || o === null)
68
+ return 0;
69
+ if (typeof o !== "object")
70
+ return bad();
71
+ const v = o[k];
72
+ if (v === undefined || v === null)
73
+ return 0;
74
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : bad();
75
+ }
76
+ export function parseUsageRow(raw) {
77
+ const r = rec(raw);
78
+ const cw = r.context_window;
79
+ if (cw !== null && cw !== undefined && cw !== "0-200k" && cw !== "200k-1M")
80
+ bad();
81
+ return {
82
+ tokens: reqNum(r, "uncached_input_tokens") +
83
+ reqNum(r, "cache_read_input_tokens") +
84
+ reqNum(r, "output_tokens") +
85
+ optNum(r.cache_creation, "ephemeral_1h_input_tokens") +
86
+ optNum(r.cache_creation, "ephemeral_5m_input_tokens"),
87
+ contextWindow: (cw ?? null),
88
+ };
89
+ }
90
+ const CENT = /^-?\d+(\.\d+)?$/;
91
+ export function parseCostRow(raw) {
92
+ const r = rec(raw);
93
+ if (typeof r.amount !== "string" || !CENT.test(r.amount))
94
+ bad();
95
+ if (typeof r.currency !== "string" || r.currency === "")
96
+ bad();
97
+ return { cents: Number(r.amount), currency: r.currency };
98
+ }
99
+ const DAY_MS = 86_400_000;
100
+ /** Buckets are UTC-aligned: floor so a mid-day start reads the whole bucket it falls in. */
101
+ export function alignStartingAt(startingAt) {
102
+ const t = Date.parse(startingAt);
103
+ if (!Number.isFinite(t))
104
+ return startingAt;
105
+ return new Date(Math.floor(t / DAY_MS) * DAY_MS).toISOString().replace(".000Z", "Z");
106
+ }
107
+ const backoff = (attempt) => Math.min(250 * 2 ** (attempt - 1), MAX_RETRY_WAIT_MS);
108
+ function retryWait(res, attempt) {
109
+ const ra = Number(res.headers.get("retry-after"));
110
+ return Number.isFinite(ra) && ra > 0 ? ra * 1000 : backoff(attempt);
111
+ }
112
+ class Reader {
113
+ auth;
114
+ fetchImpl;
115
+ sleep;
116
+ maxPages;
117
+ timeoutMs;
118
+ maxRetries;
119
+ constructor(auth, deps) {
120
+ this.auth = auth;
121
+ this.fetchImpl = deps.fetchImpl ?? ((...a) => fetch(...a));
122
+ this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
123
+ this.maxPages = deps.maxPages ?? DEFAULT_MAX_PAGES;
124
+ this.timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
125
+ this.maxRetries = deps.maxRetries ?? DEFAULT_MAX_RETRIES;
126
+ }
127
+ async walk(path, base, parse, callMaxPages) {
128
+ // The fence: the credential goes to the two report paths and nowhere else.
129
+ assertReportPath(path);
130
+ const ceiling = typeof callMaxPages === "number" && Number.isFinite(callMaxPages) && callMaxPages >= 1
131
+ ? Math.min(this.maxPages, Math.floor(callMaxPages))
132
+ : this.maxPages;
133
+ const rows = [];
134
+ let page = null;
135
+ let pages = 0;
136
+ for (;;) {
137
+ const params = new URLSearchParams(base);
138
+ if (page)
139
+ params.set("page", page);
140
+ const body = await this.request(`${ANTHROPIC_ADMIN_ORIGIN}${path}?${params.toString()}`);
141
+ pages += 1;
142
+ if (!Array.isArray(body.data))
143
+ bad();
144
+ for (const raw of body.data) {
145
+ const b = rec(raw);
146
+ if (typeof b.starting_at !== "string" || typeof b.ending_at !== "string")
147
+ bad();
148
+ if (!Array.isArray(b.results))
149
+ bad();
150
+ for (const r of b.results)
151
+ rows.push(parse(r));
152
+ }
153
+ const more = body.has_more;
154
+ if (more !== undefined && more !== null && typeof more !== "boolean")
155
+ bad();
156
+ if (more !== true)
157
+ return { rows, truncated: false, pages };
158
+ if (typeof body.next_page !== "string" || body.next_page === "")
159
+ bad();
160
+ if (pages >= ceiling)
161
+ return { rows, truncated: true, pages };
162
+ page = body.next_page;
163
+ }
164
+ }
165
+ async request(url) {
166
+ const deadline = AbortSignal.timeout(this.timeoutMs);
167
+ for (let attempt = 0;;) {
168
+ let res;
169
+ try {
170
+ res = await this.fetchImpl(url, {
171
+ method: "GET",
172
+ headers: {
173
+ [this.auth.header]: this.auth.value,
174
+ "anthropic-version": ANTHROPIC_VERSION,
175
+ accept: "application/json",
176
+ },
177
+ // A redirect would carry the x-api-key header to wherever it points: refuse to follow one.
178
+ redirect: "error",
179
+ signal: deadline,
180
+ });
181
+ }
182
+ catch {
183
+ // Never surface the thrown value: it can carry the request.
184
+ if (deadline.aborted)
185
+ throw new AnthropicUsageError("timeout");
186
+ if (attempt++ < this.maxRetries) {
187
+ await this.sleep(backoff(attempt));
188
+ continue;
189
+ }
190
+ throw new AnthropicUsageError("network");
191
+ }
192
+ if (res.ok) {
193
+ let json;
194
+ try {
195
+ json = await res.json();
196
+ }
197
+ catch {
198
+ throw new AnthropicUsageError("upstream");
199
+ }
200
+ return rec(json);
201
+ }
202
+ await res.body?.cancel().catch(() => { });
203
+ if (res.status === 401 || res.status === 403)
204
+ throw new AnthropicUsageError(this.auth.header === "x-api-key" ? "key_refused" : "token_refused");
205
+ const retryable = res.status === 429 || res.status >= 500;
206
+ const wait = retryable ? retryWait(res, attempt + 1) : 0;
207
+ if (retryable &&
208
+ !deadline.aborted &&
209
+ wait <= MAX_RETRY_WAIT_MS &&
210
+ attempt++ < this.maxRetries) {
211
+ await this.sleep(wait);
212
+ continue;
213
+ }
214
+ throw new AnthropicUsageError(res.status === 429 ? "rate_limited" : "upstream");
215
+ }
216
+ }
217
+ }
218
+ const validDay = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d) && Number.isFinite(Date.parse(`${d}T00:00:00Z`));
219
+ /**
220
+ * The same two reads mnemom-api made: usage grouped by context_window (the exact >200k share) and cost
221
+ * (total spend). The key is shape-checked before any call so a regular API key never leaves the machine.
222
+ * Throws AnthropicUsageError only. A walk stopped by the page cap is an error, never a short total.
223
+ */
224
+ export async function fetchMeasuredUsage(key, period, deps = {}) {
225
+ const k = key?.trim();
226
+ if (!k || !isAdminKeyShape(k))
227
+ throw new AnthropicUsageError("key_invalid");
228
+ return fetchMeasuredUsageWithAuth({ header: "x-api-key", value: k }, period, deps);
229
+ }
230
+ /** A WIF access token as the bearer for the two report reads. Printable ASCII only, else key_invalid. */
231
+ export function bearerAuth(token) {
232
+ if (!/^[\x21-\x7e]{8,4096}$/.test(token))
233
+ throw new AnthropicUsageError("key_invalid");
234
+ return { header: "authorization", value: `Bearer ${token}` };
235
+ }
236
+ /** The two reads with an already-resolved credential (pasted key or WIF bearer). Throws AnthropicUsageError only. */
237
+ export async function fetchMeasuredUsageWithAuth(auth, period, deps = {}) {
238
+ if (!validDay(period.start) || !validDay(period.end))
239
+ throw new AnthropicUsageError("malformed");
240
+ const startingAt = alignStartingAt(`${period.start}T00:00:00Z`);
241
+ const endingAt = new Date(Date.parse(`${period.end}T00:00:00Z`) + DAY_MS).toISOString();
242
+ const reader = new Reader(auth, deps);
243
+ const up = new URLSearchParams({
244
+ starting_at: startingAt,
245
+ ending_at: endingAt,
246
+ bucket_width: "1d",
247
+ });
248
+ up.append("group_by[]", "context_window");
249
+ const usage = await reader.walk(USAGE_PATH, up, parseUsageRow);
250
+ if (usage.truncated)
251
+ throw new AnthropicUsageError("too_large");
252
+ const cp = new URLSearchParams({ starting_at: startingAt, ending_at: endingAt });
253
+ const cost = await reader.walk(COST_PATH, cp, parseCostRow);
254
+ if (cost.truncated)
255
+ throw new AnthropicUsageError("too_large");
256
+ let total = 0;
257
+ let over = 0;
258
+ for (const r of usage.rows) {
259
+ total += r.tokens;
260
+ if (r.contextWindow === "200k-1M")
261
+ over += r.tokens;
262
+ }
263
+ let cents = 0;
264
+ for (const r of cost.rows) {
265
+ if (r.currency !== "USD")
266
+ throw new AnthropicUsageError("malformed");
267
+ cents += r.cents;
268
+ }
269
+ return { usd: Math.round(cents) / 100, over200kShare: total > 0 ? over / total : null };
270
+ }
@@ -1,3 +1,4 @@
1
+ // GENERATED from shared/org-scan-core/src/spend/cost-corpus.v1.ts by cli/scripts/sync-org-scan-core.mjs; do not edit.
1
2
  // Cost corpus (port of mnemom-api src/org-scan/spend/cost-corpus.v1.json, as_of 2026-10-28T00:00:00Z). A TS module, not a
2
3
  // JSON asset, so the build needs no copy-assets entry.
3
4
  export default {