@mnemom/mnemom 0.17.34-next.2 → 0.17.34-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 (75) hide show
  1. package/dist/commands/agent-run.d.ts +14 -6
  2. package/dist/commands/agent-run.js +44 -27
  3. package/dist/commands/agent-share-test-issuer.d.ts +67 -0
  4. package/dist/commands/agent-share-test-issuer.js +139 -0
  5. package/dist/commands/agent-share.d.ts +7 -1
  6. package/dist/commands/agent-share.js +14 -4
  7. package/dist/commands/agent.d.ts +7 -5
  8. package/dist/commands/agent.js +64 -36
  9. package/dist/commands/durable-lock.d.ts +8 -1
  10. package/dist/commands/durable-lock.js +9 -4
  11. package/dist/commands/scan.d.ts +12 -1
  12. package/dist/commands/scan.js +22 -3
  13. package/dist/commands/wrap.js +1 -1
  14. package/dist/durable/adopt-live.js +5 -8
  15. package/dist/durable/adopt.d.ts +8 -0
  16. package/dist/durable/adopt.js +27 -6
  17. package/dist/durable/cwd-rule.d.ts +9 -1
  18. package/dist/durable/cwd-rule.js +18 -0
  19. package/dist/durable/launch-spec.d.ts +2 -0
  20. package/dist/durable/launch-spec.js +1 -1
  21. package/dist/durable/test-mode.mjs +25 -1
  22. package/dist/host/client.d.ts +3 -1
  23. package/dist/host/daemon.d.ts +7 -3
  24. package/dist/host/daemon.js +13 -3
  25. package/dist/host/owner.d.ts +3 -0
  26. package/dist/host/owner.js +2 -0
  27. package/dist/host/run.d.ts +2 -3
  28. package/dist/host/run.js +5 -20
  29. package/dist/host/s1-names.d.ts +1 -0
  30. package/dist/host/s1-names.js +1 -0
  31. package/dist/host/verbs.d.ts +2 -0
  32. package/dist/host/verbs.js +11 -0
  33. package/dist/index.js +13 -9
  34. package/dist/lib/agent.d.ts +5 -3
  35. package/dist/lib/agent.js +6 -3
  36. package/dist/lib/claude-bin.d.ts +6 -0
  37. package/dist/lib/claude-bin.js +26 -0
  38. package/dist/lib/commit-report.d.ts +5 -4
  39. package/dist/lib/commit-report.js +6 -9
  40. package/dist/lib/config.d.ts +8 -1
  41. package/dist/lib/config.js +28 -0
  42. package/dist/lib/durable/launch.d.ts +28 -11
  43. package/dist/lib/durable/launch.js +33 -15
  44. package/dist/lib/durable/live.d.ts +15 -19
  45. package/dist/lib/durable/live.js +41 -43
  46. package/dist/lib/launcher-config.d.ts +6 -0
  47. package/dist/lib/launcher-config.js +11 -0
  48. package/dist/lib/runner/exec-append.d.ts +15 -0
  49. package/dist/lib/runner/exec-append.js +2 -0
  50. package/dist/lib/runner/executor-client.d.ts +2 -1
  51. package/dist/lib/runner/executor-state.d.ts +10 -0
  52. package/dist/lib/runner/executor-state.js +24 -0
  53. package/dist/lib/runner/github-base.d.ts +24 -0
  54. package/dist/lib/runner/github-base.js +27 -0
  55. package/dist/lib/runner/job.d.ts +9 -4
  56. package/dist/lib/runner/job.js +43 -15
  57. package/dist/lib/runner/oidc.d.ts +3 -2
  58. package/dist/lib/runner/oidc.js +7 -4
  59. package/dist/lib/runner/options.d.ts +6 -0
  60. package/dist/lib/runner/options.js +19 -1
  61. package/dist/lib/runner/phases.d.ts +7 -4
  62. package/dist/lib/runner/phases.js +8 -4
  63. package/dist/lib/runner/proxy.d.ts +5 -1
  64. package/dist/lib/runner/proxy.js +7 -1
  65. package/dist/lib/scan/cost-per-pr.d.ts +93 -0
  66. package/dist/lib/scan/cost-per-pr.js +101 -0
  67. package/dist/lib/scan/record.d.ts +27 -1
  68. package/dist/lib/scan/record.js +114 -16
  69. package/dist/lib/scan/report-email.d.ts +25 -5
  70. package/dist/lib/scan/report-email.js +23 -2
  71. package/dist/lib/scan/scan.d.ts +3 -0
  72. package/dist/lib/scan/scan.js +13 -1
  73. package/package.json +1 -1
  74. package/dist/lib/durable/first-turn.d.ts +0 -50
  75. package/dist/lib/durable/first-turn.js +0 -102
@@ -8,11 +8,12 @@
8
8
  * is oidc_<code> for S1's OIDC_EXCHANGE_REFUSALS, anything else oidc_unavailable. The token never
9
9
  * reaches argv, a log line or an outcome.
10
10
  *
11
- * S1 names the route but not its answer's field; `executor_key` is this runner's reading, open
12
- * with the Durable lead until S1 freezes it (no api handler exists yet to read it from).
11
+ * S1 names the route but not its answer's fields; `executor_key` (and `executor_id`, which binds
12
+ * the bundle) is this runner's reading, open with the Durable lead until S1 freezes it (no api handler exists yet to read it from).
13
13
  */
