@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.
- package/dist/base-url-proxy.mjs +30 -0
- package/dist/commands/agent-fuel.d.ts +75 -0
- package/dist/commands/agent-fuel.js +298 -0
- package/dist/commands/agent-implicit-notice.d.ts +31 -0
- package/dist/commands/agent-implicit-notice.js +73 -0
- package/dist/commands/agent-session.d.ts +2 -1
- package/dist/commands/agent-session.js +2 -1
- package/dist/commands/agent.d.ts +52 -1
- package/dist/commands/agent.js +111 -26
- package/dist/commands/auth.js +20 -0
- package/dist/commands/status.js +1 -1
- package/dist/commands/update.js +3 -1
- package/dist/commands/validate.d.ts +1 -1
- package/dist/commands/validate.js +1 -1
- package/dist/index.js +51 -12
- package/dist/lib/agent.d.ts +32 -19
- package/dist/lib/agent.js +34 -32
- package/dist/lib/api.d.ts +53 -0
- package/dist/lib/api.js +31 -10
- package/dist/lib/canonical-json.d.ts +12 -0
- package/dist/lib/canonical-json.js +22 -0
- package/dist/lib/conduct-receipt.d.ts +81 -0
- package/dist/lib/conduct-receipt.js +177 -0
- package/dist/lib/contract-hash.d.ts +61 -0
- package/dist/lib/contract-hash.js +126 -0
- package/dist/lib/experimental.d.ts +10 -1
- package/dist/lib/experimental.js +35 -1
- package/dist/lib/oauth.d.ts +32 -1
- package/dist/lib/oauth.js +157 -44
- package/dist/lib/statusline-bin.d.ts +2 -2
- package/dist/lib/statusline-bin.js +4 -7
- package/dist/lib/statusline-feed.d.ts +33 -0
- package/dist/lib/statusline-feed.js +68 -0
- package/dist/lib/statusline.d.ts +39 -88
- package/dist/lib/statusline.js +58 -250
- package/dist/lib/update/decide.d.ts +22 -3
- package/dist/lib/update/decide.js +35 -12
- package/dist/lib/update/doctor.js +5 -1
- package/dist/lib/update/run.d.ts +2 -0
- package/dist/lib/update/run.js +22 -8
- 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
|
-
*
|
|
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
|
|
1287
|
-
*
|
|
1288
|
-
*
|
|
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
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
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
|
|
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
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
-
|
|
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
|
-
// —
|
|
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;
|