@mnemom/mnemom 0.17.31 → 0.17.32-next.1

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 (46) 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-share-fixture.d.ts +20 -0
  9. package/dist/commands/agent-share-fixture.js +134 -0
  10. package/dist/commands/agent-share.d.ts +87 -0
  11. package/dist/commands/agent-share.js +251 -0
  12. package/dist/commands/agent.d.ts +75 -2
  13. package/dist/commands/agent.js +165 -28
  14. package/dist/commands/status.js +1 -1
  15. package/dist/commands/update.js +3 -1
  16. package/dist/commands/validate.d.ts +1 -1
  17. package/dist/commands/validate.js +1 -1
  18. package/dist/index.js +68 -12
  19. package/dist/lib/agent.d.ts +34 -19
  20. package/dist/lib/agent.js +41 -32
  21. package/dist/lib/api.d.ts +66 -0
  22. package/dist/lib/api.js +50 -10
  23. package/dist/lib/canonical-json.d.ts +12 -0
  24. package/dist/lib/canonical-json.js +22 -0
  25. package/dist/lib/commit-report.d.ts +109 -0
  26. package/dist/lib/commit-report.js +205 -0
  27. package/dist/lib/conduct-receipt.d.ts +81 -0
  28. package/dist/lib/conduct-receipt.js +177 -0
  29. package/dist/lib/contract-hash.d.ts +61 -0
  30. package/dist/lib/contract-hash.js +126 -0
  31. package/dist/lib/experimental.d.ts +10 -1
  32. package/dist/lib/experimental.js +35 -1
  33. package/dist/lib/oauth.d.ts +2 -1
  34. package/dist/lib/oauth.js +2 -1
  35. package/dist/lib/statusline-bin.d.ts +2 -2
  36. package/dist/lib/statusline-bin.js +4 -7
  37. package/dist/lib/statusline-feed.d.ts +33 -0
  38. package/dist/lib/statusline-feed.js +68 -0
  39. package/dist/lib/statusline.d.ts +39 -88
  40. package/dist/lib/statusline.js +58 -250
  41. package/dist/lib/update/decide.d.ts +22 -3
  42. package/dist/lib/update/decide.js +35 -12
  43. package/dist/lib/update/doctor.js +5 -1
  44. package/dist/lib/update/run.d.ts +2 -0
  45. package/dist/lib/update/run.js +22 -8
  46. package/package.json +1 -1
@@ -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. */
@@ -75,7 +75,7 @@ export const EXPERIMENTAL_FEATURES = [
75
75
  {
76
76
  // No flag, launcher.toml key or env of its own: `mnemom experimental enable` is the switch.
77
77
  id: "measured-savings",
78
- summary: "The status bar shows the saving the gateway measured on this session instead of the benchmark estimate",
78
+ summary: "The gateway measures each request's saving into the response usage (the status bar never shows it)",
79
79
  since: "0.17.16",
80
80
  gates: {
81
81
  flags: [],
@@ -84,6 +84,29 @@ export const EXPERIMENTAL_FEATURES = [
84
84
  env: [],
85
85
  },
86
86
  },
87
+ {
88
+ // No flag, launcher.toml key or env of its own: `mnemom experimental enable` is the switch.
89
+ id: "record",
90
+ summary: "Print the conduct receipt at exit: the session's measured model cost and μ, or the record link",
91
+ since: "0.17.32",
92
+ gates: {
93
+ flags: [],
94
+ configKeys: [],
95
+ commands: [],
96
+ env: [],
97
+ },
98
+ },
99
+ {
100
+ id: "fuel",
101
+ summary: "The fuel step: `mnemom agent fuel <file>` seals a batch of job contracts with one confirmation and commits them to the repo",
102
+ since: "0.17.32",
103
+ gates: {
104
+ flags: [],
105
+ configKeys: [],
106
+ commands: ["mnemom agent fuel"],
107
+ env: [],
108
+ },
109
+ },
87
110
  ];
88
111
  export const EXPERIMENTAL_IDS = EXPERIMENTAL_FEATURES.map((f) => f.id);
89
112
  export const EXPERIMENTAL_ENV = "MNEMOM_EXPERIMENTAL";
@@ -126,6 +149,17 @@ export function resolveExperimental(opts) {
126
149
  unknown: stored.filter((t) => !isExperimentalId(t)),
127
150
  };
128
151
  }
152
+ /**
153
+ * Features a prerelease (`-next.N`) build makes available without `mnemom experimental
154
+ * enable`: door one's T16 turns implicit goals on by default on `next` (decisions 12 and
155
+ * 22), so its flag, its launcher.toml value and `mnemom agent goal` are not refused there.
156
+ * Stable is unchanged.
157
+ */
158
+ export const PRERELEASE_AVAILABLE = ["implicit"];
159
+ /** The features whose flags, keys and commands are usable: the enabled ones, plus PRERELEASE_AVAILABLE on a prerelease. */
160
+ export function availableFeatures(enabled, prerelease) {
161
+ return normalize(prerelease ? [...enabled, ...PRERELEASE_AVAILABLE] : enabled);
162
+ }
129
163
  /** The one-line stderr notice for a launch with experimental features on; null when none are. */
