@mnemom/mnemom 0.17.31-next.1 → 0.17.32-next.0

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 (41) hide show
  1. package/dist/base-url-proxy.mjs +30 -0
  2. package/dist/commands/agent-fuel.d.ts +75 -0
  3. package/dist/commands/agent-fuel.js +298 -0
  4. package/dist/commands/agent-implicit-notice.d.ts +31 -0
  5. package/dist/commands/agent-implicit-notice.js +73 -0
  6. package/dist/commands/agent-session.d.ts +2 -1
  7. package/dist/commands/agent-session.js +2 -1
  8. package/dist/commands/agent.d.ts +52 -1
  9. package/dist/commands/agent.js +111 -26
  10. package/dist/commands/auth.js +20 -0
  11. package/dist/commands/status.js +1 -1
  12. package/dist/commands/update.js +3 -1
  13. package/dist/commands/validate.d.ts +1 -1
  14. package/dist/commands/validate.js +1 -1
  15. package/dist/index.js +51 -12
  16. package/dist/lib/agent.d.ts +32 -19
  17. package/dist/lib/agent.js +34 -32
  18. package/dist/lib/api.d.ts +53 -0
  19. package/dist/lib/api.js +31 -10
  20. package/dist/lib/canonical-json.d.ts +12 -0
  21. package/dist/lib/canonical-json.js +22 -0
  22. package/dist/lib/conduct-receipt.d.ts +81 -0
  23. package/dist/lib/conduct-receipt.js +177 -0
  24. package/dist/lib/contract-hash.d.ts +61 -0
  25. package/dist/lib/contract-hash.js +126 -0
  26. package/dist/lib/experimental.d.ts +10 -1
  27. package/dist/lib/experimental.js +35 -1
  28. package/dist/lib/oauth.d.ts +32 -1
  29. package/dist/lib/oauth.js +157 -44
  30. package/dist/lib/statusline-bin.d.ts +2 -2
  31. package/dist/lib/statusline-bin.js +4 -7
  32. package/dist/lib/statusline-feed.d.ts +33 -0
  33. package/dist/lib/statusline-feed.js +68 -0
  34. package/dist/lib/statusline.d.ts +39 -88
  35. package/dist/lib/statusline.js +58 -250
  36. package/dist/lib/update/decide.d.ts +22 -3
  37. package/dist/lib/update/decide.js +35 -12
  38. package/dist/lib/update/doctor.js +5 -1
  39. package/dist/lib/update/run.d.ts +2 -0
  40. package/dist/lib/update/run.js +22 -8
  41. package/package.json +4 -4
package/dist/lib/agent.js CHANGED
@@ -22,6 +22,7 @@
22
22
  import { createHash, randomUUID } from "node:crypto";
23
23
  import { readFileSync } from "node:fs";
24
24
  import { join } from "node:path";
25
+ import { canonicalJson } from "./canonical-json.js";
25
26
  import { getGatewayUrl } from "./config.js";
26
27
  import { normSession } from "./slack-route.js";
27
28
  /** The us-2/prod gateway host. The default door — never any other cell silently. */
@@ -438,6 +439,7 @@ export const AGENT_SUB_VERBS = new Set([
438
439
  "move",
439
440
  "export",
440
441
  "sessions",
442
+ "fuel",
441
443
  "show",
442
444
  "goal",
443
445
  ]);
@@ -480,23 +482,7 @@ export function isPositiveNumber(value) {
480
482
  const n = Number(s);
481
483
  return Number.isFinite(n) && n > 0;
482
484
  }
483
- /**
484
- * Canonical JSON — object keys sorted recursively, arrays in order, no
485
- * whitespace, `undefined` keys dropped. Byte-identical to the gateway's
486
- * `canonicalJson` (gateway/src/goal-contract.ts), so the base64 we send and the
487
- * sha we print match what the gateway seals.
488
- */
489
- export function canonicalJson(value) {
490
- if (value === null || typeof value !== "object")
491
- return JSON.stringify(value);
492
- if (Array.isArray(value))
493
- return `[${value.map(canonicalJson).join(",")}]`;
494
- const obj = value;
495
- const keys = Object.keys(obj)
496
- .filter((k) => obj[k] !== undefined)
497
- .sort();
498
- return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
499
- }
485
+ export { canonicalJson } from "./canonical-json.js";
500
486
  /** sha256 hex (first 16 chars) of a string — the operator-eyeball contract hash. */
501
487
  export function sha256Hex16(text) {
502
488
  return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16);