14
14
  import { OIDC_EXCHANGE_AUDIENCE, OIDC_EXCHANGE_REFUSALS, routePath, } from "../../vendor/durable-contract/index.js";
15
15
  import { errorCode } from "./executor-client.js";
16
+ import { UUID_RE } from "./options.js";
16
17
  const unavailable = (detail) => ({
17
18
  ok: false,
18
19
  reason: "oidc_unavailable",
@@ -72,9 +73,11 @@ export async function exchangeOidc(opts) {
72
73
  }
73
74
  const body = await readJson(res);
74
75
  if (res.status === 200) {
75
- const key = body?.executor_key;
76
+ const b = body;
77
+ const key = b?.executor_key;
78
+ const id = typeof b?.executor_id === "string" && UUID_RE.test(b.executor_id) ? b.executor_id : null;
76
79
  if (typeof key === "string" && key !== "")
77
- return { ok: true, executorKey: key };
80
+ return { ok: true, executorKey: key, executorId: id };
78
81
  return unavailable("exchange answer has no executor_key");
79
82
  }
80
83
  const code = errorCode(body);
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { type RunnerFailMode } from "./settings.js";
8
8
  export declare const EXECUTOR_KEY_ENV = "MNEMOM_EXECUTOR_KEY";
9
+ /** The enrolled executor's own id (executor-state.json `executor_id`): the bundle binds to it. */
10
+ export declare const EXECUTOR_ID_ENV = "MNEMOM_EXECUTOR_ID";
9
11
  export declare const ACTIONS_ID_TOKEN_URL_ENV = "ACTIONS_ID_TOKEN_REQUEST_URL";
10
12
  export declare const ACTIONS_ID_TOKEN_TOKEN_ENV = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
11
13
  export interface RunFlags {
@@ -28,6 +30,8 @@ export interface RunPlan {
28
30
  failMode: RunnerFailMode;
29
31
  json: boolean;
30
32
  credential: RunCredential;
33
+ /** This runner's executor id, when the env names one; else the claim or exchange answer's. */
34
+ executorId: string | null;
31
35
  /** Warnings to print on stderr (an unknown fail mode reads as closed). */
32
36
  warnings: string[];
33
37
  }
@@ -38,4 +42,6 @@ export type RunPlanResult = {
38
42
  ok: false;
39
43
  usage: string;
40
44
  };
45
+ /** A lowercase uuid: the executor id's only accepted form. */
46
+ export declare const UUID_RE: RegExp;
41
47
  export declare function resolveRunPlan(flags: RunFlags, env: NodeJS.ProcessEnv): RunPlanResult;
@@ -6,10 +6,14 @@
6
6
  */
7
7
  import { resolveFailMode, RUNNER_FAIL_MODE_ENV } from "./settings.js";
8
8
  export const EXECUTOR_KEY_ENV = "MNEMOM_EXECUTOR_KEY";
9
+ /** The enrolled executor's own id (executor-state.json `executor_id`): the bundle binds to it. */
10
+ export const EXECUTOR_ID_ENV = "MNEMOM_EXECUTOR_ID";
9
11
  export const ACTIONS_ID_TOKEN_URL_ENV = "ACTIONS_ID_TOKEN_REQUEST_URL";
10
12
  export const ACTIONS_ID_TOKEN_TOKEN_ENV = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
11
13
  const usage = (message) => ({ ok: false, usage: message });
12
14
  const set = (v) => v !== undefined && v.trim() !== "";
15
+ /** A lowercase uuid: the executor id's only accepted form. */
16
+ export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
13
17
  export function resolveRunPlan(flags, env) {
14
18
  if (Boolean(flags.watch) === Boolean(flags.once)) {
15
19
  return usage("mnemom agent run: give exactly one of --watch or --once");
@@ -40,8 +44,22 @@ export function resolveRunPlan(flags, env) {
40
44
  }
41
45
  credential = { kind: "executor_key", key: key.trim() };
42
46
  }
47
+ let executorId = null;
48
+ if (set(env[EXECUTOR_ID_ENV])) {
49
+ executorId = env[EXECUTOR_ID_ENV].trim();
50
+ if (!UUID_RE.test(executorId)) {
51
+ return usage(`mnemom agent run: ${EXECUTOR_ID_ENV} is not a lowercase uuid`);
52
+ }
53
+ }
43
54
  return {
44
55
  ok: true,
45
- plan: { form, failMode: fail.mode, json: Boolean(flags.json), credential, warnings },
56
+ plan: {
57
+ form,
58
+ failMode: fail.mode,
59
+ json: Boolean(flags.json),
60
+ credential,
61
+ executorId,
62
+ warnings,
63
+ },
46
64
  };
47
65
  }
@@ -5,8 +5,9 @@
5
5
  * verified bundle, in its order. A capabilities phase runs headless Claude Code with exactly the
6
6
  * tools its capability ids grant (an unknown id fails the phase; nothing is ever widened). A code
7
7
  * phase runs the bundle's JavaScript in a child node. Both get a scrubbed environment: a fresh
8
- * HOME, the model URL of the runner's own proxy and the model credential, never the executor key,
9
- * the lease key or a GitHub token.
8
+ * HOME, the model URL of the runner's own proxy and a fixed placeholder model key, never a real
9
+ * provider key, the executor key, the lease key or a GitHub token. The gateway authenticates the
10
+ * phase's model requests on the lease key the proxy adds, so claude only needs some key to start.
10
11
  */
11
12
  import type { ServedPhase } from "./bundle.js";
12
13
  /** W-L12's capability ids (workflow mnemom.door2 v1), and the Claude Code tools each grants. */
@@ -14,8 +15,10 @@ export declare const CAPABILITY_TOOLS: Readonly<Record<string, {
14
15
  tools: string[];
15
16
  allowed: string[];
16
17
  }>>;
17
- /** Env names a phase may inherit: the model credential and a working PATH. Nothing else. */
18
- export declare const PHASE_ENV_PASSTHROUGH: readonly ["PATH", "ANTHROPIC_API_KEY", "LANG", "TZ"];
18
+ /** Env names a phase may inherit: a working PATH and locale. Nothing else. */
19
+ export declare const PHASE_ENV_PASSTHROUGH: readonly ["PATH", "LANG", "TZ"];
20
+ /** Not a secret: lets claude start; the proxy drops it before the gateway (ruling: option (i)). */
21
+ export declare const PHASE_MODEL_KEY_PLACEHOLDER = "mnemom-runner-placeholder";
19
22
  export type ToolGrant = {
20
23
  ok: true;
21
24
  tools: string[];
@@ -5,8 +5,9 @@
5
5
  * verified bundle, in its order. A capabilities phase runs headless Claude Code with exactly the
6
6
  * tools its capability ids grant (an unknown id fails the phase; nothing is ever widened). A code
7
7
  * phase runs the bundle's JavaScript in a child node. Both get a scrubbed environment: a fresh
8
- * HOME, the model URL of the runner's own proxy and the model credential, never the executor key,
9
- * the lease key or a GitHub token.
8
+ * HOME, the model URL of the runner's own proxy and a fixed placeholder model key, never a real
9
+ * provider key, the executor key, the lease key or a GitHub token. The gateway authenticates the
10
+ * phase's model requests on the lease key the proxy adds, so claude only needs some key to start.
10
11
  */
11
12
  /** W-L12's capability ids (workflow mnemom.door2 v1), and the Claude Code tools each grants. */
12
13
  export const CAPABILITY_TOOLS = Object.freeze({
@@ -20,8 +21,10 @@ export const CAPABILITY_TOOLS = Object.freeze({
20
21
  // The runner opens the PR itself once every phase has passed; the model gets no tool for it.
21
22
  "pr.open": { tools: [], allowed: [] },
22
23
  });
23
- /** Env names a phase may inherit: the model credential and a working PATH. Nothing else. */
24
- export const PHASE_ENV_PASSTHROUGH = ["PATH", "ANTHROPIC_API_KEY", "LANG", "TZ"];
24
+ /** Env names a phase may inherit: a working PATH and locale. Nothing else. */
25
+ export const PHASE_ENV_PASSTHROUGH = ["PATH", "LANG", "TZ"];
26
+ /** Not a secret: lets claude start; the proxy drops it before the gateway (ruling: option (i)). */
27
+ export const PHASE_MODEL_KEY_PLACEHOLDER = "mnemom-runner-placeholder";
25
28
  export function toolsFor(capabilityIds) {
26
29
  const unknown = capabilityIds.filter((id) => !Object.hasOwn(CAPABILITY_TOOLS, id));
27
30
  if (unknown.length > 0)
@@ -54,6 +57,7 @@ export function phaseEnv(parent, home, modelUrl) {
54
57
  env[name] = v;
55
58
  }
56
59
  env.HOME = home;
60
+ env.ANTHROPIC_API_KEY = PHASE_MODEL_KEY_PLACEHOLDER;
57
61
  env.ANTHROPIC_BASE_URL = modelUrl;
58
62
  env.MNEMOM_RUNNER_MODEL_URL = modelUrl;
59
63
  env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
@@ -3,17 +3,21 @@
3
3
  *
4
4
  * A served phase's Claude Code (and a code phase's child) points ANTHROPIC_BASE_URL here, on
5
5
  * loopback. The proxy forwards each request to the gateway and decides every Mnemom header itself:
6
- * any `x-mnemom-*` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, and the job's
6
+ * any `x-mnemom-*`, `x-api-key` or `authorization` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, the job read's agent id in
7
+ * x-mnemom-agent-id (the gateway checks it against the lease's session), and the job's
7
8
  * sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
8
9
  * sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
9
10
  * which refetches and continues only on a higher sealed version, else fails closed.
10
11
  */
11
12
  import { request as httpRequest, type IncomingMessage, type ServerResponse } from "node:http";
12
13
  export declare const LEASE_KEY_HEADER = "x-mnemom-api-key";
14
+ export declare const AGENT_ID_HEADER = "x-mnemom-agent-id";
13
15
  export interface ProxyState {
14
16
  /** The sealed header value to send; swapped in place after an accepted amend. */
15
17
  contractValue: string;
16
18
  leaseKey: string;
19
+ /** The session's agent, from the job read; always sent, a phase's own value never is. */
20
+ agentId: string;
17
21
  }
18
22
  export interface ForwardedRequest {
19
23
  method: string;
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * A served phase's Claude Code (and a code phase's child) points ANTHROPIC_BASE_URL here, on
5
5
  * loopback. The proxy forwards each request to the gateway and decides every Mnemom header itself:
6
- * any `x-mnemom-*` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, and the job's
6
+ * any `x-mnemom-*`, `x-api-key` or `authorization` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, the job read's agent id in
7
+ * x-mnemom-agent-id (the gateway checks it against the lease's session), and the job's
7
8
  * sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
8
9
  * sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
9
10
  * which refetches and continues only on a higher sealed version, else fails closed.
@@ -12,6 +13,7 @@ import { createServer, request as httpRequest, } from "node:http";
12
13
  import { request as httpsRequest } from "node:https";
13
14
  import { applySealedContract, CONTRACT_HEADER } from "./contract-header.js";
14
15
  export const LEASE_KEY_HEADER = "x-mnemom-api-key";
16
+ export const AGENT_ID_HEADER = "x-mnemom-agent-id";
15
17
  /** Hop-by-hop headers (RFC 9110 7.6.1) and the ones the proxy owns. */
16
18
  const DROP = new Set([
17
19
  "connection",
@@ -23,6 +25,9 @@ const DROP = new Set([
23
25
  "trailer",
24
26
  "host",
25
27
  "content-length",
28
+ // The phase's model key is a placeholder; the gateway authenticates on the lease key alone.
29
+ "x-api-key",
30
+ "authorization",
26
31
  ]);
27
32
  /** Outbound headers for one request: the phase's own, minus anything the proxy decides. */
28
33
  export function outboundHeaders(incoming, state) {
@@ -34,6 +39,7 @@ export function outboundHeaders(incoming, state) {
34
39
  kept[lower] = value;
35
40
  }
36
41
  kept[LEASE_KEY_HEADER] = state.leaseKey;
42
+ kept[AGENT_ID_HEADER] = state.agentId;
37
43
  return applySealedContract(kept, state.contractValue);
38
44
  }
39
45
  /** The largest 409 body the proxy reads for a contract mismatch. */
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Cost per merged PR and cost by PR size, for the Org Scan report. Pure: numbers in, numbers out.
3
+ *
4
+ * Source of the spend. Measured per-request cost cannot be tied to a PR with the data the scan has:
5
+ * the Anthropic usage reads return one whole-org total (no per-request or per-PR rows) and the Mnemom
6
+ * Record totals are one window sum (model cost and a request count, no PR key). So every figure here is
7
+ * `label: "estimate"`: the period's spend split across the merged PRs in proportion to lines changed.
8
+ * Each figure names the spend it was split from (`spend_source`); sources are never mixed in one report.
9
+ *
10
+ * anthropic_usage the whole-org Anthropic cost for the period (the CTO's admin key)
11
+ * modelled_range our modelled estimate (a low to high range, so the figures are ranges)
12
+ *
13
+ * Size is per PR, in lines changed = additions + deletions, exactly as GitHub reports them. Lockfiles and
14
+ * generated files are NOT excluded. small < 50; medium 50..500 inclusive; large > 500.
15
+ *
16
+ * Each size figure is the average cost of ONE PR of that size: spend * (mean lines of the bucket) /
17
+ * (total lines of the PRs in scope). Scope: the modelled estimate prices AI-assisted PRs only, so it is
18
+ * split across those only (`pr_scope: "ai_assisted"`, buckets too); the others use every merged PR
19
+ * (`pr_scope: "all_merged"`). `pr_count` counts PRs in that scope. Usage-key figures also carry
20
+ * `spend_scope: "whole_org"` (the key covers the whole organization, not just the scanned repos). The cost per merged PR is the spend over the count of merged PRs.
21
+ *
22
+ * Missing data is an absent field, never 0 or NaN: no usable spend, zero merged PRs in the period, an
23
+ * empty bucket, a scan that did not read every merged PR in the period, or a result past safe-integer
24
+ * range. Reverts are listed without sizes, so they are not counted as merged PRs here.
25
+ */
26
+ import type { SpendEstimate } from "./spend/spend-estimate.js";
27
+ /** Strictly below this is small. 50 is medium. */
28
+ export declare const SMALL_MAX_EXCLUSIVE = 50;
29
+ /** At or below this (and not small) is medium. 500 is medium; 501 is large. */
30
+ export declare const MEDIUM_MAX_INCLUSIVE = 500;
31
+ export type SizeBucket = "small" | "medium" | "large";
32
+ export type CostSpendSourceName = "anthropic_usage" | "modelled_range";
33
+ export interface PrSize {
34
+ /** YYYY-MM-DD, UTC. */
35
+ mergedDay: string;
36
+ /** additions + deletions. */
37
+ lines: number;
38
+ /** The PR carries an AI-tool mark (the same rule the modelled spend uses to count AI-assisted PRs). */
39
+ aiAssisted: boolean;
40
+ }
41
+ /** Where the spend comes from. USD, never cents. */
42
+ export type CostSpend = {
43
+ source: "anthropic_usage";
44
+ usd: number;
45
+ } | {
46
+ source: "modelled_range";
47
+ lowUsd: number;
48
+ highUsd: number;
49
+ };
50
+ /** One figure: a single value, or a low to high range for the modelled source. Integer cents. */
51
+ export type PrScope = "ai_assisted" | "all_merged";
52
+ export type CostFigure = {
53
+ /** "measured" is not possible with current data (see header); every figure is an estimate. */
54
+ label: "estimate";
55
+ spend_source: CostSpendSourceName;
56
+ /**
57
+ * Which PRs the average is over. The modelled estimate prices AI-assisted PRs only, so it is split
58
+ * across those only ("ai_assisted"); the usage key covers all spend, so they use "all_merged".
59
+ */
60
+ pr_scope: PrScope;
61
+ /** PRs in that scope. */
62
+ pr_count: number;
63
+ /** Present on anthropic_usage only: the key's usage is the whole organization's, not just these PRs'. */
64
+ spend_scope?: "whole_org";
65
+ } & ({
66
+ value_cents: number;
67
+ } | {
68
+ low_cents: number;
69
+ high_cents: number;
70
+ });
71
+ export interface CostPerPr {
72
+ cost_per_merged_pr?: CostFigure;
73
+ cost_by_pr_size?: Partial<Record<SizeBucket, CostFigure>>;
74
+ }
75
+ export declare const sizeBucket: (lines: number) => SizeBucket;
76
+ /** Inclusive day count of YYYY-MM-DD..YYYY-MM-DD, or null if unparsable or reversed. */
77
+ export declare function periodDays(p: {
78
+ start: string;
79
+ end: string;
80
+ }): number | null;
81
+ /** The period's spend in USD as [low, high]; null when there is no usable spend. */
82
+ export declare function spendForPeriod(spend: CostSpend): [number, number] | null;
83
+ /** The modelled estimate as a spend source, or null when the estimate is "none". */
84
+ export declare const modelledSpend: (e: SpendEstimate) => CostSpend | null;
85
+ /**
86
+ * @param prs every merged PR the scan read, with merge day and size
87
+ * @param exhaustive true only if the scan read EVERY merged PR in the period (no sample, no skipped
88
+ * large PR, none unreadable, no cap hit). Otherwise the average is not defined.
89
+ */
90
+ export declare function buildCostPerPr(prs: readonly PrSize[] | undefined, period: {
91
+ start: string;
92
+ end: string;
93
+ }, spend: CostSpend | null, exhaustive: boolean): CostPerPr;
@@ -0,0 +1,101 @@
1
+ /** Strictly below this is small. 50 is medium. */
2
+ export const SMALL_MAX_EXCLUSIVE = 50;
3
+ /** At or below this (and not small) is medium. 500 is medium; 501 is large. */
4
+ export const MEDIUM_MAX_INCLUSIVE = 500;
5
+ export const sizeBucket = (lines) => lines < SMALL_MAX_EXCLUSIVE ? "small" : lines <= MEDIUM_MAX_INCLUSIVE ? "medium" : "large";
6
+ const DAY_MS = 86_400_000;
7
+ /** Inclusive day count of YYYY-MM-DD..YYYY-MM-DD, or null if unparsable or reversed. */
8
+ export function periodDays(p) {
9
+ const s = Date.parse(`${p.start}T00:00:00Z`);
10
+ const e = Date.parse(`${p.end}T00:00:00Z`);
11
+ if (!Number.isFinite(s) || !Number.isFinite(e) || e < s)
12
+ return null;
13
+ return Math.round((e - s) / DAY_MS) + 1;
14
+ }
15
+ const pos = (n) => typeof n === "number" && Number.isFinite(n) && n > 0;
16
+ /** The period's spend in USD as [low, high]; null when there is no usable spend. */
17
+ export function spendForPeriod(spend) {
18
+ let r;
19
+ if (spend.source === "anthropic_usage") {
20
+ if (!pos(spend.usd))
21
+ return null;
22
+ r = [spend.usd, spend.usd];
23
+ }
24
+ else {
25
+ if (!pos(spend.highUsd) || !(spend.lowUsd >= 0) || spend.lowUsd > spend.highUsd)
26
+ return null;
27
+ r = [spend.lowUsd, spend.highUsd];
28
+ }
29
+ return r.every((x) => Number.isFinite(x) && x * 100 <= Number.MAX_SAFE_INTEGER) ? r : null;
30
+ }
31
+ /** The modelled estimate as a spend source, or null when the estimate is "none". */
32
+ export const modelledSpend = (e) => e.kind === "range" ? { source: "modelled_range", lowUsd: e.lowUsd, highUsd: e.highUsd } : null;
33
+ /**
34
+ * `share` is the fraction of the period's spend one average PR here carries: always in (0, 1] by
35
+ * construction, and the spend is already bounded to safe-integer cents by spendForPeriod, so the
36
+ * rounded cents are finite safe integers without a further guard.
37
+ */
38
+ function figure(spend, [low, high], count, share, scope) {
39
+ const c = (usd) => Math.round(usd * share * 100);
40
+ const base = {
41
+ label: "estimate",
42
+ spend_source: spend.source,
43
+ pr_scope: scope,
44
+ pr_count: count,
45
+ ...(spend.source === "anthropic_usage" ? { spend_scope: "whole_org" } : {}),
46
+ };
47
+ return spend.source === "modelled_range"
48
+ ? { ...base, low_cents: c(low), high_cents: c(high) }
49
+ : { ...base, value_cents: c(low) };
50
+ }
51
+ /**
52
+ * @param prs every merged PR the scan read, with merge day and size
53
+ * @param exhaustive true only if the scan read EVERY merged PR in the period (no sample, no skipped
54
+ * large PR, none unreadable, no cap hit). Otherwise the average is not defined.
55
+ */
56
+ export function buildCostPerPr(prs, period, spend, exhaustive) {
57
+ if (!exhaustive || !spend || !Array.isArray(prs))
58
+ return {};
59
+ if (periodDays(period) === null)
60
+ return {};
61
+ const total = spendForPeriod(spend);
62
+ if (!total)
63
+ return {};
64
+ const inPeriod = prs.filter((p) => p.mergedDay >= period.start &&
65
+ p.mergedDay <= period.end &&
66
+ Number.isSafeInteger(p.lines) &&
67
+ p.lines >= 0);
68
+ // The modelled estimate is AI-tool spend, so it is split across AI-assisted PRs only; the usage key is
69
+ // the whole spend, so it is split across every merged PR.
70
+ const scope = spend.source === "modelled_range" ? "ai_assisted" : "all_merged";
71
+ const scoped = scope === "ai_assisted" ? inPeriod.filter((p) => p.aiAssisted) : inPeriod;
72
+ const n = scoped.length;
73
+ if (n === 0)
74
+ return {};
75
+ const out = {};
76
+ out.cost_per_merged_pr = figure(spend, total, n, 1 / n, scope);
77
+ let allLines = 0;
78
+ const agg = {
79
+ small: { n: 0, lines: 0 },
80
+ medium: { n: 0, lines: 0 },
81
+ large: { n: 0, lines: 0 },
82
+ };
83
+ for (const p of scoped) {
84
+ allLines += p.lines;
85
+ const a = agg[sizeBucket(p.lines)];
86
+ a.n += 1;
87
+ a.lines += p.lines;
88
+ }
89
+ // Splitting by lines needs lines: with none (or a sum past safe range) there is no size figure.
90
+ if (allLines <= 0 || !Number.isSafeInteger(allLines))
91
+ return out;
92
+ const bySize = {};
93
+ for (const b of ["small", "medium", "large"]) {
94
+ const a = agg[b];
95
+ if (a.n === 0)
96
+ continue;
97
+ bySize[b] = figure(spend, total, a.n, a.lines / a.n / allLines, scope);
98
+ }
99
+ out.cost_by_pr_size = bySize;
100
+ return out;
101
+ }
@@ -4,10 +4,19 @@
4
4
  * Read-only and local: the answer is held in memory to build the report and is never uploaded or
5
5
  * stored (mnemom-api#4211: Mnemom stores no scan result).
6
6
  *
7
- * The window is always the last 30 days ending now (the contract's maximum). It is its own
7
+ * The window is the last 30 days ending now, less a one-minute margin at the start so it stays
8
+ * inside the API's retention floor (the contract's maximum is 30 days). It is its own
8
9
  * window, not the PR scan period, and the report labels it "last 30 days"; no other scan-window
9
10
  * number is ever produced here.
10
11
  *
12
+ * An org with more requests in the window than the API will total in one read (it answers 400
13
+ * "window holds more than N requests: narrow it") is read in pieces: the failing window is halved
14
+ * at its midpoint, recursively, and the pieces are summed. The API's window is half-open
15
+ * [from, to), so pieces [from, mid) and [mid, to) neither overlap nor leave a gap. Fan-out is
16
+ * bounded (RECORD_MAX_REQUESTS reads, no piece split below RECORD_MIN_SPLIT_MS per half), and a
17
+ * total is all or nothing: any piece that fails for any other reason makes the whole read
18
+ * "unavailable", never a partial sum.
19
+ *
11
20
  * Outcomes are a closed set, and every one has fixed text. The thrown value, the response body
12
21
  * and the credential never reach the terminal:
13
22
  * ok totals read and shape-checked
@@ -19,6 +28,21 @@ import { type RecordWindowTotals } from "../api.js";
19
28
  export declare const RECORD_WINDOW_DAYS = 30;
20
29
  export declare const RECORD_WINDOW_LABEL = "last 30 days";
21
30
  export declare const RECORD_TIMEOUT_MS = 10000;
31
+ /**
32
+ * The API 400s a `from` earlier than its own now minus 30 days (retention floor, strict on purpose:
33
+ * mnemom-api#4310). Its clock runs later than ours by latency, so a window opening exactly 30 days
34
+ * before our now is always past that floor. Open it this much after instead; `to` stays our now
35
+ * (the API accepts a `to` up to 60s ahead of its clock: WINDOW_SKEW_MS in mnemom-api
36
+ * src/record/totals.ts). Ending at now centres the budget: the window is accepted while our clock is
37
+ * within 60s of the API's in either direction; ending earlier would trade away the floor side.
38
+ */
39
+ export declare const RECORD_FLOOR_MARGIN_MS = 60000;
40
+ /** Most reads one scan may make (the first full-window read included). */
41
+ export declare const RECORD_MAX_REQUESTS = 64;
42
+ /** Wall-clock budget for all the reads of one scan; past it the result is "unavailable". */
43
+ export declare const RECORD_TOTAL_TIMEOUT_MS = 60000;
44
+ /** A window is split only if both halves stay at least this long. */
45
+ export declare const RECORD_MIN_SPLIT_MS: number;
22
46
  export declare const isOrgId: (v: unknown) => v is string;
23
47
  export declare const RECORD_AUTH_MSG = "Record totals were refused for this org. Run `mnemom login` (or set MNEMOM_API_KEY) with an owner, admin or auditor of the org passed to --org, then retry.";
24
48
  export declare class RecordAuthError extends Error {
@@ -41,6 +65,8 @@ export interface RecordDeps {
41
65
  fetchTotals?: (orgId: string, from: string, to: string, signal: AbortSignal) => Promise<unknown>;
42
66
  now?: () => number;
43
67
  timeoutMs?: number;
68
+ /** Budget for every read of one total together (default RECORD_TOTAL_TIMEOUT_MS). */
69
+ totalTimeoutMs?: number;
44
70
  }
45
71
  export declare function recordWindow(nowMs: number): RecordWindow;
46
72
  /** Shape-check the body: a malformed answer is "unavailable", never rendered. */
@@ -4,10 +4,19 @@
4
4
  * Read-only and local: the answer is held in memory to build the report and is never uploaded or
5
5
  * stored (mnemom-api#4211: Mnemom stores no scan result).
6
6
  *
7
- * The window is always the last 30 days ending now (the contract's maximum). It is its own
7
+ * The window is the last 30 days ending now, less a one-minute margin at the start so it stays
8
+ * inside the API's retention floor (the contract's maximum is 30 days). It is its own
8
9
  * window, not the PR scan period, and the report labels it "last 30 days"; no other scan-window
9
10
  * number is ever produced here.
10
11
  *
12
+ * An org with more requests in the window than the API will total in one read (it answers 400
13
+ * "window holds more than N requests: narrow it") is read in pieces: the failing window is halved
14
+ * at its midpoint, recursively, and the pieces are summed. The API's window is half-open
15
+ * [from, to), so pieces [from, mid) and [mid, to) neither overlap nor leave a gap. Fan-out is
16
+ * bounded (RECORD_MAX_REQUESTS reads, no piece split below RECORD_MIN_SPLIT_MS per half), and a
17
+ * total is all or nothing: any piece that fails for any other reason makes the whole read
18
+ * "unavailable", never a partial sum.
19
+ *
11
20
  * Outcomes are a closed set, and every one has fixed text. The thrown value, the response body
12
21
  * and the credential never reach the terminal:
13
22
  * ok totals read and shape-checked
@@ -19,8 +28,23 @@ import { getRecordWindowTotals, MnemomApiError } from "../api.js";
19
28
  export const RECORD_WINDOW_DAYS = 30;
20
29
  export const RECORD_WINDOW_LABEL = "last 30 days";
21
30
  export const RECORD_TIMEOUT_MS = 10_000;
22
- /** `to` may not be in the future; stay a minute behind so a fast local clock cannot trip it. */
23
- const CLOCK_SKEW_MS = 60_000;
31
+ /**
32
+ * The API 400s a `from` earlier than its own now minus 30 days (retention floor, strict on purpose:
33
+ * mnemom-api#4310). Its clock runs later than ours by latency, so a window opening exactly 30 days
34
+ * before our now is always past that floor. Open it this much after instead; `to` stays our now
35
+ * (the API accepts a `to` up to 60s ahead of its clock: WINDOW_SKEW_MS in mnemom-api
36
+ * src/record/totals.ts). Ending at now centres the budget: the window is accepted while our clock is
37
+ * within 60s of the API's in either direction; ending earlier would trade away the floor side.
38
+ */
39
+ export const RECORD_FLOOR_MARGIN_MS = 60_000;
40
+ /** Most reads one scan may make (the first full-window read included). */
41
+ export const RECORD_MAX_REQUESTS = 64;
42
+ /** Wall-clock budget for all the reads of one scan; past it the result is "unavailable". */
43
+ export const RECORD_TOTAL_TIMEOUT_MS = 60_000;
44
+ /** A window is split only if both halves stay at least this long. */
45
+ export const RECORD_MIN_SPLIT_MS = 60 * 60 * 1000;
46
+ /** The API's bigint ceiling: a larger sum cannot be a real total. */
47
+ const MAX_MMU = 9223372036854775807n;
24
48
  const DAY_MS = 24 * 60 * 60 * 1000;
25
49
  const ORG_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
26
50
  const MMU_RE = /^(0|[1-9][0-9]{0,18})$/;
@@ -33,9 +57,9 @@ export class RecordAuthError extends Error {
33
57
  }
34
58
  }
35
59
  export function recordWindow(nowMs) {
36
- const to = nowMs - CLOCK_SKEW_MS;
60
+ const to = nowMs;
37
61
  return {
38
- from: new Date(to - RECORD_WINDOW_DAYS * DAY_MS).toISOString(),
62
+ from: new Date(to - RECORD_WINDOW_DAYS * DAY_MS + RECORD_FLOOR_MARGIN_MS).toISOString(),
39
63
  to: new Date(to).toISOString(),
40
64
  };
41
65
  }
@@ -60,26 +84,100 @@ export function parseWindowTotals(v) {
60
84
  as_of: o.as_of,
61
85
  };
62
86
  }
87
+ /** The API's too-many-requests answer: a 400 saying the window holds more than N requests. */
88
+ const isWindowTooBig = (e) => e instanceof MnemomApiError &&
89
+ e.effectiveStatus === 400 &&
90
+ /holds more than \d+ requests/.test(e.message);
91
+ /** Latest of two as_of timestamps (a "latest row" field is combined by max, never added). */
92
+ function laterAsOf(a, b) {
93
+ if (a === null)
94
+ return b;
95
+ if (b === null)
96
+ return a;
97
+ const ta = Date.parse(a);
98
+ const tb = Date.parse(b);
99
+ if (Number.isNaN(ta) || Number.isNaN(tb))
100
+ throw new Error("unparsable as_of");
101
+ return tb > ta ? b : a;
102
+ }
103
+ /**
104
+ * Add two windows' totals. Costs, charges and row counts are additive over disjoint windows (a
105
+ * request sits in the window of its gateway row, and its billing row follows it). `mu_charged_mmu`
106
+ * is null for a window with no billing row, so it is null only when every piece is null, as the
107
+ * API's own multi-lineage sum does. `as_of` is the latest row's time, so it is the max.
108
+ */
109
+ function addTotals(a, b) {
110
+ const model = BigInt(a.model_cost_mmu) + BigInt(b.model_cost_mmu);
111
+ const mu = a.mu_charged_mmu === null && b.mu_charged_mmu === null
112
+ ? null
113
+ : BigInt(a.mu_charged_mmu ?? "0") + BigInt(b.mu_charged_mmu ?? "0");
114
+ const rows = a.row_count + b.row_count;
115
+ if (model > MAX_MMU || (mu !== null && mu > MAX_MMU) || !Number.isSafeInteger(rows)) {
116
+ throw new Error("summed total out of range");
117
+ }
118
+ return {
119
+ model_cost_mmu: model.toString(),
120
+ mu_charged_mmu: mu === null ? null : mu.toString(),
121
+ row_count: rows,
122
+ as_of: laterAsOf(a.as_of, b.as_of),
123
+ };
124
+ }
125
+ /** Any failure other than auth / not-in-cohort makes the whole total unavailable. */
126
+ class SplitFailed extends Error {
127
+ }
128
+ class NotInCohort extends Error {
129
+ }
63
130
  /** Never throws except RecordAuthError (401/403, fail closed). */
64
131
  export async function fetchRecordTotals(orgId, deps = {}) {
65
132
  if (!isOrgId(orgId))
66
133
  return { status: "unavailable" };
67
134
  const window = recordWindow((deps.now ?? Date.now)());
68
135
  const fetchTotals = deps.fetchTotals ?? getRecordWindowTotals;
69
- const signal = AbortSignal.timeout(deps.timeoutMs ?? RECORD_TIMEOUT_MS);
136
+ const timeoutMs = deps.timeoutMs ?? RECORD_TIMEOUT_MS;
137
+ const deadline = Date.now() + (deps.totalTimeoutMs ?? RECORD_TOTAL_TIMEOUT_MS);
138
+ let requests = 0;
139
+ /** Total of [fromMs, toMs). Throws RecordAuthError, NotInCohort (root 404 only) or SplitFailed. */
140
+ const read = async (fromMs, toMs, root) => {
141
+ const left = deadline - Date.now();
142
+ if (requests >= RECORD_MAX_REQUESTS || left <= 0)
143
+ throw new SplitFailed();
144
+ requests += 1;
145
+ try {
146
+ const body = await fetchTotals(orgId, new Date(fromMs).toISOString(), new Date(toMs).toISOString(), AbortSignal.timeout(Math.min(timeoutMs, left)));
147
+ const totals = parseWindowTotals(body);
148
+ if (!totals)
149
+ throw new SplitFailed();
150
+ return totals;
151
+ }
152
+ catch (e) {
153
+ if (e instanceof MnemomApiError) {
154
+ const s = e.effectiveStatus;
155
+ if (s === 401 || s === 403)
156
+ throw new RecordAuthError();
157
+ if (s === 404 && root)
158
+ throw new NotInCohort();
159
+ if (isWindowTooBig(e)) {
160
+ const mid = fromMs + Math.floor((toMs - fromMs) / 2);
161
+ if (mid - fromMs < RECORD_MIN_SPLIT_MS || toMs - mid < RECORD_MIN_SPLIT_MS) {
162
+ throw new SplitFailed();
163
+ }
164
+ const first = await read(fromMs, mid, false);
165
+ return addTotals(first, await read(mid, toMs, false));
166
+ }
167
+ }
168
+ // RecordAuthError / NotInCohort / nested failures above are thrown from this handler and never land here.
169
+ throw new SplitFailed();
170
+ }
171
+ };
70
172
  try {
71
- const body = await fetchTotals(orgId, window.from, window.to, signal);
72
- const totals = parseWindowTotals(body);
73
- return totals ? { status: "ok", totals, window } : { status: "unavailable" };
173
+ const totals = await read(Date.parse(window.from), Date.parse(window.to), true);
174
+ return { status: "ok", totals, window };
74
175
  }
75
176
  catch (e) {
76
- if (e instanceof MnemomApiError) {
77
- const s = e.effectiveStatus;
78
- if (s === 401 || s === 403)
79
- throw new RecordAuthError();
80
- if (s === 404)
81
- return { status: "not_in_cohort" };
82
- }
177
+ if (e instanceof RecordAuthError)
178
+ throw e;
179
+ if (e instanceof NotInCohort)
180
+ return { status: "not_in_cohort" };
83
181
  return { status: "unavailable" };
84
182
  }
85
183
  }