@mnemom/mnemom 0.17.31 → 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 (40) 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/status.js +1 -1
  11. package/dist/commands/update.js +3 -1
  12. package/dist/commands/validate.d.ts +1 -1
  13. package/dist/commands/validate.js +1 -1
  14. package/dist/index.js +51 -12
  15. package/dist/lib/agent.d.ts +32 -19
  16. package/dist/lib/agent.js +34 -32
  17. package/dist/lib/api.d.ts +53 -0
  18. package/dist/lib/api.js +31 -10
  19. package/dist/lib/canonical-json.d.ts +12 -0
  20. package/dist/lib/canonical-json.js +22 -0
  21. package/dist/lib/conduct-receipt.d.ts +81 -0
  22. package/dist/lib/conduct-receipt.js +177 -0
  23. package/dist/lib/contract-hash.d.ts +61 -0
  24. package/dist/lib/contract-hash.js +126 -0
  25. package/dist/lib/experimental.d.ts +10 -1
  26. package/dist/lib/experimental.js +35 -1
  27. package/dist/lib/oauth.d.ts +2 -1
  28. package/dist/lib/oauth.js +2 -1
  29. package/dist/lib/statusline-bin.d.ts +2 -2
  30. package/dist/lib/statusline-bin.js +4 -7
  31. package/dist/lib/statusline-feed.d.ts +33 -0
  32. package/dist/lib/statusline-feed.js +68 -0
  33. package/dist/lib/statusline.d.ts +39 -88
  34. package/dist/lib/statusline.js +58 -250
  35. package/dist/lib/update/decide.d.ts +22 -3
  36. package/dist/lib/update/decide.js +35 -12
  37. package/dist/lib/update/doctor.js +5 -1
  38. package/dist/lib/update/run.d.ts +2 -0
  39. package/dist/lib/update/run.js +22 -8
  40. package/package.json +1 -1
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;
@@ -0,0 +1,126 @@
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
+ import { createHash } from "node:crypto";
16
+ import { canonicalJson } from "./canonical-json.js";
17
+ /** The gateway's `CONTRACT_JSON_MAX`: bytes of canonical JSON of the normalised contract. */
18
+ export const CONTRACT_JSON_MAX = 8_192;
19
+ /** The gateway's `STATEMENT_MAX` (its `USER_CLIP`), in characters. */
20
+ export const STATEMENT_MAX = 4_000;
21
+ const MODES = new Set(["nudge", "enforce"]);
22
+ function stringArray(value) {
23
+ if (!Array.isArray(value))
24
+ return [];
25
+ return value.filter((v) => typeof v === "string" && v.trim().length > 0);
26
+ }
27
+ function positiveFinite(value) {
28
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
29
+ }
30
+ /** The gateway's `normaliseContractObject`: null without a non-empty statement. Mode defaults to "nudge". */
31
+ export function normaliseContract(obj) {
32
+ const statement = typeof obj.statement === "string" ? obj.statement.trim() : "";
33
+ if (!statement)
34
+ return null;
35
+ const contract = {
36
+ statement,
37
+ requirements: stringArray(obj.requirements),
38
+ allowed_paths: stringArray(obj.allowed_paths),
39
+ forbidden_paths: stringArray(obj.forbidden_paths),
40
+ };
41
+ if (typeof obj.goal_id === "string" && obj.goal_id.trim())
42
+ contract.goal_id = obj.goal_id.trim();
43
+ const maxTurns = positiveFinite(obj.max_turns);
44
+ if (maxTurns !== undefined)
45
+ contract.max_turns = maxTurns;
46
+ const budgetUsd = positiveFinite(obj.budget_usd);
47
+ if (budgetUsd !== undefined)
48
+ contract.budget_usd = budgetUsd;
49
+ const stallTurns = positiveFinite(obj.stall_turns);
50
+ if (stallTurns !== undefined)
51
+ contract.stall_turns = stallTurns;
52
+ const timeLimitS = positiveFinite(obj.time_limit_s);
53
+ if (timeLimitS !== undefined)
54
+ contract.time_limit_s = timeLimitS;
55
+ contract.mode = MODES.has(obj.mode) ? obj.mode : "nudge";
56
+ return contract;
57
+ }
58
+ /** The gateway's seal-time gate (`checkContractObject`), with the same reasons. */
59
+ export function checkContract(value, label = "contract") {
60
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
61
+ return { ok: false, reason: "unparseable", message: `${label} must be a JSON object` };
62
+ }
63
+ const obj = value;
64
+ const contract = normaliseContract(obj);
65
+ if (!contract) {
66
+ return {
67
+ ok: false,
68
+ reason: "statement_missing",
69
+ message: `${label} needs a non-empty "statement"`,
70
+ };
71
+ }
72
+ if (contract.statement.length > STATEMENT_MAX) {
73
+ return {
74
+ ok: false,
75
+ reason: "statement_too_long",
76
+ message: `"statement" is ${contract.statement.length} characters; the maximum is ${STATEMENT_MAX}`,
77
+ };
78
+ }
79
+ if (obj.mode !== undefined && !MODES.has(obj.mode)) {
80
+ return {
81
+ ok: false,
82
+ reason: "mode_invalid",
83
+ message: `"mode" must be "nudge" or "enforce" (got ${JSON.stringify(obj.mode)})`,
84
+ };
85
+ }
86
+ const bytes = contractSizeBytes(contract);
87
+ if (bytes > CONTRACT_JSON_MAX) {
88
+ return {
89
+ ok: false,
90
+ reason: "contract_too_large",
91
+ message: `the contract is ${bytes} bytes of canonical JSON; the maximum is ${CONTRACT_JSON_MAX}`,
92
+ };
93
+ }
94
+ return { ok: true, contract };
95
+ }
96
+ /** Bytes the 8 KB limit measures: canonical JSON of the normalised contract, mode included. */
97
+ export function contractSizeBytes(contract) {
98
+ return Buffer.byteLength(canonicalJson(contract), "utf8");
99
+ }
100
+ /** The hash form: a default "nudge" mode dropped, "enforce" kept (the gateway's `canonicalHash`). */
101
+ export function hashForm(contract) {
102
+ const form = { ...contract };
103
+ if ((contract.mode ?? "nudge") === "nudge")
104
+ delete form.mode;
105
+ return form;
106
+ }
107
+ /**
108
+ * The committed contract file's exact bytes: canonical JSON of the hash form, no trailing newline,
109
+ * so sha256 of the file equals `contractSha256`.
110
+ */
111
+ export function contractFileText(contract) {
112
+ return canonicalJson(hashForm(contract));
113
+ }
114
+ /** Full lowercase-hex sha256. Its first 16 characters are the gateway's `x-mnemom-contract-hash`. */
115
+ export function contractSha256(contract) {
116
+ return createHash("sha256").update(contractFileText(contract), "utf8").digest("hex");
117
+ }
118
+ /**
119
+ * Where a sealed contract is committed in the repo: content-addressed by its full sha256. Throws
120
+ * on anything but 64 lowercase hex characters, so the path can never leave `.mnemom/contracts/`.
121
+ */
122
+ export function contractRepoPath(sha256) {
123
+ if (!/^[0-9a-f]{64}$/.test(sha256))
124
+ throw new Error("contract sha256 must be 64 lowercase hex characters");
125
+ return `.mnemom/contracts/${sha256}.json`;
126
+ }
@@ -12,7 +12,7 @@
12
12
  * Everything here is pure or dependency-injected; index.ts and the command