@@ -652,7 +638,8 @@ export const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
652
638
  /**
653
639
  * The gateway env key that has it measure each request's saving for a single session and put
654
640
  * it in the client-facing usage (`mnemom_savings` in the read `usage.iterations` entry), where
655
- * the status bar reads it from Claude Code's transcript. Default OFF on the gateway; sent `"on"`
641
+ * it lands in Claude Code's transcript. The status bar never shows it (decision 14: never
642
+ * "saved"). Default OFF on the gateway; sent `"on"`
656
643
  * only while the `measured-savings` experimental feature is on and the launched CLI is Claude
657
644
  * Code (`resolveUsageSavings`). Independent of folding, so every profile sends it — `off` too,
658
645
  * where the measured saving is honestly $0.00.
@@ -1283,9 +1270,9 @@ export function resolveCompactionIntercept(opts) {
1283
1270
  return !(raw === "0" || raw === "false" || raw === "off" || raw === "no");
1284
1271
  }
1285
1272
  /**
1286
- * Does the gateway measure this launch's saving (`GATEWAY_USAGE_SAVINGS` in the header, and the
1287
- * status bar in measured mode)? Only with the `measured-savings` experimental feature on, and
1288
- * only for Claude Code — the one CLI whose status bar reads the measurement. No flag or
1273
+ * Does the gateway measure this launch's saving (`GATEWAY_USAGE_SAVINGS` in the header)? Only
1274
+ * with the `measured-savings` experimental feature on, and only for Claude Code, whose
1275
+ * transcript keeps the measurement. No flag or
1289
1276
  * launcher.toml key: `mnemom experimental enable measured-savings` is the switch.
1290
1277
  */