130
164
  export function experimentalNotice(enabled) {
131
165
  if (enabled.length === 0)
@@ -42,7 +42,8 @@ export interface AuthServerMetadata {
42
42
  }
43
43
  /**
44
44
  * Fetch (and process-cache) the AS metadata document. We resolve it relative to
45
- * the active API base URL so staging/local point at their own AS.
45
+ * the active API base URL so local (and any non-default `Environment`) points
46
+ * at its own AS.
46
47
  */
47
48
  export declare function discover(): Promise<AuthServerMetadata>;
48
49
  /** Reset the process-level discovery cache (used by tests). */
package/dist/lib/oauth.js CHANGED
@@ -117,7 +117,8 @@ async function oauthFetch(what, url, init = {}) {
117
117
  let cachedMetadata = null;
118
118
  /**
119
119
  * Fetch (and process-cache) the AS metadata document. We resolve it relative to
120
- * the active API base URL so staging/local point at their own AS.
120
+ * the active API base URL so local (and any non-default `Environment`) points
121
+ * at its own AS.
121
122
  */
122
123
  export async function discover() {
123
124
  if (cachedMetadata)
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Entry for the `mnemom agent` status-bar counter (lib/statusline.ts). Claude Code runs it
3
- * on every status update, so it imports only the counter — not the whole CLI.
2
+ * Entry for the `mnemom agent` status-bar segment (lib/statusline.ts). Claude Code runs it
3
+ * on every status update, so it imports only the segment — not the whole CLI.
4
4
  */
5
5
  export {};
@@ -1,17 +1,14 @@
1
1
  /**
2
- * Entry for the `mnemom agent` status-bar counter (lib/statusline.ts). Claude Code runs it
3
- * on every status update, so it imports only the counter — not the whole CLI.
2
+ * Entry for the `mnemom agent` status-bar segment (lib/statusline.ts). Claude Code runs it
3
+ * on every status update, so it imports only the segment — not the whole CLI.
4
4
  */
5
- import { homedir } from "node:os";
6
- import { join } from "node:path";
7
- import { INNER_ENV, measuredMode, render } from "./statusline.js";
5
+ import { FEED_ENV, INNER_ENV, render } from "./statusline.js";
8
6
  const chunks = [];
9
7
  process.stdin.on("data", (c) => chunks.push(c));
10
8
  process.stdin.on("end", () => {
11
9
  const line = render(Buffer.concat(chunks).toString("utf8"), {
12
- stateDir: join(homedir(), ".mnemom", "statusline"),
13
10
  inner: process.env[INNER_ENV] || undefined,
14
- measured: measuredMode(process.env),
11
+ feed: process.env[FEED_ENV] || undefined,
15
12
  });
16
13
  process.stdout.write(`${line}\n`);
17
14
  });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The launcher side of the fed status line (W1.4, decision 14), prerelease only.
3
+ *
4
+ * The local base-URL proxy records the gateway's `x-mnemom-lineage-key` for this session in
5
+ * its record-state file (the one the conduct receipt reads). While Claude Code runs, the launcher reads Record's accumulator totals for that
6
+ * lineage every few seconds and writes them to the feed file the status-line bin reads
7
+ * (lib/statusline.ts). The bin never touches the network.
8
+ *
9
+ * Fail open everywhere: no lineage yet, a dark route (404), a non-billing role (403) or any
10
+ * error leaves the feed as it was, so the status line stays "mnemom" until fed.
11
+ */
12
+ import { type RecordTotals } from "./api.js";
13
+ /** How often the launcher re-reads the totals. */
14
+ export declare const FEED_INTERVAL_MS = 10000;
15
+ export interface FeedOptions {
16
+ /** The proxy's record-state JSON (MNEMOM_AGENT_BU_RECORD_STATE), carrying `lineage_key`. */
17
+ recordStateFile: string;
18
+ /** Read by the status-line bin (MNEMOM_STATUSLINE_FEED). */
19
+ feedFile: string;
20
+ orgId: string;
21
+ intervalMs?: number;
22
+ /** Test seam; the real read is getRecordTotals (throws on any non-200). */
23
+ fetchTotals?: (orgId: string, lineageKey: string) => Promise<RecordTotals | null>;
24
+ }
25
+ /**
26
+ * The lineage key the proxy recorded, or null when there is none (yet) or it is not the
27
+ * gateway's `cid:`/`cr:` shape (the proxy's own check is looser; this read is exact).
28
+ */
29
+ export declare function readLineage(path: string): string | null;
30
+ /** One poll: read the lineage, fetch its totals, write the feed. Never throws. */
31
+ export declare function feedOnce(o: FeedOptions): Promise<boolean>;
32
+ /** Poll until stopped. The timer is unref'd, so it never holds the launcher open. */
33
+ export declare function startFeed(o: FeedOptions): () => void;