13
13
  * modules are the thin drivers.
14
14
  */
15
- export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "measured-savings";
15
+ export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "measured-savings" | "record" | "fuel";
16
16
  /**
17
17
  * Features that used to be experimental and are now on by default for everyone. Their ids are
18
18
  * no longer in the registry, but `mnemom experimental enable|disable <id>` still accepts them
@@ -63,6 +63,15 @@ export declare function resolveExperimental(opts: {
63
63
  stored: ExperimentalPrefs | undefined;
64
64
  env: Record<string, string | undefined>;
65
65
  }): ExperimentalState;
66
+ /**
67
+ * Features a prerelease (`-next.N`) build makes available without `mnemom experimental
68
+ * enable`: door one's T16 turns implicit goals on by default on `next` (decisions 12 and
69
+ * 22), so its flag, its launcher.toml value and `mnemom agent goal` are not refused there.
70
+ * Stable is unchanged.
71
+ */
72
+ export declare const PRERELEASE_AVAILABLE: readonly ExperimentalId[];
73
+ /** The features whose flags, keys and commands are usable: the enabled ones, plus PRERELEASE_AVAILABLE on a prerelease. */
74
+ export declare function availableFeatures(enabled: readonly ExperimentalId[], prerelease: boolean): ExperimentalId[];
66
75
  /** The one-line stderr notice for a launch with experimental features on; null when none are. */
67
76
  export declare function experimentalNotice(enabled: readonly ExperimentalId[]): string | null;
68
77
  /** The error for using an experimental flag or command while its feature is off. */