@mnemom/mnemom 0.17.38-next.0 → 0.17.38-next.2
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.
- package/dist/commands/agent-doctor.d.ts +2 -2
- package/dist/commands/agent-doctor.js +4 -4
- package/dist/commands/agent-run.d.ts +10 -2
- package/dist/commands/agent-run.js +12 -6
- package/dist/commands/agent-setup.d.ts +11 -9
- package/dist/commands/agent-setup.js +24 -19
- package/dist/commands/card.js +1 -1
- package/dist/commands/onboard.js +5 -4
- package/dist/commands/scan.d.ts +21 -7
- package/dist/commands/scan.js +93 -56
- package/dist/commands/verify-card.d.ts +25 -1
- package/dist/commands/verify-card.js +29 -2
- package/dist/commands/wrap.js +4 -3
- package/dist/index.js +7 -5
- package/dist/lib/runner/executor-client.d.ts +6 -0
- package/dist/lib/runner/executor-client.js +6 -0
- package/dist/lib/runner/job.d.ts +12 -3
- package/dist/lib/runner/job.js +30 -2
- package/dist/lib/scan/caps.d.ts +1 -35
- package/dist/lib/scan/caps.js +2 -67
- package/dist/lib/scan/cost-per-pr.d.ts +1 -93
- package/dist/lib/scan/cost-per-pr.js +2 -101
- package/dist/lib/scan/credential.d.ts +2 -26
- package/dist/lib/scan/credential.js +5 -58
- package/dist/lib/scan/failure.d.ts +1 -10
- package/dist/lib/scan/failure.js +2 -43
- package/dist/lib/scan/pr-source.d.ts +1 -57
- package/dist/lib/scan/pr-source.js +2 -132
- package/dist/lib/scan/report-email.d.ts +9 -89
- package/dist/lib/scan/report-email.js +76 -92
- package/dist/lib/scan/scan.d.ts +1 -67
- package/dist/lib/scan/scan.js +2 -194
- package/dist/lib/scan/share.d.ts +10 -6
- package/dist/lib/scan/share.js +28 -11
- package/dist/lib/scan/spend/ai-tool-detect.d.ts +1 -6
- package/dist/lib/scan/spend/ai-tool-detect.js +2 -18
- package/dist/lib/scan/spend/anthropic-usage.d.ts +1 -76
- package/dist/lib/scan/spend/anthropic-usage.js +2 -269
- package/dist/lib/scan/spend/prices.d.ts +1 -33
- package/dist/lib/scan/spend/prices.js +2 -57
- package/dist/lib/scan/spend/spend-estimate.d.ts +1 -30
- package/dist/lib/scan/spend/spend-estimate.js +2 -70
- package/dist/lib/scan/spend/spend-tiers.d.ts +1 -16
- package/dist/lib/scan/spend/spend-tiers.js +6 -26
- package/dist/lib/scan/spend/token-rate.d.ts +1 -30
- package/dist/lib/scan/spend/token-rate.js +2 -54
- package/dist/lib/starter-integrity.d.ts +20 -0
- package/dist/lib/starter-integrity.js +28 -0
- package/dist/vendor/shared/org-scan-core/caps.d.ts +35 -0
- package/dist/vendor/shared/org-scan-core/caps.js +68 -0
- package/dist/vendor/shared/org-scan-core/cost-per-pr.d.ts +93 -0
- package/dist/vendor/shared/org-scan-core/cost-per-pr.js +101 -0
- package/dist/vendor/shared/org-scan-core/credential.d.ts +43 -0
- package/dist/vendor/shared/org-scan-core/credential.js +74 -0
- package/dist/vendor/shared/org-scan-core/failure.d.ts +10 -0
- package/dist/vendor/shared/org-scan-core/failure.js +43 -0
- package/dist/vendor/shared/org-scan-core/index.d.ts +18 -0
- package/dist/vendor/shared/org-scan-core/index.js +19 -0
- package/dist/vendor/shared/org-scan-core/pr-source.d.ts +61 -0
- package/dist/vendor/shared/org-scan-core/pr-source.js +138 -0
- package/dist/vendor/shared/org-scan-core/report-build.d.ts +120 -0
- package/dist/vendor/shared/org-scan-core/report-build.js +119 -0
- package/dist/vendor/shared/org-scan-core/scan.d.ts +75 -0
- package/dist/vendor/shared/org-scan-core/scan.js +202 -0
- package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.d.ts +6 -0
- package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.js +18 -0
- package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.d.ts +76 -0
- package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.js +270 -0
- package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.js +1 -0
- package/dist/vendor/shared/org-scan-core/spend/prices.d.ts +33 -0
- package/dist/vendor/shared/org-scan-core/spend/prices.js +58 -0
- package/dist/vendor/shared/org-scan-core/spend/spend-estimate.d.ts +30 -0
- package/dist/vendor/shared/org-scan-core/spend/spend-estimate.js +71 -0
- package/dist/vendor/shared/org-scan-core/spend/spend-select.d.ts +16 -0
- package/dist/vendor/shared/org-scan-core/spend/spend-select.js +26 -0
- package/dist/vendor/shared/org-scan-core/spend/token-rate.d.ts +30 -0
- package/dist/vendor/shared/org-scan-core/spend/token-rate.js +55 -0
- package/package.json +2 -2
- /package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.d.ts +0 -0
package/dist/lib/scan/share.js
CHANGED
|
@@ -1,32 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* "What may Mnemom keep on file?" (MNE-9634). This is a retention choice only; it never changes what is emailed. It is asked at
|
|
3
|
+
* the start, before any scan work. The scan itself is always complete: the full report, with the counts
|
|
4
|
+
* for each repo, is emailed to the runner, every Org Admin and anyone the runner adds, at every level.
|
|
5
|
+
*
|
|
6
|
+
* full Mnemom keeps the totals and the counts for each repo, by name, on file (the default).
|
|
7
|
+
* analytics Mnemom keeps the team totals only, with no repo names. `summary` is the old name for this
|
|
8
|
+
* and is accepted as an alias; it is never upgraded to full.
|
|
9
|
+
* none Mnemom keeps nothing on file. The report is still emailed.
|
|
10
|
+
*
|
|
11
|
+
* "none" is what a cancelled, empty-piped or non-interactive prompt resolves to (fail closed).
|
|
12
|
+
* Storing the shared payload on Mnemom's side is not built yet; the CLI only prints it.
|
|
5
13
|
*/
|
|
6
14
|
import { askSelect } from "../prompt.js";
|
|
7
15
|
/** The level preselected at the prompt (Enter picks it). */
|
|
8
16
|
export const DEFAULT_SHARE_LEVEL = "full";
|
|
9
17
|
export const SHARE_OPTIONS = [
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
18
|
+
{
|
|
19
|
+
level: "full",
|
|
20
|
+
label: "Full report (default): Mnemom keeps the totals and the counts for each repo, by name, on file",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
level: "analytics",
|
|
24
|
+
label: "Analytics: Mnemom keeps the team totals only on file, with no repo names",
|
|
25
|
+
},
|
|
26
|
+
{ level: "none", label: "Nothing: Mnemom keeps nothing on file" },
|
|
13
27
|
];
|
|
14
|
-
/** Shown just above the
|
|
28
|
+
/** Shown just above the prompt. The full report, repo names included, is emailed whatever is chosen here. */
|
|
15
29
|
export const SHARE_DISCLOSURE = [
|
|
16
|
-
"
|
|
17
|
-
"
|
|
30
|
+
"Whatever you choose, the full report, including repo names, is emailed to you, your org's admins and any addresses you added.",
|
|
31
|
+
"The next question is only about what Mnemom keeps on file. Pressing Enter chooses the Full report.",
|
|
18
32
|
].join("\n");
|
|
19
33
|
/** Asks the user, with Full preselected. Anything but a recognised answer (cancel, EOF, noise) is "none": fail closed. */
|
|
20
34
|
export async function promptShareLevel() {
|
|
21
35
|
// eslint-disable-next-line no-console -- consent text must show on stdout directly above the prompt (askSelect also prints with console.log)
|
|
22
36
|
console.log(SHARE_DISCLOSURE);
|
|
23
|
-
const answer = await askSelect("What may
|
|
37
|
+
const answer = await askSelect("What may Mnemom keep on file?", SHARE_OPTIONS.map((o) => o.label), SHARE_OPTIONS.findIndex((o) => o.level === DEFAULT_SHARE_LEVEL));
|
|
24
38
|
if (answer === null)
|
|
25
39
|
return "none";
|
|
26
40
|
return SHARE_OPTIONS.find((o) => o.label === answer)?.level ?? "none";
|
|
27
41
|
}
|
|
42
|
+
/** `summary` is the old name for `analytics`; the meaning is unchanged. */
|
|
28
43
|
export function parseShareLevel(v) {
|
|
29
|
-
|
|
44
|
+
if (v === "summary")
|
|
45
|
+
return "analytics";
|
|
46
|
+
return v === "analytics" || v === "full" || v === "none" ? v : null;
|
|
30
47
|
}
|
|
31
48
|
export function buildSharePayload(level, r, recipients,
|
|
32
49
|
/** undefined: the legacy line; null: no spend line; string: that line. */
|
|
@@ -1,6 +1 @@
|
|
|
1
|
-
|
|
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[];
|
|
1
|
+
export * from "../../../vendor/shared/org-scan-core/spend/ai-tool-detect.js";
|
|
@@ -1,18 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
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
|
-
}
|
|
1
|
+
// Moved to shared/org-scan-core (MNE-9752); vendored at cli/src/vendor/shared/org-scan-core. Re-exported so CLI imports stay put.
|
|
2
|
+
export * from "../../../vendor/shared/org-scan-core/spend/ai-tool-detect.js";
|
|
@@ -1,76 +1 @@
|
|
|
1
|
-
export
|
|
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>;
|
|
1
|
+
export * from "../../../vendor/shared/org-scan-core/spend/anthropic-usage.js";
|
|
@@ -1,269 +1,2 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
// context_window, and cost_report) with the server-only parts (egress allowlist) dropped.
|
|
4
|
-
//
|
|
5
|
-
// MNE-9706: the reads take either credential, as the server's credential-source.ts does: the pasted Admin
|
|
6
|
-
// key (x-api-key) or a short-lived WIF access token (Authorization: Bearer, see anthropic-wif.ts). Either
|
|
7
|
-
// one is sent ONLY to the two report paths below; walk() refuses any other path (the fence, design T18:
|
|
8
|
-
// Anthropic's WIF scope for these reports is org:admin, so the fence has to live here).
|
|
9
|
-
//
|
|
10
|
-
// The key is held in memory for the length of the two reads. It is never stored, logged, written to disk,
|
|
11
|
-
// echoed, put in argv, a URL or an error message, and is sent only to api.anthropic.com (a constant, not
|
|
12
|
-
// configurable) in the x-api-key header. Every failure surfaces as a fixed sentence: the thrown value, the
|
|
13
|
-
// response body and the key never reach the terminal.
|
|
14
|
-
export const ANTHROPIC_ADMIN_ORIGIN = "https://api.anthropic.com";
|
|
15
|
-
export const ADMIN_KEY_ENV = "ANTHROPIC_ADMIN_KEY";
|
|
16
|
-
const ANTHROPIC_VERSION = "2023-06-01";
|
|
17
|
-
const USAGE_PATH = "/v1/organizations/usage_report/messages";
|
|
18
|
-
const COST_PATH = "/v1/organizations/cost_report";
|
|
19
|
-
/** The only paths a usage credential is ever sent to (design T18: fenced to report endpoints). */
|
|
20
|
-
export const REPORT_PATHS = [USAGE_PATH, COST_PATH];
|
|
21
|
-
/** The fence, as one exported check so it is directly testable. Throws AnthropicUsageError for any other path. */
|
|
22
|
-
export function assertReportPath(path) {
|
|
23
|
-
if (!REPORT_PATHS.includes(path))
|
|
24
|
-
throw new AnthropicUsageError("malformed");
|
|
25
|
-
}
|
|
26
|
-
export const DEFAULT_MAX_PAGES = 20;
|
|
27
|
-
/** One shared deadline for a request including its retries (not N x timeout). */
|
|
28
|
-
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
29
|
-
export const DEFAULT_MAX_RETRIES = 2;
|
|
30
|
-
const MAX_RETRY_WAIT_MS = 5_000;
|
|
31
|
-
const ADMIN_KEY_RE = /^sk-ant-admin[A-Za-z0-9_-]{8,}$/;
|
|
32
|
-
/** Shape check only; the key is never echoed, even on a mismatch. */
|
|
33
|
-
export const isAdminKeyShape = (k) => typeof k === "string" && ADMIN_KEY_RE.test(k.trim());
|
|
34
|
-
const FAILURE_TEXT = {
|
|
35
|
-
key_invalid: "The Anthropic admin key is not an Admin API key (it should start with sk-ant-admin).",
|
|
36
|
-
key_refused: "Anthropic refused the admin key (401/403).",
|
|
37
|
-
token_refused: "Anthropic refused the workload identity token for the usage reports (401/403). The federation rule needs scope org:admin.",
|
|
38
|
-
rate_limited: "Anthropic rate limit reached.",
|
|
39
|
-
timeout: "Anthropic did not answer in time.",
|
|
40
|
-
network: "Anthropic was unreachable.",
|
|
41
|
-
upstream: "Anthropic usage could not be read.",
|
|
42
|
-
malformed: "Anthropic returned usage in a shape this CLI does not recognise.",
|
|
43
|
-
too_large: "Anthropic usage for this period is too large to read completely.",
|
|
44
|
-
};
|
|
45
|
-
/** Fixed sentence for a failure reason. */
|
|
46
|
-
export const usageFailureText = (r) => FAILURE_TEXT[r];
|
|
47
|
-
/** Carries only a reason tag. Its message is the fixed sentence: nothing upstream can reach it. */
|
|
48
|
-
export class AnthropicUsageError extends Error {
|
|
49
|
-
reason;
|
|
50
|
-
constructor(reason) {
|
|
51
|
-
super(FAILURE_TEXT[reason]);
|
|
52
|
-
this.reason = reason;
|
|
53
|
-
this.name = "AnthropicUsageError";
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
const bad = () => {
|
|
57
|
-
throw new AnthropicUsageError("malformed");
|
|
58
|
-
};
|
|
59
|
-
const rec = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : bad();
|
|
60
|
-
function reqNum(r, k) {
|
|
61
|
-
const v = r[k];
|
|
62
|
-
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : bad();
|
|
63
|
-
}
|
|
64
|
-
/** Optional counter inside an optional object: absent is 0, present-but-wrong is an error. */
|
|
65
|
-
function optNum(o, k) {
|
|
66
|
-
if (o === undefined || o === null)
|
|
67
|
-
return 0;
|
|
68
|
-
if (typeof o !== "object")
|
|
69
|
-
return bad();
|
|
70
|
-
const v = o[k];
|
|
71
|
-
if (v === undefined || v === null)
|
|
72
|
-
return 0;
|
|
73
|
-
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : bad();
|
|
74
|
-
}
|
|
75
|
-
export function parseUsageRow(raw) {
|
|
76
|
-
const r = rec(raw);
|
|
77
|
-
const cw = r.context_window;
|
|
78
|
-
if (cw !== null && cw !== undefined && cw !== "0-200k" && cw !== "200k-1M")
|
|
79
|
-
bad();
|
|
80
|
-
return {
|
|
81
|
-
tokens: reqNum(r, "uncached_input_tokens") +
|
|
82
|
-
reqNum(r, "cache_read_input_tokens") +
|
|
83
|
-
reqNum(r, "output_tokens") +
|
|
84
|
-
optNum(r.cache_creation, "ephemeral_1h_input_tokens") +
|
|
85
|
-
optNum(r.cache_creation, "ephemeral_5m_input_tokens"),
|
|
86
|
-
contextWindow: (cw ?? null),
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
const CENT = /^-?\d+(\.\d+)?$/;
|
|
90
|
-
export function parseCostRow(raw) {
|
|
91
|
-
const r = rec(raw);
|
|
92
|
-
if (typeof r.amount !== "string" || !CENT.test(r.amount))
|
|
93
|
-
bad();
|
|
94
|
-
if (typeof r.currency !== "string" || r.currency === "")
|
|
95
|
-
bad();
|
|
96
|
-
return { cents: Number(r.amount), currency: r.currency };
|
|
97
|
-
}
|
|
98
|
-
const DAY_MS = 86_400_000;
|
|
99
|
-
/** Buckets are UTC-aligned: floor so a mid-day start reads the whole bucket it falls in. */
|
|
100
|
-
export function alignStartingAt(startingAt) {
|
|
101
|
-
const t = Date.parse(startingAt);
|
|
102
|
-
if (!Number.isFinite(t))
|
|
103
|
-
return startingAt;
|
|
104
|
-
return new Date(Math.floor(t / DAY_MS) * DAY_MS).toISOString().replace(".000Z", "Z");
|
|
105
|
-
}
|
|
106
|
-
const backoff = (attempt) => Math.min(250 * 2 ** (attempt - 1), MAX_RETRY_WAIT_MS);
|
|
107
|
-
function retryWait(res, attempt) {
|
|
108
|
-
const ra = Number(res.headers.get("retry-after"));
|
|
109
|
-
return Number.isFinite(ra) && ra > 0 ? ra * 1000 : backoff(attempt);
|
|
110
|
-
}
|
|
111
|
-
class Reader {
|
|
112
|
-
auth;
|
|
113
|
-
fetchImpl;
|
|
114
|
-
sleep;
|
|
115
|
-
maxPages;
|
|
116
|
-
timeoutMs;
|
|
117
|
-
maxRetries;
|
|
118
|
-
constructor(auth, deps) {
|
|
119
|
-
this.auth = auth;
|
|
120
|
-
this.fetchImpl = deps.fetchImpl ?? ((...a) => fetch(...a));
|
|
121
|
-
this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
122
|
-
this.maxPages = deps.maxPages ?? DEFAULT_MAX_PAGES;
|
|
123
|
-
this.timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
124
|
-
this.maxRetries = deps.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
125
|
-
}
|
|
126
|
-
async walk(path, base, parse, callMaxPages) {
|
|
127
|
-
// The fence: the credential goes to the two report paths and nowhere else.
|
|
128
|
-
assertReportPath(path);
|
|
129
|
-
const ceiling = typeof callMaxPages === "number" && Number.isFinite(callMaxPages) && callMaxPages >= 1
|
|
130
|
-
? Math.min(this.maxPages, Math.floor(callMaxPages))
|
|
131
|
-
: this.maxPages;
|
|
132
|
-
const rows = [];
|
|
133
|
-
let page = null;
|
|
134
|
-
let pages = 0;
|
|
135
|
-
for (;;) {
|
|
136
|
-
const params = new URLSearchParams(base);
|
|
137
|
-
if (page)
|
|
138
|
-
params.set("page", page);
|
|
139
|
-
const body = await this.request(`${ANTHROPIC_ADMIN_ORIGIN}${path}?${params.toString()}`);
|
|
140
|
-
pages += 1;
|
|
141
|
-
if (!Array.isArray(body.data))
|
|
142
|
-
bad();
|
|
143
|
-
for (const raw of body.data) {
|
|
144
|
-
const b = rec(raw);
|
|
145
|
-
if (typeof b.starting_at !== "string" || typeof b.ending_at !== "string")
|
|
146
|
-
bad();
|
|
147
|
-
if (!Array.isArray(b.results))
|
|
148
|
-
bad();
|
|
149
|
-
for (const r of b.results)
|
|
150
|
-
rows.push(parse(r));
|
|
151
|
-
}
|
|
152
|
-
const more = body.has_more;
|
|
153
|
-
if (more !== undefined && more !== null && typeof more !== "boolean")
|
|
154
|
-
bad();
|
|
155
|
-
if (more !== true)
|
|
156
|
-
return { rows, truncated: false, pages };
|
|
157
|
-
if (typeof body.next_page !== "string" || body.next_page === "")
|
|
158
|
-
bad();
|
|
159
|
-
if (pages >= ceiling)
|
|
160
|
-
return { rows, truncated: true, pages };
|
|
161
|
-
page = body.next_page;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
async request(url) {
|
|
165
|
-
const deadline = AbortSignal.timeout(this.timeoutMs);
|
|
166
|
-
for (let attempt = 0;;) {
|
|
167
|
-
let res;
|
|
168
|
-
try {
|
|
169
|
-
res = await this.fetchImpl(url, {
|
|
170
|
-
method: "GET",
|
|
171
|
-
headers: {
|
|
172
|
-
[this.auth.header]: this.auth.value,
|
|
173
|
-
"anthropic-version": ANTHROPIC_VERSION,
|
|
174
|
-
accept: "application/json",
|
|
175
|
-
},
|
|
176
|
-
// A redirect would carry the x-api-key header to wherever it points: refuse to follow one.
|
|
177
|
-
redirect: "error",
|
|
178
|
-
signal: deadline,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
catch {
|
|
182
|
-
// Never surface the thrown value: it can carry the request.
|
|
183
|
-
if (deadline.aborted)
|
|
184
|
-
throw new AnthropicUsageError("timeout");
|
|
185
|
-
if (attempt++ < this.maxRetries) {
|
|
186
|
-
await this.sleep(backoff(attempt));
|
|
187
|
-
continue;
|
|
188
|
-
}
|
|
189
|
-
throw new AnthropicUsageError("network");
|
|
190
|
-
}
|
|
191
|
-
if (res.ok) {
|
|
192
|
-
let json;
|
|
193
|
-
try {
|
|
194
|
-
json = await res.json();
|
|
195
|
-
}
|
|
196
|
-
catch {
|
|
197
|
-
throw new AnthropicUsageError("upstream");
|
|
198
|
-
}
|
|
199
|
-
return rec(json);
|
|
200
|
-
}
|
|
201
|
-
await res.body?.cancel().catch(() => { });
|
|
202
|
-
if (res.status === 401 || res.status === 403)
|
|
203
|
-
throw new AnthropicUsageError(this.auth.header === "x-api-key" ? "key_refused" : "token_refused");
|
|
204
|
-
const retryable = res.status === 429 || res.status >= 500;
|
|
205
|
-
const wait = retryable ? retryWait(res, attempt + 1) : 0;
|
|
206
|
-
if (retryable &&
|
|
207
|
-
!deadline.aborted &&
|
|
208
|
-
wait <= MAX_RETRY_WAIT_MS &&
|
|
209
|
-
attempt++ < this.maxRetries) {
|
|
210
|
-
await this.sleep(wait);
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
throw new AnthropicUsageError(res.status === 429 ? "rate_limited" : "upstream");
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
const validDay = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d) && Number.isFinite(Date.parse(`${d}T00:00:00Z`));
|
|
218
|
-
/**
|
|
219
|
-
* The same two reads mnemom-api made: usage grouped by context_window (the exact >200k share) and cost
|
|
220
|
-
* (total spend). The key is shape-checked before any call so a regular API key never leaves the machine.
|
|
221
|
-
* Throws AnthropicUsageError only. A walk stopped by the page cap is an error, never a short total.
|
|
222
|
-
*/
|
|
223
|
-
export async function fetchMeasuredUsage(key, period, deps = {}) {
|
|
224
|
-
const k = key?.trim();
|
|
225
|
-
if (!k || !isAdminKeyShape(k))
|
|
226
|
-
throw new AnthropicUsageError("key_invalid");
|
|
227
|
-
return fetchMeasuredUsageWithAuth({ header: "x-api-key", value: k }, period, deps);
|
|
228
|
-
}
|
|
229
|
-
/** A WIF access token as the bearer for the two report reads. Printable ASCII only, else key_invalid. */
|
|
230
|
-
export function bearerAuth(token) {
|
|
231
|
-
if (!/^[\x21-\x7e]{8,4096}$/.test(token))
|
|
232
|
-
throw new AnthropicUsageError("key_invalid");
|
|
233
|
-
return { header: "authorization", value: `Bearer ${token}` };
|
|
234
|
-
}
|
|
235
|
-
/** The two reads with an already-resolved credential (pasted key or WIF bearer). Throws AnthropicUsageError only. */
|
|
236
|
-
export async function fetchMeasuredUsageWithAuth(auth, period, deps = {}) {
|
|
237
|
-
if (!validDay(period.start) || !validDay(period.end))
|
|
238
|
-
throw new AnthropicUsageError("malformed");
|
|
239
|
-
const startingAt = alignStartingAt(`${period.start}T00:00:00Z`);
|
|
240
|
-
const endingAt = new Date(Date.parse(`${period.end}T00:00:00Z`) + DAY_MS).toISOString();
|
|
241
|
-
const reader = new Reader(auth, deps);
|
|
242
|
-
const up = new URLSearchParams({
|
|
243
|
-
starting_at: startingAt,
|
|
244
|
-
ending_at: endingAt,
|
|
245
|
-
bucket_width: "1d",
|
|
246
|
-
});
|
|
247
|
-
up.append("group_by[]", "context_window");
|
|
248
|
-
const usage = await reader.walk(USAGE_PATH, up, parseUsageRow);
|
|
249
|
-
if (usage.truncated)
|
|
250
|
-
throw new AnthropicUsageError("too_large");
|
|
251
|
-
const cp = new URLSearchParams({ starting_at: startingAt, ending_at: endingAt });
|
|
252
|
-
const cost = await reader.walk(COST_PATH, cp, parseCostRow);
|
|
253
|
-
if (cost.truncated)
|
|
254
|
-
throw new AnthropicUsageError("too_large");
|
|
255
|
-
let total = 0;
|
|
256
|
-
let over = 0;
|
|
257
|
-
for (const r of usage.rows) {
|
|
258
|
-
total += r.tokens;
|
|
259
|
-
if (r.contextWindow === "200k-1M")
|
|
260
|
-
over += r.tokens;
|
|
261
|
-
}
|
|
262
|
-
let cents = 0;
|
|
263
|
-
for (const r of cost.rows) {
|
|
264
|
-
if (r.currency !== "USD")
|
|
265
|
-
throw new AnthropicUsageError("malformed");
|
|
266
|
-
cents += r.cents;
|
|
267
|
-
}
|
|
268
|
-
return { usd: Math.round(cents) / 100, over200kShare: total > 0 ? over / total : null };
|
|
269
|
-
}
|
|
1
|
+
// Moved to shared/org-scan-core (MNE-9752); vendored at cli/src/vendor/shared/org-scan-core. Re-exported so CLI imports stay put.
|
|
2
|
+
export * from "../../../vendor/shared/org-scan-core/spend/anthropic-usage.js";
|
|
@@ -1,33 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
export interface TokenPrice {
|
|
3
|
-
/** USD per 1M input tokens. */
|
|
4
|
-
inputPerMTok: number;
|
|
5
|
-
/** USD per 1M output tokens. */
|
|
6
|
-
outputPerMTok: number;
|
|
7
|
-
model: string;
|
|
8
|
-
}
|
|
9
|
-
export interface PriceBand {
|
|
10
|
-
low: TokenPrice;
|
|
11
|
-
high: TokenPrice;
|
|
12
|
-
/** Page the prices were read from. */
|
|
13
|
-
source: string;
|
|
14
|
-
}
|
|
15
|
-
export declare const PRICES_AS_OF = "2026-10-28";
|
|
16
|
-
export declare const TOOL_PRICES: Record<Exclude<AiTool, "copilot">, PriceBand>;
|
|
17
|
-
/**
|
|
18
|
-
* GitHub Copilot bills per premium request, not per token
|
|
19
|
-
* (https://docs.github.com/en/copilot/concepts/billing/copilot-requests),
|
|
20
|
-
* so it has no token price of its own. Its agent routes to the other vendors' models, so its band is
|
|
21
|
-
* the union of the other three: cheapest low to dearest high. An assumption, stated here once.
|
|
22
|
-
*/
|
|
23
|
-
export declare function copilotBand(): PriceBand;
|
|
24
|
-
/**
|
|
25
|
-
* Share of billed tokens that are input (the rest output). Agent loops re-read large context and
|
|
26
|
-
* write little, so input dominates. An ASSUMPTION used both to turn our known run cost into
|
|
27
|
-
* tokens-per-line and to price those tokens for each tool; it cancels to first order when the
|
|
28
|
-
* tool's input/output price ratio matches ours, and is the main source of the range's width.
|
|
29
|
-
*/
|
|
30
|
-
export declare const INPUT_SHARE = 0.9;
|
|
31
|
-
export declare function bandFor(tool: AiTool): PriceBand;
|
|
32
|
-
/** USD per token at the assumed input/output mix. */
|
|
33
|
-
export declare function blendedUsdPerToken(p: TokenPrice): number;
|
|
1
|
+
export * from "../../../vendor/shared/org-scan-core/spend/prices.js";
|
|
@@ -1,57 +1,2 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
// USD per million tokens (input / output), standard (non-batch) list price. A band is the cheapest
|
|
4
|
-
// and the dearest model a tool's coding agent plausibly runs on, so the estimate is a RANGE.
|
|
5
|
-
// Re-read the pages and bump PRICES_AS_OF when they move; a stale price widens nothing silently,
|
|
6
|
-
// it just shifts the range, which is why the date ships with the estimate's method text.
|
|
7
|
-
export const PRICES_AS_OF = "2026-10-28";
|
|
8
|
-
export const TOOL_PRICES = {
|
|
9
|
-
claude_code: {
|
|
10
|
-
source: "https://platform.claude.com/docs/en/about-claude/pricing",
|
|
11
|
-
low: { model: "Claude Sonnet 5.5", inputPerMTok: 2, outputPerMTok: 10 },
|
|
12
|
-
high: { model: "Claude Opus 5", inputPerMTok: 5, outputPerMTok: 25 },
|
|
13
|
-
},
|
|
14
|
-
codex: {
|
|
15
|
-
source: "https://developers.openai.com/api/docs/pricing",
|
|
16
|
-
low: { model: "gpt-5.3-codex (standard)", inputPerMTok: 1.75, outputPerMTok: 14 },
|
|
17
|
-
high: { model: "gpt-5.3-codex (fast)", inputPerMTok: 3.5, outputPerMTok: 28 },
|
|
18
|
-
},
|
|
19
|
-
gemini: {
|
|
20
|
-
source: "https://ai.google.dev/gemini-api/docs/pricing",
|
|
21
|
-
// Gemini 3.8 Flash is $0.75/$3.75 through 2026-12-31 and $1.50/$7.50 from 2027-01-01; the
|
|
22
|
-
// post-promotion price is used so the lower bound does not expire in two months.
|
|
23
|
-
low: { model: "Gemini 3.8 Flash (from 2027-01-01)", inputPerMTok: 1.5, outputPerMTok: 7.5 },
|
|
24
|
-
high: { model: "Gemini 3.1 Pro (prompts up to 200k)", inputPerMTok: 2, outputPerMTok: 12 },
|
|
25
|
-
},
|
|
26
|
-
};
|
|
27
|
-
/**
|
|
28
|
-
* GitHub Copilot bills per premium request, not per token
|
|
29
|
-
* (https://docs.github.com/en/copilot/concepts/billing/copilot-requests),
|
|
30
|
-
* so it has no token price of its own. Its agent routes to the other vendors' models, so its band is
|
|
31
|
-
* the union of the other three: cheapest low to dearest high. An assumption, stated here once.
|
|
32
|
-
*/
|
|
33
|
-
export function copilotBand() {
|
|
34
|
-
const bands = Object.values(TOOL_PRICES);
|
|
35
|
-
const blend = (p) => p.inputPerMTok * INPUT_SHARE + p.outputPerMTok * (1 - INPUT_SHARE);
|
|
36
|
-
const low = bands.map((b) => b.low).sort((a, b) => blend(a) - blend(b))[0];
|
|
37
|
-
const high = bands.map((b) => b.high).sort((a, b) => blend(b) - blend(a))[0];
|
|
38
|
-
return {
|
|
39
|
-
low,
|
|
40
|
-
high,
|
|
41
|
-
source: "union of the claude_code, codex and gemini bands (per-request billing)",
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Share of billed tokens that are input (the rest output). Agent loops re-read large context and
|
|
46
|
-
* write little, so input dominates. An ASSUMPTION used both to turn our known run cost into
|
|
47
|
-
* tokens-per-line and to price those tokens for each tool; it cancels to first order when the
|
|
48
|
-
* tool's input/output price ratio matches ours, and is the main source of the range's width.
|
|
49
|
-
*/
|
|
50
|
-
export const INPUT_SHARE = 0.9;
|
|
51
|
-
export function bandFor(tool) {
|
|
52
|
-
return tool === "copilot" ? copilotBand() : TOOL_PRICES[tool];
|
|
53
|
-
}
|
|
54
|
-
/** USD per token at the assumed input/output mix. */
|
|
55
|
-
export function blendedUsdPerToken(p) {
|
|
56
|
-
return (p.inputPerMTok * INPUT_SHARE + p.outputPerMTok * (1 - INPUT_SHARE)) / 1_000_000;
|
|
57
|
-
}
|
|
1
|
+
// Moved to shared/org-scan-core (MNE-9752); vendored at cli/src/vendor/shared/org-scan-core. Re-exported so CLI imports stay put.
|
|
2
|
+
export * from "../../../vendor/shared/org-scan-core/spend/prices.js";
|
|
@@ -1,30 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { type TokenRate } from "./token-rate.js";
|
|
3
|
-
/** AI-assisted PRs needed before a range is worth printing. */
|
|
4
|
-
export declare const MIN_AI_PRS = 10;
|
|
5
|
-
/** Per-PR input. `markText` is commit messages / PR body, read for tool marks and dropped. */
|
|
6
|
-
export interface SpendPrInput {
|
|
7
|
-
additions: number;
|
|
8
|
-
deletions: number;
|
|
9
|
-
markText: string;
|
|
10
|
-
/** Tools already detected inside the PR source (so the body never leaves it). Wins over markText. */
|
|
11
|
-
tools?: AiTool[];
|
|
12
|
-
}
|
|
13
|
-
export type NoSpendReason = "too_few_ai_prs" | "no_rate" | "invalid_input";
|
|
14
|
-
export type SpendEstimate = {
|
|
15
|
-
kind: "range";
|
|
16
|
-
lowUsd: number;
|
|
17
|
-
highUsd: number;
|
|
18
|
-
aiPrs: number;
|
|
19
|
-
scannedPrs: number;
|
|
20
|
-
byTool: Partial<Record<AiTool, number>>;
|
|
21
|
-
/** One line, labelled estimate, passes claim-lint. */
|
|
22
|
-
line: string;
|
|
23
|
-
} | {
|
|
24
|
-
kind: "none";
|
|
25
|
-
reason: NoSpendReason;
|
|
26
|
-
aiPrs: number;
|
|
27
|
-
scannedPrs: number;
|
|
28
|
-
line: string;
|
|
29
|
-
};
|
|
30
|
-
export declare function estimateSpend(prs: unknown, rate?: TokenRate | null): SpendEstimate;
|
|
1
|
+
export * from "../../../vendor/shared/org-scan-core/spend/spend-estimate.js";
|
|
@@ -1,70 +1,2 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
// No line is produced when there are too few AI-assisted PRs to price (MIN_AI_PRS). That is a
|
|
5
|
-
// data-sufficiency threshold on the evidence behind the range, nothing to do with how large a
|
|
6
|
-
// reporting cohort is. Default-OFF behind ORG_SCAN_MODE via estimateSpendIfEnabled.
|
|
7
|
-
import { AI_TOOLS, detectAiTools } from "./ai-tool-detect.js";
|
|
8
|
-
import { PRICES_AS_OF, bandFor, blendedUsdPerToken } from "./prices.js";
|
|
9
|
-
import { DERIVED_TOKEN_RATE } from "./token-rate.js";
|
|
10
|
-
/** AI-assisted PRs needed before a range is worth printing. */
|
|
11
|
-
export const MIN_AI_PRS = 10;
|
|
12
|
-
/** Fixed strings only: a failure never carries input or an error message into the output. */
|
|
13
|
-
const NONE_LINES = {
|
|
14
|
-
too_few_ai_prs: "No spend estimate: too few AI-assisted PRs in this scan to price a range.",
|
|
15
|
-
no_rate: "No spend estimate: the cost reference data is not sufficient to price a range.",
|
|
16
|
-
invalid_input: "No spend estimate: the PR counts could not be read.",
|
|
17
|
-
};
|
|
18
|
-
const isCount = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
19
|
-
function none(reason, aiPrs, scannedPrs) {
|
|
20
|
-
return { kind: "none", reason, aiPrs, scannedPrs, line: NONE_LINES[reason] };
|
|
21
|
-
}
|
|
22
|
-
function validPr(p) {
|
|
23
|
-
if (typeof p !== "object" || p === null)
|
|
24
|
-
return false;
|
|
25
|
-
const o = p;
|
|
26
|
-
return (isCount(o.additions) &&
|
|
27
|
-
isCount(o.deletions) &&
|
|
28
|
-
typeof o.markText === "string" &&
|
|
29
|
-
(o.tools === undefined ||
|
|
30
|
-
(Array.isArray(o.tools) && o.tools.every((t) => AI_TOOLS.includes(t)))));
|
|
31
|
-
}
|
|
32
|
-
/** Two significant figures so the range does not claim precision it lacks ($2.4, not $2; 0 stays $0). */
|
|
33
|
-
function usd(n) {
|
|
34
|
-
return `$${n.toLocaleString("en-US", { maximumSignificantDigits: 2 })}`;
|
|
35
|
-
}
|
|
36
|
-
export function estimateSpend(prs, rate = DERIVED_TOKEN_RATE) {
|
|
37
|
-
if (!Array.isArray(prs))
|
|
38
|
-
return none("invalid_input", 0, 0);
|
|
39
|
-
const scanned = prs.length;
|
|
40
|
-
if (!prs.every(validPr))
|
|
41
|
-
return none("invalid_input", 0, scanned);
|
|
42
|
-
let aiPrs = 0;
|
|
43
|
-
let low = 0;
|
|
44
|
-
let high = 0;
|
|
45
|
-
const byTool = {};
|
|
46
|
-
for (const p of prs) {
|
|
47
|
-
const tools = p.tools ?? detectAiTools(p.markText);
|
|
48
|
-
if (tools.length === 0)
|
|
49
|
-
continue;
|
|
50
|
-
aiPrs += 1;
|
|
51
|
-
for (const t of tools)
|
|
52
|
-
byTool[t] = (byTool[t] ?? 0) + 1;
|
|
53
|
-
if (!rate)
|
|
54
|
-
continue;
|
|
55
|
-
const lines = p.additions + p.deletions;
|
|
56
|
-
// A PR carrying several marks is priced once, across the widest band of the tools named.
|
|
57
|
-
const bands = tools.map(bandFor);
|
|
58
|
-
const lo = Math.min(...bands.map((b) => blendedUsdPerToken(b.low)));
|
|
59
|
-
const hi = Math.max(...bands.map((b) => blendedUsdPerToken(b.high)));
|
|
60
|
-
low += lines * rate.low * lo;
|
|
61
|
-
high += lines * rate.high * hi;
|
|
62
|
-
}
|
|
63
|
-
if (aiPrs < MIN_AI_PRS)
|
|
64
|
-
return none("too_few_ai_prs", aiPrs, scanned);
|
|
65
|
-
if (!rate)
|
|
66
|
-
return none("no_rate", aiPrs, scanned);
|
|
67
|
-
const line = `Estimate, AI tool spend: ${usd(low)} to ${usd(high)} across ${aiPrs} AI-assisted PRs ` +
|
|
68
|
-
`(public list prices as of ${PRICES_AS_OF} times a tokens-per-line rate from our own agents' PRs; not billing data).`;
|
|
69
|
-
return { kind: "range", lowUsd: low, highUsd: high, aiPrs, scannedPrs: scanned, byTool, line };
|
|
70
|
-
}
|
|
1
|
+
// Moved to shared/org-scan-core (MNE-9752); vendored at cli/src/vendor/shared/org-scan-core. Re-exported so CLI imports stay put.
|
|
2
|
+
export * from "../../../vendor/shared/org-scan-core/spend/spend-estimate.js";
|