1291
1278
  export function resolveUsageSavings(opts) {
@@ -1333,27 +1320,42 @@ export function buildHandoffMcpConfig(p) {
1333
1320
  export function buildHandoffMcpConfigJson(p) {
1334
1321
  return JSON.stringify(buildHandoffMcpConfig(p));
1335
1322
  }
1323
+ /** Does a Claude Code passthrough run headless (`-p` / `--print`)? */
1324
+ export function passthroughIsPrint(passthrough) {
1325
+ return passthrough.some((a) => a === "-p" || a === "--print" || a.startsWith("--print="));
1326
+ }
1336
1327
  /**
1337
1328
  * Is this launch implicit-mode? Precedence, highest wins:
1338
1329
  * 1. an explicit flag — `--implicit` (true) or `--no-implicit` (false);
1339
1330
  * 2. a `--goal` statement — a sealed contract is a per-launch choice that the
1340
- * standing config default must never silently override;
1331
+ * standing config default must never silently override, so a launch never
1332
+ * runs both modes;
1341
1333
  * 3. the config default `goal_mode` — `"off"` turns alignment off, `"implicit"`
1342
1334
  * keeps it implicit;
1343
- * 4. with the `implicit` experimental feature ON, IMPLICIT: the gateway infers
1344
- * the goal from the user's own messages (turn it off for a launch with
1345
- * `--no-implicit`, or standing with `goal_mode = "off"`); with it OFF (the
1346
- * default), OFF.
1335
+ * 4. with the `implicit` experimental feature ON, IMPLICIT (as before T16);
1336
+ * 5. on a prerelease (`next`) build, IMPLICIT for an interactive or Remote Control
1337
+ * launch and OFF for a headless `-p` one (OD-13: T16 never covers `-p`);
1338
+ * 6. otherwise OFF — stable without the experimental feature behaves as today.
1347
1339
  * Pure so every arm is unit-tested; the caller passes the resolved value on to
1348
1340
  * `assembleContract` / `buildGoalModeHeader` (which still refuse `--implicit`
1349
1341
  * combined with a contract flag — that conflict is EXPLICIT and is reported).
1350
1342
  */
1351
- export function resolveImplicit(opts) {
1343
+ export function resolveImplicitSource(opts) {
1352
1344
  if (opts.implicit !== undefined)
1353
- return opts.implicit;
1345
+ return { implicit: opts.implicit, source: "flag" };
1354
1346
  if (hasText(opts.goal))
1355
- return false;
1356
- if (opts.experimentalOn === false)
1357
- return false;
1358
- return opts.configGoalMode !== "off";
1347
+ return { implicit: false, source: "goal" };
1348
+ const experimentalOn = opts.experimentalOn !== false;
1349
+ if (!experimentalOn && !opts.prerelease)
1350
+ return { implicit: false, source: "default" };
1351
+ if (opts.configGoalMode !== undefined) {
1352
+ return { implicit: opts.configGoalMode === "implicit", source: "config" };
1353
+ }
1354
+ if (experimentalOn)
1355
+ return { implicit: true, source: "experimental" };
1356
+ return { implicit: !opts.print, source: opts.print ? "default" : "prerelease" };
1357
+ }
1358
+ /** `resolveImplicitSource(opts).implicit`. */
1359
+ export function resolveImplicit(opts) {
1360
+ return resolveImplicitSource(opts).implicit;
1359
1361
  }
package/dist/lib/api.d.ts CHANGED
@@ -236,6 +236,12 @@ export declare function verifyBinding(agentId: string, keyHash: string): Promise
236
236
  bound: boolean;
237
237
  key_prefix: string | null;
238
238
  }>;
239
+ /**
240
+ * GET /v1/agents/:id, sent with the caller's credentials. The API answers with the projection
241
+ * the caller may see: the org-member view (claimed_by, org_id, `caller: "org_member"`) for an
242
+ * agent in one of the caller's orgs, the public view otherwise. A pinned launch needs the member
243
+ * view, so an unauthenticated read here refuses every pin.
244
+ */
239
245
  export declare function getAgent(id: string): Promise<Agent>;
240
246
  export interface AgentListItem {
241
247
  id: string;
@@ -431,6 +437,19 @@ export interface MuBalance {
431
437
  effectiveMode?: string;
432
438
  enforcementPausedReason?: string | null;
433
439
  }
440
+ /**
441
+ * GET /v1/orgs/{org_id}/record/totals?lineage_key= — one lineage's model cost and μ as
442
+ * decimal-string millimu (`mu_charged_mmu` null until billing commits a charge). Dark unless the
443
+ * org is in the record-accumulator cohort: a 404 then, like an unknown lineage.
444
+ */
445
+ export interface RecordTotals {
446
+ lineage_key: string;
447
+ model_cost_mmu: string;
448
+ mu_charged_mmu: string | null;
449
+ as_of: string;
450
+ row_count: number;
451
+ }
452
+ export declare function getRecordTotals(orgId: string, lineageKey: string, signal?: AbortSignal): Promise<RecordTotals>;
434
453
  export declare function getMuBalance(orgId?: string): Promise<MuBalance>;
435
454
  /**
436
455
  * Mint the one-time `cards:write:protection` capability for an agent — the
@@ -1277,6 +1296,40 @@ export interface OrgUsageQuery {
1277
1296
  * the user "org not found" on what is most often just the flag being off.
1278
1297
  */
1279
1298
  export declare function getOrgUsage(orgId: string, opts?: OrgUsageQuery): Promise<OrgUsageResponse>;
1299
+ /** One sealed contract in the batch-seal response (mnemom-api migration 582). */
1300
+ export interface SealedContractRow {
1301
+ seal_id: string;
1302
+ ordinal: number;
1303
+ contract_sha256: string;
1304
+ /** The deterministic node identity: `contract:<org_id>:<sha256>`. */
1305
+ node_id: string;
1306
+ version: number;
1307
+ job_key: string | null;
1308
+ canon_status: "pending" | "written";
1309
+ }
1310
+ /** `POST /v1/orgs/{org_id}/contract-step/batch-seal` 200 body. */
1311
+ export interface BatchSealResponse {
1312
+ batch_id: string;
1313
+ replayed: boolean;
1314
+ repo: string;
1315
+ seals: SealedContractRow[];
1316
+ /** Whether the sealed contracts were handed to dispatch: off, enqueued, or deferred (fail open). */
1317
+ dispatch: "off" | "enqueued" | "deferred";
1318
+ }
1319
+ export interface BatchSealRequest {
1320
+ repo: string;
1321
+ source: "cli";
1322
+ contracts: Array<{
1323
+ contract: unknown;
1324
+ job_key?: string;
1325
+ }>;
1326
+ }
1327
+ /**
1328
+ * Seal a batch of job contracts in one transaction (Doors fuel step, W3.2). The caller passes the
1329
+ * Idempotency-Key and holds it across retries, so a retried confirmation replays the first batch
1330
+ * instead of sealing twice.
1331
+ */
1332
+ export declare function sealContractBatch(orgId: string, body: BatchSealRequest, idempotencyKey: string, githubToken: string): Promise<BatchSealResponse>;
1280
1333
  /** `GET /v1/code/entitlement` 200 body — why Mnemom Agent is enabled for the org. */
1281
1334
  export interface CodeEntitlement {
1282
1335
  org_id: string;
package/dist/lib/api.js CHANGED
@@ -112,14 +112,6 @@ export async function readApiError(response, fallback) {
112
112
  specDeviation: parsed.spec_deviation,
113
113
  });
114
114
  }
115
- async function fetchApi(endpoint) {
116
- const url = validateUrl(`${API_BASE}${endpoint}`);
117
- const response = await mnemomFetch(url);
118
- if (!response.ok) {
119
- throw await readApiError(response, "API request failed");
120
- }
121
- return response.json();
122
- }
123
115
  export async function postApi(endpoint, body, opts = {}) {
124
116
  const url = validateUrl(`${API_BASE}${endpoint}`);
125
117
  const headers = {
@@ -197,8 +189,19 @@ async function fetchWithAuthRetry(url, buildInit) {
197
189
  return first;
198
190
  return mnemomFetch(url, await buildInit());
199
191
  }
192
+ /**
193
+ * GET /v1/agents/:id, sent with the caller's credentials. The API answers with the projection
194
+ * the caller may see: the org-member view (claimed_by, org_id, `caller: "org_member"`) for an
195
+ * agent in one of the caller's orgs, the public view otherwise. A pinned launch needs the member
196
+ * view, so an unauthenticated read here refuses every pin.
197
+ */
200
198
  export async function getAgent(id) {
201
- return fetchApi(`/v1/agents/${id}`);
199
+ const url = validateUrl(`${API_BASE}/v1/agents/${id}`);
200
+ const response = await fetchWithAuthRetry(url, async () => ({ headers: await authHeaders() }));
201
+ if (!response.ok) {
202
+ throw await readApiError(response, "API request failed");
203
+ }
204
+ return (await response.json());
202
205
  }
203
206
  /**
204
207
  * Key-compatible agent listing — `GET /v1/agents` is scoped by the caller's
@@ -405,6 +408,9 @@ export async function getMyPersonalOrg() {
405
408
  export async function getMe() {
406
409
  return gFetch("/v1/auth/me", {}, { fallback: "Could not read your identity" });
407
410
  }
411
+ export async function getRecordTotals(orgId, lineageKey, signal) {
412
+ return gFetch(`/v1/orgs/${encodeURIComponent(orgId)}/record/totals?lineage_key=${encodeURIComponent(lineageKey)}`, signal ? { signal } : {}, { notFoundLabel: "Record totals", fallback: "Could not read record totals" });
413
+ }
408
414
  export async function getMuBalance(orgId) {
409
415
  const q = orgId ? `?org_id=${encodeURIComponent(orgId)}` : "";
410
416
  return gFetch(`/v1/billing/mu${q}`, {}, { fallback: "Could not read MU balance" });
@@ -747,7 +753,7 @@ function emptyIntegrityRow(agentId) {
747
753
  export async function getTraces(id, limit = 10) {
748
754
  const data = await gFetch(`/v1/traces?agent_id=${id}&limit=${limit}`, {}, { fallback: "API request failed" });
749
755
  // Accept both the envelope shape (current API) and a bare array (defensive
750
- // — staging is mid-deploy when a CLI smoke runs against an older API).
756
+ // — the test cell is mid-deploy when a CLI smoke runs against an older API).
751
757
  if (Array.isArray(data))
752
758
  return data;
753
759
  return data.traces ?? [];
@@ -1308,6 +1314,7 @@ async function gFetch(path, init = {}, opts = {}) {
1308
1314
  ...(init.headers ?? {}),
1309
1315
  },
1310
1316
  body: init.body,
1317
+ ...(init.signal ? { signal: init.signal } : {}),
1311
1318
  }));
1312
1319
  if (!response.ok) {
1313
1320
  if (response.status === 401)
@@ -1569,6 +1576,20 @@ async function codeFetch(path, init = {}, fallback) {
1569
1576
  }
1570
1577
  return (await response.json());
1571
1578
  }
1579
+ /**
1580
+ * Seal a batch of job contracts in one transaction (Doors fuel step, W3.2). The caller passes the
1581
+ * Idempotency-Key and holds it across retries, so a retried confirmation replays the first batch
1582
+ * instead of sealing twice.
1583
+ */
1584
+ export async function sealContractBatch(orgId, body, idempotencyKey, githubToken) {
1585
+ return codeFetch(`/v1/orgs/${encodeURIComponent(orgId)}/contract-step/batch-seal`, {
1586
+ method: "POST",
1587
+ body: JSON.stringify(body),
1588
+ // The api checks with this token that the caller can write to the repo (OD-10). It reads
1589
+ // it for that check only and never stores it.
1590
+ headers: { "Idempotency-Key": idempotencyKey, "X-GitHub-Token": githubToken },
1591
+ }, "Could not seal the contracts");
1592
+ }
1572
1593
  /** The machine-readable code on the gate's 403 when the org is not enabled. */
1573
1594
  export const CODE_NOT_ENABLED = "code_not_enabled";
1574
1595
  /**
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Canonical JSON: the one place the CLI serialises a contract for hashing. Kept in its own module,
3
+ * with no imports, so the contract hash helper and the thin runner can use it without loading
4
+ * agent.ts. `agent.ts` re-exports it for its existing importers.
5
+ */
6
+ /**
7
+ * Canonical JSON — object keys sorted recursively, arrays in order, no
8
+ * whitespace, `undefined` keys dropped. Byte-identical to the gateway's
9
+ * `canonicalJson` (gateway/src/goal-contract.ts), so the base64 we send and the
10
+ * sha we print match what the gateway seals.
11
+ */
12
+ export declare function canonicalJson(value: unknown): string;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Canonical JSON: the one place the CLI serialises a contract for hashing. Kept in its own module,
3
+ * with no imports, so the contract hash helper and the thin runner can use it without loading
4
+ * agent.ts. `agent.ts` re-exports it for its existing importers.
5
+ */
6
+ /**
7
+ * Canonical JSON — object keys sorted recursively, arrays in order, no
8
+ * whitespace, `undefined` keys dropped. Byte-identical to the gateway's
9
+ * `canonicalJson` (gateway/src/goal-contract.ts), so the base64 we send and the
10
+ * sha we print match what the gateway seals.
11
+ */
12
+ export function canonicalJson(value) {
13
+ if (value === null || typeof value !== "object")
14
+ return JSON.stringify(value);
15
+ if (Array.isArray(value))
16
+ return `[${value.map(canonicalJson).join(",")}]`;
17
+ const obj = value;
18
+ const keys = Object.keys(obj)
19
+ .filter((k) => obj[k] !== undefined)
20
+ .sort();
21
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
22
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The conduct receipt (experimental `record`, Doors W1.2 / W1.3): the engineer's private view of
3
+ * one session, printed when `mnemom agent` exits and when a durable session parks.
4
+ *
5
+ * Conduct receipt
6
+ * model cost $0.42
7
+ * μ 12.5
8
+ *
9
+ * Every figure is measured, never estimated (decision 14): model cost and μ are the session
10
+ * lineage's `record_accumulator` totals, read from `GET /v1/orgs/{org}/record/totals`. μ reads
11
+ * "μ pending" until billing has committed a charge for the lineage (the route's null), and the
12
+ * receipt then gives a link instead of a number.
13
+ *
14
+ * NEVER BLOCKS EXIT. The whole read, auth refresh included, races a hard deadline
15
+ * (RECEIPT_DEADLINE_MS): with the API or the record builder down, exit takes at most that much
16
+ * longer, and the receipt falls back to the link. The caller keeps its exit code; nothing here
17
+ * throws.
18
+ *
19
+ * The lineage key is the gateway's (x-mnemom-lineage-key, set on every response), captured by the
20
+ * local base-URL proxy into a state file: the CLI never derives one itself.
21
+ */
22
+ /** The hard ceiling on what the receipt adds to exit (W1.3: at most 2 s). */
23
+ export declare const RECEIPT_DEADLINE_MS = 1800;
24
+ /** Pause between totals reads while μ is still pending. */
25
+ export declare const RECEIPT_POLL_MS = 400;
26
+ /** What the local proxy saw on the session's responses (base-url-proxy.mjs, record state). */
27
+ export interface RecordState {
28
+ lineageKey?: string;
29
+ recordUrl?: string;
30
+ }
31
+ /** A totals read: the route's two amounts, or why there are none. */
32
+ export type TotalsRead = {
33
+ kind: "ok";
34
+ modelCostMmu: bigint;
35
+ muChargedMmu: bigint | null;
36
+ } | {
37
+ kind: "unavailable";
38
+ };
39
+ export interface ReceiptDeps {
40
+ /** One totals read for the lineage; may throw or hang (both are "unavailable"). */
41
+ readTotals: (lineageKey: string, signal: AbortSignal) => Promise<TotalsRead>;
42
+ /** The page the receipt links to when no record URL was captured (the agent dashboard). */
43
+ fallbackUrl: string;
44
+ now?: () => number;
45
+ sleep?: (ms: number) => Promise<void>;
46
+ deadlineMs?: number;
47
+ pollMs?: number;
48
+ }
49
+ /** Read the proxy's record-state file; a missing or malformed file is an empty state. */
50
+ export declare function readRecordState(path: string | undefined): RecordState;
51
+ /** Keep only a well-formed lineage key and an https record URL. */
52
+ export declare function sanitizeRecordState(raw: Record<string, unknown>): RecordState;
53
+ /** Parse the totals route body; anything off-contract is "unavailable", never a guessed figure. */
54
+ export declare function parseTotals(body: unknown): TotalsRead;
55
+ /** `$0.42`; a non-zero cost under a cent reads `< $0.01`, never `$0.00`. */
56
+ export declare function formatUsd(mmu: bigint): string;
57
+ /** μ to at most three decimals, trailing zeros dropped: 12500 mmu → `12.5`. */
58
+ export declare function formatMu(mmu: bigint): string;
59
+ /** The receipt lines for what was read (pure). */
60
+ export declare function receiptLines(read: TotalsRead | null, link: string, trigger?: "exit" | "park"): string[];
61
+ /**
62
+ * Read the lineage's totals until μ is final or the deadline passes, then return the receipt.
63
+ * Never throws and never waits past `deadlineMs`.
64
+ */
65
+ export declare function buildConductReceipt(state: RecordState, deps: ReceiptDeps, trigger?: "exit" | "park"): Promise<string[]>;
66
+ /** Where the receipt's launch facts come from (the launcher at exit; Durable at park). */
67
+ export interface ReceiptLaunch {
68
+ orgId: string;
69
+ /** The served agent id: the fallback link is its dashboard page. */
70
+ agentId: string;
71
+ /** The proxy's record-state file (absent: no lineage was captured). */
72
+ statePath?: string;
73
+ }
74
+ /**
75
+ * The receipt for a launch, against the real API (the exit and park call sites).
76
+ *
77
+ * The deadline bounds what the caller waits for, not every network call: on a 401 the auth
78
+ * refresh (fetchWithAuthRetry) takes no signal and may outlive the receipt. The exit path
79
+ * process.exit()s right after; a long-lived caller (a durable park) may see it finish later.
80
+ */
81
+ export declare function conductReceiptFor(launch: ReceiptLaunch, trigger?: "exit" | "park", deps?: Partial<ReceiptDeps>): Promise<string[]>;
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The conduct receipt (experimental `record`, Doors W1.2 / W1.3): the engineer's private view of
3
+ * one session, printed when `mnemom agent` exits and when a durable session parks.
4
+ *
5
+ * Conduct receipt
6
+ * model cost $0.42
7
+ * μ 12.5
8
+ *
9
+ * Every figure is measured, never estimated (decision 14): model cost and μ are the session
10
+ * lineage's `record_accumulator` totals, read from `GET /v1/orgs/{org}/record/totals`. μ reads
11
+ * "μ pending" until billing has committed a charge for the lineage (the route's null), and the
12
+ * receipt then gives a link instead of a number.
13
+ *
14
+ * NEVER BLOCKS EXIT. The whole read, auth refresh included, races a hard deadline
15
+ * (RECEIPT_DEADLINE_MS): with the API or the record builder down, exit takes at most that much
16
+ * longer, and the receipt falls back to the link. The caller keeps its exit code; nothing here
17
+ * throws.
18
+ *
19
+ * The lineage key is the gateway's (x-mnemom-lineage-key, set on every response), captured by the
20
+ * local base-URL proxy into a state file: the CLI never derives one itself.
21
+ */
22
+ import { readFileSync } from "node:fs";
23
+ import { getRecordTotals } from "./api.js";
24
+ import { getWebsiteUrl } from "./config.js";
25
+ /** The hard ceiling on what the receipt adds to exit (W1.3: at most 2 s). */
26
+ export const RECEIPT_DEADLINE_MS = 1800;
27
+ /** Pause between totals reads while μ is still pending. */
28
+ export const RECEIPT_POLL_MS = 400;
29
+ /** 1 MU = 1000 mmu; 1 USD = 100 000 mmu (mnemom-api src/billing/pricing.ts MMU_PER_USD). */
30
+ const MMU_PER_MU = 1000n;
31
+ const MMU_PER_USD = 100000n;
32
+ const LINEAGE_KEY_RE = /^[A-Za-z0-9:._-]{1,256}$/;
33
+ const MMU_RE = /^(0|[1-9][0-9]{0,18})$/;
34
+ /** Read the proxy's record-state file; a missing or malformed file is an empty state. */
35
+ export function readRecordState(path) {
36
+ if (!path)
37
+ return {};
38
+ try {
39
+ const raw = JSON.parse(readFileSync(path, "utf8"));
40
+ return sanitizeRecordState(raw);
41
+ }
42
+ catch {
43
+ return {};
44
+ }
45
+ }
46
+ /** Keep only a well-formed lineage key and an https record URL. */
47
+ export function sanitizeRecordState(raw) {
48
+ const out = {};
49
+ if (typeof raw.lineage_key === "string" && LINEAGE_KEY_RE.test(raw.lineage_key)) {
50
+ out.lineageKey = raw.lineage_key;
51
+ }
52
+ if (typeof raw.record_url === "string" && isHttpsUrl(raw.record_url)) {
53
+ out.recordUrl = raw.record_url;
54
+ }
55
+ return out;
56
+ }
57
+ /** Printable ASCII only: the URL is printed to the user's terminal, so no escape bytes. */
58
+ const PRINTABLE_URL_RE = /^https:\/\/[\x21-\x7e]+$/;
59
+ function isHttpsUrl(s) {
60
+ if (s.length > 2048 || !PRINTABLE_URL_RE.test(s))
61
+ return false;
62
+ try {
63
+ return new URL(s).protocol === "https:";
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ /** Parse the totals route body; anything off-contract is "unavailable", never a guessed figure. */
70
+ export function parseTotals(body) {
71
+ if (!body || typeof body !== "object")
72
+ return { kind: "unavailable" };
73
+ const b = body;
74
+ if (typeof b.model_cost_mmu !== "string" || !MMU_RE.test(b.model_cost_mmu)) {
75
+ return { kind: "unavailable" };
76
+ }
77
+ let mu = null;
78
+ if (b.mu_charged_mmu !== null) {
79
+ if (typeof b.mu_charged_mmu !== "string" || !MMU_RE.test(b.mu_charged_mmu)) {
80
+ return { kind: "unavailable" };
81
+ }
82
+ mu = BigInt(b.mu_charged_mmu);
83
+ }
84
+ return { kind: "ok", modelCostMmu: BigInt(b.model_cost_mmu), muChargedMmu: mu };
85
+ }
86
+ /** `$0.42`; a non-zero cost under a cent reads `< $0.01`, never `$0.00`. */
87
+ export function formatUsd(mmu) {
88
+ if (mmu > 0n && mmu * 100n < MMU_PER_USD)
89
+ return "< $0.01";
90
+ const cents = (mmu * 100n + MMU_PER_USD / 2n) / MMU_PER_USD;
91
+ return `$${cents / 100n}.${String(cents % 100n).padStart(2, "0")}`;
92
+ }
93
+ /** μ to at most three decimals, trailing zeros dropped: 12500 mmu → `12.5`. */
94
+ export function formatMu(mmu) {
95
+ const whole = mmu / MMU_PER_MU;
96
+ const frac = String(mmu % MMU_PER_MU)
97
+ .padStart(3, "0")
98
+ .replace(/0+$/, "");
99
+ return frac ? `${whole}.${frac}` : `${whole}`;
100
+ }
101
+ /** The receipt lines for what was read (pure). */
102
+ export function receiptLines(read, link, trigger = "exit") {
103
+ const head = trigger === "park" ? "Conduct receipt (parked)" : "Conduct receipt";
104
+ if (!read || read.kind === "unavailable") {
105
+ return [head, ` record not available yet: ${link}`];
106
+ }
107
+ const lines = [head, ` model cost ${formatUsd(read.modelCostMmu)}`];
108
+ if (read.muChargedMmu === null)
109
+ lines.push(` μ pending ${link}`);
110
+ else
111
+ lines.push(` μ ${formatMu(read.muChargedMmu)}`);
112
+ return lines;
113
+ }
114
+ /**
115
+ * Read the lineage's totals until μ is final or the deadline passes, then return the receipt.
116
+ * Never throws and never waits past `deadlineMs`.
117
+ */
118
+ export async function buildConductReceipt(state, deps, trigger = "exit") {
119
+ const link = state.recordUrl ?? deps.fallbackUrl;
120
+ if (!state.lineageKey)
121
+ return receiptLines(null, link, trigger);
122
+ const now = deps.now ?? Date.now;
123
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
124
+ const deadline = now() + (deps.deadlineMs ?? RECEIPT_DEADLINE_MS);
125
+ const pollMs = deps.pollMs ?? RECEIPT_POLL_MS;
126
+ const lineageKey = state.lineageKey;
127
+ let last = null;
128
+ for (;;) {
129
+ const left = deadline - now();
130
+ if (left <= 0)
131
+ break;
132
+ const read = await readWithin(deps.readTotals, lineageKey, left);
133
+ // A later failure never hides an earlier good read.
134
+ if (read.kind === "ok" || !last)
135
+ last = read;
136
+ if (read.kind === "ok" && read.muChargedMmu !== null)
137
+ break;
138
+ if (deadline - now() <= pollMs)
139
+ break;
140
+ await sleep(pollMs);
141
+ }
142
+ return receiptLines(last, link, trigger);
143
+ }
144
+ async function readWithin(readTotals, lineageKey, ms) {
145
+ const ac = new AbortController();
146
+ let timer;
147
+ const timeout = new Promise((resolve) => {
148
+ timer = setTimeout(() => {
149
+ ac.abort();
150
+ resolve({ kind: "unavailable" });
151
+ }, ms);
152
+ });
153
+ try {
154
+ return await Promise.race([
155
+ readTotals(lineageKey, ac.signal).catch(() => ({ kind: "unavailable" })),
156
+ timeout,
157
+ ]);
158
+ }
159
+ finally {
160
+ clearTimeout(timer);
161
+ }
162
+ }
163
+ /**
164
+ * The receipt for a launch, against the real API (the exit and park call sites).
165
+ *
166
+ * The deadline bounds what the caller waits for, not every network call: on a 401 the auth
167
+ * refresh (fetchWithAuthRetry) takes no signal and may outlive the receipt. The exit path
168
+ * process.exit()s right after; a long-lived caller (a durable park) may see it finish later.
169
+ */
170
+ export async function conductReceiptFor(launch, trigger = "exit", deps = {}) {
171
+ return buildConductReceipt(readRecordState(launch.statePath), {
172
+ ...deps,
173
+ readTotals: deps.readTotals ??
174
+ (async (lineageKey, signal) => parseTotals(await getRecordTotals(launch.orgId, lineageKey, signal))),
175
+ fallbackUrl: deps.fallbackUrl ?? `${getWebsiteUrl()}/agents/${encodeURIComponent(launch.agentId)}`,
176
+ }, trigger);
177
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The contract's full sha256 and seal-time check, as the gateway computes them (Doors W3.5).
3
+ *
4
+ * Callers that hash contracts outside the gateway (the fuel step's CLI verb and the thin runner,
5
+ * each in its own change) use this module. It computes a contract's hash, its committed file's
6
+ * bytes and its repo path; writing the file is the caller's job. It ports the gateway's
7
+ * `normaliseContractObject` / `checkContractObject` / `canonicalHash` (gateway/src/goal-contract.ts). The full sha256 is the digest whose first 16
8
+ * hex characters are the gateway's `x-mnemom-contract-hash`: sha256 of the canonical JSON of the
9
+ * normalised contract, with a default `"nudge"` mode dropped and `"enforce"` kept. The 8 KB
10
+ * limit is measured, like the gateway's, on the canonical JSON with the mode still present.
11
+ *
12
+ * `gateway/test/fixtures/contract-sha256-parity.json` pins the gateway's own outputs; the CLI's
13
+ * parity suite and mnemom-api's (`src/contract-step/hash.ts`) read the same cases.
14
+ */
15
+ /** The gateway's `CONTRACT_JSON_MAX`: bytes of canonical JSON of the normalised contract. */
16
+ export declare const CONTRACT_JSON_MAX = 8192;
17
+ /** The gateway's `STATEMENT_MAX` (its `USER_CLIP`), in characters. */
18
+ export declare const STATEMENT_MAX = 4000;
19
+ export type ContractMode = "nudge" | "enforce";
20
+ /** pv2 SealedContract, the shape the gateway seals from `x-mnemom-contract`. */
21
+ export interface Contract {
22
+ goal_id?: string;
23
+ statement: string;
24
+ requirements: string[];
25
+ allowed_paths: string[];
26
+ forbidden_paths: string[];
27
+ max_turns?: number;
28
+ budget_usd?: number;
29
+ stall_turns?: number;
30
+ time_limit_s?: number;
31
+ mode?: ContractMode;
32
+ }
33
+ export type ContractRejectReason = "unparseable" | "statement_missing" | "statement_too_long" | "contract_too_large" | "mode_invalid";
34
+ export type ContractCheck = {
35
+ ok: true;
36
+ contract: Contract;
37
+ } | {
38
+ ok: false;
39
+ reason: ContractRejectReason;
40
+ message: string;
41
+ };
42
+ /** The gateway's `normaliseContractObject`: null without a non-empty statement. Mode defaults to "nudge". */
43
+ export declare function normaliseContract(obj: Record<string, unknown>): Contract | null;
44
+ /** The gateway's seal-time gate (`checkContractObject`), with the same reasons. */
45
+ export declare function checkContract(value: unknown, label?: string): ContractCheck;
46
+ /** Bytes the 8 KB limit measures: canonical JSON of the normalised contract, mode included. */
47
+ export declare function contractSizeBytes(contract: Contract): number;
48
+ /** The hash form: a default "nudge" mode dropped, "enforce" kept (the gateway's `canonicalHash`). */
49
+ export declare function hashForm(contract: Contract): Contract;
50
+ /**
51
+ * The committed contract file's exact bytes: canonical JSON of the hash form, no trailing newline,
52
+ * so sha256 of the file equals `contractSha256`.
53
+ */
54
+ export declare function contractFileText(contract: Contract): string;
55
+ /** Full lowercase-hex sha256. Its first 16 characters are the gateway's `x-mnemom-contract-hash`. */
56
+ export declare function contractSha256(contract: Contract): string;
57
+ /**
58
+ * Where a sealed contract is committed in the repo: content-addressed by its full sha256. Throws
59
+ * on anything but 64 lowercase hex characters, so the path can never leave `.mnemom/contracts/`.
60
+ */
61
+ export declare function contractRepoPath(sha256: string): string;