@mnemom/mnemom 0.17.6-next.1 → 0.17.6-next.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,7 +20,9 @@ import { mergeAgentConfig } from "../lib/agent-config.js";
20
20
  import { askSelect, askInput, isInteractive } from "../lib/prompt.js";
21
21
  import { getGatewayUrl } from "../lib/config.js";
22
22
  import { fmt } from "../lib/format.js";
23
+ import { NOOP_SESSION_CONFIG_B64, SESSION_CONFIG_HEADER } from "../lib/agent.js";
23
24
  const AGENT_HEADER = "x-mnemom-agent";
25
+ const MNEMOM_KEY_HEADER = "x-mnemom-api-key";
24
26
  const PROVIDERS = {
25
27
  anthropic: {
26
28
  label: "Anthropic",
@@ -208,6 +210,10 @@ export async function birthThroughGateway(gatewayUrl, provider, agentName, provi
208
210
  [prov.keyHeader]: authValue,
209
211
  [AGENT_HEADER]: agentName,
210
212
  };
213
+ if (opts.mnemomKey) {
214
+ headers[MNEMOM_KEY_HEADER] = opts.mnemomKey;
215
+ headers[SESSION_CONFIG_HEADER] = NOOP_SESSION_CONFIG_B64;
216
+ }
211
217
  if (provider === "anthropic") {
212
218
  headers["anthropic-version"] = "2023-06-01";
213
219
  }
@@ -235,13 +241,24 @@ export async function birthThroughGateway(gatewayUrl, provider, agentName, provi
235
241
  messages: [{ role: "user", content: "hello" }],
236
242
  });
237
243
  }
238
- const response = await fetch(url, {
244
+ const post = (h) => fetch(url, {
239
245
  method: "POST",
240
- headers,
246
+ headers: h,
241
247
  body,
242
248
  signal: opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined,
243
249
  });
244
- const agentId = response.headers.get(AGENT_HEADER);
250
+ let response = await post(headers);
251
+ let agentId = response.headers.get(AGENT_HEADER);
252
+ // An agent an earlier plain call already birthed sits unclaimed in the sandbox org, and the
253
+ // gateway refuses agent traffic for it with 402 `agent_unclaimed` (and no agent id) — yet that
254
+ // is exactly the agent `mnemom agent setup` must claim. Ask once more as plain traffic (no
255
+ // session config): that serves it exactly as before and returns the id for the claim step.
256
+ if (!agentId && SESSION_CONFIG_HEADER in headers && (await isAgentUnclaimedRefusal(response))) {
257
+ const plain = { ...headers };
258
+ Reflect.deleteProperty(plain, SESSION_CONFIG_HEADER);
259
+ response = await post(plain);
260
+ agentId = response.headers.get(AGENT_HEADER);
261
+ }
245
262
  if (!response.ok && !agentId) {
246
263
  const text = await response.text().catch(() => "");
247
264
  throw new MnemomApiError(response.status, `Gateway returned ${response.status}: ${text.slice(0, 300)}`);
@@ -251,6 +268,22 @@ export async function birthThroughGateway(gatewayUrl, provider, agentName, provi
251
268
  }
252
269
  return agentId;
253
270
  }
271
+ /**
272
+ * Is `response` the gateway's agent-traffic refusal for an agent still unclaimed in the sandbox
273
+ * org — 402 with `error.details.reason === "agent_unclaimed"`? Reads a clone, so the caller can
274
+ * still read the body. Anything unparseable is `false`.
275
+ */
276
+ async function isAgentUnclaimedRefusal(response) {
277
+ if (response.status !== 402)
278
+ return false;
279
+ try {
280
+ const body = (await response.clone().json());
281
+ return body?.error?.details?.reason === "agent_unclaimed";
282
+ }
283
+ catch {
284
+ return false;
285
+ }
286
+ }
254
287
  // ── Starter cards ─────────────────────────────────────────────────────────────
255
288
  function buildStarterAlignmentCard(agentId) {
256
289
  return {
package/dist/index.js CHANGED
@@ -175,7 +175,7 @@ const agentCmd = program
175
175
  .addOption(new Option("--slack", "EXPERIMENTAL (`mnemom experimental enable slack`): attach the Slack channel — messages addressed to this session arrive in it, and it can reply (see `mnemom agent slack`)").hideHelp(hiddenUnless("slack")))
176
176
  .option("--context", "Force context folding ON for this launch even when launcher.toml (context = false) or MNEMOM_AGENT_CONTEXT turns it off")
177
177
  .option("--no-context", "Disable context folding for this launch (folding is on by default; tells the gateway to skip it for this session)")
178
- .addOption(new Option("--context-profile <profile>", 'How the gateway folds this session\'s context. "balanced" (default) keeps more of the earlier work verbatim and folds later — for long sessions that revisit earlier files and decisions. "aggressive" folds earlier and keeps a tighter working set — for cost-sensitive sessions and short, focused tasks. "off" leaves compaction to the coding agent. Also: config key context_profile, env MNEMOM_AGENT_CONTEXT_PROFILE.').argParser((v) => {
178
+ .addOption(new Option("--context-profile <profile>", 'How the gateway folds this session\'s context. "balanced" (default) keeps more of the earlier work verbatim and folds later — for long sessions that revisit earlier files and decisions. "aggressive" folds earlier and keeps a tighter working set — for cost-sensitive sessions and short, focused tasks. "adaptive" sizes the fold to the conversation: no fold below 200k tokens, aggressive from 200k to 1M, balanced above. "off" leaves compaction to the coding agent. Also: config key context_profile, env MNEMOM_AGENT_CONTEXT_PROFILE.').argParser((v) => {
179
179
  // One validator for all three surfaces (flag, MNEMOM_AGENT_CONTEXT_PROFILE,
180
180
  // launcher.toml context_profile): trimmed, case-insensitive, same error wording.
181
181
  try {
@@ -293,6 +293,12 @@ export declare const CONTEXT_RUNTIME_KEY = "CONTEXT_RUNTIME_ENABLED";
293
293
  export declare const CONTEXT_TAIL_BUDGET_KEY = "CONTEXT_RUNTIME_TAIL_BUDGET_TOKENS";
294
294
  export declare const CONTEXT_READ_CACHE_KEY = "CONTEXT_RUNTIME_READ_CACHE_TOKENS";
295
295
  export declare const CONTEXT_HEAD_RECLAIM_KEY = "CONTEXT_RUNTIME_HEAD_BREAKPOINT_RECLAIM";
296
+ /**
297
+ * The gateway key the `adaptive` context profile sets (allow-listed per session): turns on the
298
+ * size-banded fold policy. Only the literal `banded` enables it; the band thresholds and budgets
299
+ * stay at the gateway's own defaults.
300
+ */
301
+ export declare const CONTEXT_SIZE_POLICY_KEY = "CONTEXT_RUNTIME_SIZE_POLICY";
296
302
  /**
297
303
  * The named presets for how the gateway folds a session's context (`--context-profile`,
298
304
  * launcher.toml `context_profile`, `MNEMOM_AGENT_CONTEXT_PROFILE`):
@@ -301,8 +307,11 @@ export declare const CONTEXT_HEAD_RECLAIM_KEY = "CONTEXT_RUNTIME_HEAD_BREAKPOINT
301
307
  * - `balanced` → NO fold keys: the gateway's own defaults stand. The default.
302
308
  * - `aggressive` → a smaller tail budget + read cache and head-breakpoint reclaim on: folds
303
309
  * earlier and keeps a tighter working set.
310
+ * - `adaptive` → `CONTEXT_RUNTIME_SIZE_POLICY: "banded"` — the gateway picks the fold by the
311
+ * conversation's original size: no fold below 200k tokens, the aggressive
312
+ * budgets from 200k to 1M, balanced above. Opt-in; never the default.
304
313
  */
305
- export declare const CONTEXT_PROFILES: readonly ["off", "balanced", "aggressive"];
314
+ export declare const CONTEXT_PROFILES: readonly ["off", "balanced", "aggressive", "adaptive"];
306
315
  export type ContextProfile = (typeof CONTEXT_PROFILES)[number];
307
316
  /**
308
317
  * The gateway env key that gates the whole context-handoff capability (level lines +
@@ -462,8 +471,10 @@ export declare function launchPreferenceExplicit(opts: {
462
471
  * Has an orchestrator already run the session + µ-balance preflight for this
463
472
  * launch? `MNEMOM_AGENT_PREFLIGHTED=1` is for orchestrators (the ADW fleet) that
464
473
  * checked sign-in and balance themselves before spawning many launches; it skips
465
- * the per-launch session/personal-org/balance round trips. The Mnemom Agent
466
- * entitlement (which also gates Remote Control, fail-closed) is still fetched.
474
+ * the per-launch session prompt and balance round trips. The launch org still
475
+ * resolves as on a normal launch (active org, else personal org — the launch's
476
+ * Mnemom key is minted for it), and the Mnemom Agent entitlement (which also
477
+ * gates Remote Control, fail-closed) is still fetched for it.
467
478
  * Only the exact value `1` counts — anything else, including `true` or ` 1`, is
468
479
  * a normal launch.
469
480
  */
@@ -512,7 +523,11 @@ export declare function resolveRemoteControlShape(args: {
512
523
  export declare function buildCustomHeaders(opts: {
513
524
  /** Absent on a subscription-login launch: Claude Code then sends its own claude.ai login. */
514
525
  anthropicKey?: string;
515
- /** The governed-identity anchor on a subscription-login launch (lib/subscription-login.ts). */
526
+ /**
527
+ * The Mnemom key every `mnemom agent` launch sends (lib/subscription-login.ts
528
+ * resolveLaunchMnemomKey) — the gateway refuses the session without it, and on a subscription
529
+ * login it is also the governed-identity anchor.
530
+ */
516
531
  mnemomKey?: string;
517
532
  agent: string;
518
533
  conversationId: string;
@@ -543,7 +558,7 @@ export declare function parseContextProfile(raw: string, source: string): Contex
543
558
  /**
544
559
  * Which context profile this launch runs under. Precedence, highest wins:
545
560
  * 1. `--no-context` — always `off`. Combined with an explicit `--context-profile
546
- * balanced|aggressive` it is a CONFLICT and the launch refuses (never a silent override);
561
+ * balanced|aggressive|adaptive` it is a CONFLICT and the launch refuses (never a silent override);
547
562
  * `--context-profile off` agrees with it.
548
563
  * 2. an explicit `--context-profile <profile>` flag (`--context` alongside it is fine — the
549
564
  * profile wins);
@@ -561,8 +576,8 @@ export declare function resolveContextProfile(opts: {
561
576
  /**
562
577
  * The decoded per-session env overlay for this launch — the `config` object of the
563
578
  * `x-mnemom-session-config` payload, in wire order: the profile's keys first
564
- * (`CONTEXT_RUNTIME_ENABLED` for `off`; the three fold-tuning keys for `aggressive`; nothing
565
- * for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
579
+ * (`CONTEXT_RUNTIME_ENABLED` for `off`; the three fold-tuning keys for `aggressive`;
580
+ * `CONTEXT_RUNTIME_SIZE_POLICY` for `adaptive`; nothing for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
566
581
  * `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when EITHER the
567
582
  * `compaction_intercept` setting is ON OR `--compact-at` arms a target below 1M (the target
568
583
  * cannot be honored without the intercept), then `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`
@@ -597,7 +612,8 @@ export declare function buildSessionConfigPayload(opts: {
597
612
  * nothing to override (the gateway's own defaults stand — `balanced` with everything else off,
598
613
  * byte-identical to a launch before profiles existed). Independent overlays:
599
614
  * - the context profile (`buildSessionConfig`): `off` → `CONTEXT_RUNTIME_ENABLED: "false"`,
600
- * `aggressive` → the three fold-tuning keys, `balanced` → nothing;
615
+ * `aggressive` → the three fold-tuning keys, `adaptive` → `CONTEXT_RUNTIME_SIZE_POLICY:
616
+ * "banded"`, `balanced` → nothing;
601
617
  * - `compactionIntercept: true` → `GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT`
602
618
  * `"on"` (the `compaction_intercept` setting);
603
619
  * - `compactAt: <N>` (`--compact-at`, below 1M) → the SAME two intercept keys plus
@@ -616,6 +632,14 @@ export declare function buildSessionConfigB64(opts: {
616
632
  compactAt?: number | null;
617
633
  experimental?: readonly string[];
618
634
  }): string | undefined;
635
+ /**
636
+ * A no-op `x-mnemom-session-config` value: `{ v: 1, config: {} }` overrides nothing (the gateway
637
+ * applies an empty set, exactly its own defaults). buildSessionConfigB64 returns `undefined` for
638
+ * the defaults — no header at all; this is for a call that must be Mnemom agent traffic (the
639
+ * gateway's agent-traffic gate keys on the header's presence) without changing any setting: the
640
+ * launcher's birth probe (commands/wrap.ts birthThroughGateway).
641
+ */
642
+ export declare const NOOP_SESSION_CONFIG_B64: string;
619
643
  /**
620
644
  * Is the `handoff` MCP tool + `CONTEXT_HANDOFF_ENABLED` session-config header ON for
621
645
  * this launch? OFF by default (operator directive, curation 2026-09-25): an absent flag
@@ -651,6 +675,12 @@ export declare const HANDOFF_MCP_SERVER_NAME = "mnemom-handoff";
651
675
  * it the same way it inherits `ANTHROPIC_CUSTOM_HEADERS` under the terminal shape.
652
676
  */
653
677
  export declare const HANDOFF_RC_KEY_ENV_NAME = "MNEMOM_HANDOFF_KEY";
678
+ /**
679
+ * Beside `HANDOFF_RC_KEY_ENV_NAME`: the non-`ANTHROPIC_*` var `spawnRcCli` sets to the launch's
680
+ * Mnemom key when context handoff is on, so the handoff call carries the `x-mnemom-api-key` the
681
+ * gateway requires on every `mnemom agent` request.
682
+ */
683
+ export declare const HANDOFF_RC_MNEMOM_KEY_ENV_NAME = "MNEMOM_HANDOFF_MNEMOM_KEY";
654
684
  /**
655
685
  * How the `handoff` MCP server (cli/src/mcp/handoff-server.mjs) recovers the Anthropic key
656
686
  * WITHOUT it ever being written into the `--mcp-config` file on disk: `envName` is the NAME of a
@@ -682,6 +712,12 @@ export interface HandoffMcpParams {
682
712
  /** The gateway ORIGIN (e.g. https://gateway.mnemom.ai) — see `resolveHandoffGatewayHost`. */
683
713
  gatewayHost: string;
684
714
  anthropicKeySource: HandoffAnthropicKeySource;
715
+ /**
716
+ * Where the server reads the Mnemom key (`x-mnemom-api-key`) from — same contract as
717
+ * `anthropicKeySource`: `ANTHROPIC_CUSTOM_HEADERS` (its `x-mnemom-api-key:` line) on a terminal
718
+ * launch, `HANDOFF_RC_MNEMOM_KEY_ENV_NAME` under Remote Control. Absent ⇒ no Mnemom key header.
719
+ */
720
+ mnemomKeySource?: HandoffAnthropicKeySource;
685
721
  agent: string;
686
722
  conversationId: string;
687
723
  /** `buildSessionConfigB64` output — forwarded so the route resolves the SAME per-session flag. */
package/dist/lib/agent.js CHANGED
@@ -451,6 +451,12 @@ export const CONTEXT_RUNTIME_KEY = "CONTEXT_RUNTIME_ENABLED";
451
451
  export const CONTEXT_TAIL_BUDGET_KEY = "CONTEXT_RUNTIME_TAIL_BUDGET_TOKENS";
452
452
  export const CONTEXT_READ_CACHE_KEY = "CONTEXT_RUNTIME_READ_CACHE_TOKENS";
453
453
  export const CONTEXT_HEAD_RECLAIM_KEY = "CONTEXT_RUNTIME_HEAD_BREAKPOINT_RECLAIM";
454
+ /**
455
+ * The gateway key the `adaptive` context profile sets (allow-listed per session): turns on the
456
+ * size-banded fold policy. Only the literal `banded` enables it; the band thresholds and budgets
457
+ * stay at the gateway's own defaults.
458
+ */
459
+ export const CONTEXT_SIZE_POLICY_KEY = "CONTEXT_RUNTIME_SIZE_POLICY";
454
460
  /**
455
461
  * The named presets for how the gateway folds a session's context (`--context-profile`,
456
462
  * launcher.toml `context_profile`, `MNEMOM_AGENT_CONTEXT_PROFILE`):
@@ -459,8 +465,11 @@ export const CONTEXT_HEAD_RECLAIM_KEY = "CONTEXT_RUNTIME_HEAD_BREAKPOINT_RECLAIM
459
465
  * - `balanced` → NO fold keys: the gateway's own defaults stand. The default.
460
466
  * - `aggressive` → a smaller tail budget + read cache and head-breakpoint reclaim on: folds
461
467
  * earlier and keeps a tighter working set.
468
+ * - `adaptive` → `CONTEXT_RUNTIME_SIZE_POLICY: "banded"` — the gateway picks the fold by the
469
+ * conversation's original size: no fold below 200k tokens, the aggressive
470
+ * budgets from 200k to 1M, balanced above. Opt-in; never the default.
462
471
  */
463
- export const CONTEXT_PROFILES = ["off", "balanced", "aggressive"];
472
+ export const CONTEXT_PROFILES = ["off", "balanced", "aggressive", "adaptive"];
464
473
  /** The per-session env overlay each profile expands to (key order is the wire order). */
465
474
  const CONTEXT_PROFILE_CONFIG = {
466
475
  off: [[CONTEXT_RUNTIME_KEY, "false"]],
@@ -470,6 +479,7 @@ const CONTEXT_PROFILE_CONFIG = {
470
479
  [CONTEXT_READ_CACHE_KEY, "8000"],
471
480
  [CONTEXT_HEAD_RECLAIM_KEY, "true"],
472
481
  ],
482
+ adaptive: [[CONTEXT_SIZE_POLICY_KEY, "banded"]],
473
483
  };
474
484
  /**
475
485
  * The gateway env key that gates the whole context-handoff capability (level lines +
@@ -798,8 +808,10 @@ export function launchPreferenceExplicit(opts, env = process.env) {
798
808
  * Has an orchestrator already run the session + µ-balance preflight for this
799
809
  * launch? `MNEMOM_AGENT_PREFLIGHTED=1` is for orchestrators (the ADW fleet) that
800
810
  * checked sign-in and balance themselves before spawning many launches; it skips
801
- * the per-launch session/personal-org/balance round trips. The Mnemom Agent
802
- * entitlement (which also gates Remote Control, fail-closed) is still fetched.
811
+ * the per-launch session prompt and balance round trips. The launch org still
812
+ * resolves as on a normal launch (active org, else personal org — the launch's
813
+ * Mnemom key is minted for it), and the Mnemom Agent entitlement (which also
814
+ * gates Remote Control, fail-closed) is still fetched for it.
803
815
  * Only the exact value `1` counts — anything else, including `true` or ` 1`, is
804
816
  * a normal launch.
805
817
  */
@@ -902,7 +914,7 @@ export function parseContextProfile(raw, source) {
902
914
  /**
903
915
  * Which context profile this launch runs under. Precedence, highest wins:
904
916
  * 1. `--no-context` — always `off`. Combined with an explicit `--context-profile
905
- * balanced|aggressive` it is a CONFLICT and the launch refuses (never a silent override);
917
+ * balanced|aggressive|adaptive` it is a CONFLICT and the launch refuses (never a silent override);
906
918
  * `--context-profile off` agrees with it.
907
919
  * 2. an explicit `--context-profile <profile>` flag (`--context` alongside it is fine — the
908
920
  * profile wins);
@@ -931,8 +943,8 @@ export function resolveContextProfile(opts) {
931
943
  /**
932
944
  * The decoded per-session env overlay for this launch — the `config` object of the
933
945
  * `x-mnemom-session-config` payload, in wire order: the profile's keys first
934
- * (`CONTEXT_RUNTIME_ENABLED` for `off`; the three fold-tuning keys for `aggressive`; nothing
935
- * for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
946
+ * (`CONTEXT_RUNTIME_ENABLED` for `off`; the three fold-tuning keys for `aggressive`;
947
+ * `CONTEXT_RUNTIME_SIZE_POLICY` for `adaptive`; nothing for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
936
948
  * `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when EITHER the
937
949
  * `compaction_intercept` setting is ON OR `--compact-at` arms a target below 1M (the target
938
950
  * cannot be honored without the intercept), then `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`
@@ -977,7 +989,8 @@ export function buildSessionConfigPayload(opts) {
977
989
  * nothing to override (the gateway's own defaults stand — `balanced` with everything else off,
978
990
  * byte-identical to a launch before profiles existed). Independent overlays:
979
991
  * - the context profile (`buildSessionConfig`): `off` → `CONTEXT_RUNTIME_ENABLED: "false"`,
980
- * `aggressive` → the three fold-tuning keys, `balanced` → nothing;
992
+ * `aggressive` → the three fold-tuning keys, `adaptive` → `CONTEXT_RUNTIME_SIZE_POLICY:
993
+ * "banded"`, `balanced` → nothing;
981
994
  * - `compactionIntercept: true` → `GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT`
982
995
  * `"on"` (the `compaction_intercept` setting);
983
996
  * - `compactAt: <N>` (`--compact-at`, below 1M) → the SAME two intercept keys plus
@@ -995,6 +1008,14 @@ export function buildSessionConfigB64(opts) {
995
1008
  return undefined;
996
1009
  return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
997
1010
  }
1011
+ /**
1012
+ * A no-op `x-mnemom-session-config` value: `{ v: 1, config: {} }` overrides nothing (the gateway
1013
+ * applies an empty set, exactly its own defaults). buildSessionConfigB64 returns `undefined` for
1014
+ * the defaults — no header at all; this is for a call that must be Mnemom agent traffic (the
1015
+ * gateway's agent-traffic gate keys on the header's presence) without changing any setting: the
1016
+ * launcher's birth probe (commands/wrap.ts birthThroughGateway).
1017
+ */
1018
+ export const NOOP_SESSION_CONFIG_B64 = Buffer.from(JSON.stringify({ v: 1, config: {} }), "utf8").toString("base64url");
998
1019
  /**
999
1020
  * Is the `handoff` MCP tool + `CONTEXT_HANDOFF_ENABLED` session-config header ON for
1000
1021
  * this launch? OFF by default (operator directive, curation 2026-09-25): an absent flag
@@ -1038,6 +1059,12 @@ export const HANDOFF_MCP_SERVER_NAME = "mnemom-handoff";
1038
1059
  * it the same way it inherits `ANTHROPIC_CUSTOM_HEADERS` under the terminal shape.
1039
1060
  */
1040
1061
  export const HANDOFF_RC_KEY_ENV_NAME = "MNEMOM_HANDOFF_KEY";
1062
+ /**
1063
+ * Beside `HANDOFF_RC_KEY_ENV_NAME`: the non-`ANTHROPIC_*` var `spawnRcCli` sets to the launch's
1064
+ * Mnemom key when context handoff is on, so the handoff call carries the `x-mnemom-api-key` the
1065
+ * gateway requires on every `mnemom agent` request.
1066
+ */
1067
+ export const HANDOFF_RC_MNEMOM_KEY_ENV_NAME = "MNEMOM_HANDOFF_MNEMOM_KEY";
1041
1068
  /** The `--mcp-config` document registering exactly the `handoff` tool. Pure — no I/O. */
1042
1069
  export function buildHandoffMcpConfig(p) {
1043
1070
  const env = {
@@ -1046,6 +1073,8 @@ export function buildHandoffMcpConfig(p) {
1046
1073
  MNEMOM_HANDOFF_AGENT: p.agent,
1047
1074
  MNEMOM_HANDOFF_CONVERSATION_ID: p.conversationId,
1048
1075
  };
1076
+ if (p.mnemomKeySource)
1077
+ env.MNEMOM_HANDOFF_MNEMOM_KEY_ENV_NAME = p.mnemomKeySource.envName;
1049
1078
  if (p.sessionConfigB64)
1050
1079
  env.MNEMOM_HANDOFF_SESSION_CONFIG = p.sessionConfigB64;
1051
1080
  return {
package/dist/lib/api.d.ts CHANGED
@@ -1069,6 +1069,27 @@ export declare function listApiKeys(): Promise<ApiKeyListItem[]>;
1069
1069
  * for non-staff, admin:org for users not in any org-admin role).
1070
1070
  */
1071
1071
  export declare function createApiKey(name: string, scopes: ApiKeyScope[]): Promise<ApiKeyCreated>;
1072
+ /**
1073
+ * POST /v1/orgs/{org_id}/api-keys — mint an ORG-owned key (its `org_id` is that org, so the
1074
+ * gateway births a new agent there). Owners and admins may mint any scopes; a member may mint a
1075
+ * key whose scopes are exactly ["gateway"]. A refusal (viewer/auditor, a non-member, or an API
1076
+ * that predates member org keys) is thrown as MnemomApiError with its status (403/404).
1077
+ */
1078
+ export declare function createOrgApiKey(orgId: string, name: string, scopes: ApiKeyScope[]): Promise<ApiKeyCreated>;
1079
+ /**
1080
+ * GET /v1/api-keys/whoami authenticated by `key` ALONE (`X-Mnemom-Api-Key`, no session): does
1081
+ * the API accept this key? true = 200. false = 401/403 — revoked, rotated or unknown, OR a valid
1082
+ * `gateway`-only key: the API refuses every key without an `api:*` scope (ADR-049), so false
1083
+ * alone never proves a key dead. Throws on anything else (network, timeout, 429, 5xx): the
1084
+ * caller reads that as "could not check".
1085
+ */
1086
+ export declare function apiAcceptsMnemomKey(key: string, timeoutMs: number): Promise<boolean>;
1087
+ /**
1088
+ * GET /v1/api-keys with a timeout, for checking a key: the signed-in caller's ACTIVE keys (a
1089
+ * revoked or rotated key is absent). null when there is no usable session (no credential, or
1090
+ * 401/403 after the refresh retry). Throws on anything else (network, timeout, 5xx).
1091
+ */
1092
+ export declare function listOwnApiKeysForCheck(timeoutMs: number): Promise<ApiKeyListItem[] | null>;
1072
1093
  /**
1073
1094
  * POST /v1/api-keys/{key_id}/rotate — atomic mint-new + revoke-old.
1074
1095
  * Returns the full new secret only on this call. The new key inherits
@@ -1078,9 +1099,13 @@ export declare function createApiKey(name: string, scopes: ApiKeyScope[]): Promi
1078
1099
  export declare function rotateApiKey(keyId: string): Promise<ApiKeyCreated>;
1079
1100
  /**
1080
1101
  * DELETE /v1/api-keys/{key_id} — soft-revoke. The key row stays for
1081
- * audit; `is_active` flips to false and `revoked_at` is timestamped.
1102
+ * audit; `is_active` flips to false and `revoked_at` is timestamped. Only
1103
+ * a key the signed-in user created (an org key they minted included) is
1104
+ * found; anything else is a 404. `timeoutMs` bounds the call.
1082
1105
  */
1083
- export declare function revokeApiKey(keyId: string): Promise<void>;
1106
+ export declare function revokeApiKey(keyId: string, opts?: {
1107
+ timeoutMs?: number;
1108
+ }): Promise<void>;
1084
1109
  export interface LicenseValidationResult {
1085
1110
  valid: boolean;
1086
1111
  license_id?: string;
package/dist/lib/api.js CHANGED
@@ -1385,6 +1385,65 @@ export async function createApiKey(name, scopes) {
1385
1385
  }
1386
1386
  return (await response.json());
1387
1387
  }
1388
+ /**
1389
+ * POST /v1/orgs/{org_id}/api-keys — mint an ORG-owned key (its `org_id` is that org, so the
1390
+ * gateway births a new agent there). Owners and admins may mint any scopes; a member may mint a
1391
+ * key whose scopes are exactly ["gateway"]. A refusal (viewer/auditor, a non-member, or an API
1392
+ * that predates member org keys) is thrown as MnemomApiError with its status (403/404).
1393
+ */
1394
+ export async function createOrgApiKey(orgId, name, scopes) {
1395
+ const url = validateUrl(`${API_BASE}/v1/orgs/${encodeURIComponent(sanitizeForHttp(orgId))}/api-keys`);
1396
+ const response = await fetchWithAuthRetry(url, async () => ({
1397
+ method: "POST",
1398
+ headers: { ...(await authHeaders()), "Content-Type": "application/json" },
1399
+ body: JSON.stringify({ name, scopes }),
1400
+ }));
1401
+ if (!response.ok) {
1402
+ if (response.status === 401)
1403
+ throw new MnemomApiError(401, NOT_AUTHENTICATED_MSG);
1404
+ throw await readApiError(response, "Failed to create org api key");
1405
+ }
1406
+ return (await response.json());
1407
+ }
1408
+ /**
1409
+ * GET /v1/api-keys/whoami authenticated by `key` ALONE (`X-Mnemom-Api-Key`, no session): does
1410
+ * the API accept this key? true = 200. false = 401/403 — revoked, rotated or unknown, OR a valid
1411
+ * `gateway`-only key: the API refuses every key without an `api:*` scope (ADR-049), so false
1412
+ * alone never proves a key dead. Throws on anything else (network, timeout, 429, 5xx): the
1413
+ * caller reads that as "could not check".
1414
+ */
1415
+ export async function apiAcceptsMnemomKey(key, timeoutMs) {
1416
+ const url = validateUrl(`${API_BASE}/v1/api-keys/whoami`);
1417
+ const response = await mnemomFetch(url, {
1418
+ headers: { "X-Mnemom-Api-Key": sanitizeForHttp(key), Accept: "application/json" },
1419
+ signal: AbortSignal.timeout(timeoutMs),
1420
+ });
1421
+ if (response.ok)
1422
+ return true;
1423
+ if (response.status === 401 || response.status === 403)
1424
+ return false;
1425
+ throw new MnemomApiError(response.status, `api-key whoami: HTTP ${response.status}`);
1426
+ }
1427
+ /**
1428
+ * GET /v1/api-keys with a timeout, for checking a key: the signed-in caller's ACTIVE keys (a
1429
+ * revoked or rotated key is absent). null when there is no usable session (no credential, or
1430
+ * 401/403 after the refresh retry). Throws on anything else (network, timeout, 5xx).
1431
+ */
1432
+ export async function listOwnApiKeysForCheck(timeoutMs) {
1433
+ if ((await resolveAuth()).type === "none")
1434
+ return null;
1435
+ const url = validateUrl(`${API_BASE}/v1/api-keys`);
1436
+ const response = await fetchWithAuthRetry(url, async () => ({
1437
+ headers: { ...(await authHeaders()), Accept: "application/json" },
1438
+ signal: AbortSignal.timeout(timeoutMs),
1439
+ }));
1440
+ if (response.status === 401 || response.status === 403)
1441
+ return null;
1442
+ if (!response.ok) {
1443
+ throw new MnemomApiError(response.status, `list api keys: HTTP ${response.status}`);
1444
+ }
1445
+ return (await response.json()).keys ?? [];
1446
+ }
1388
1447
  /**
1389
1448
  * POST /v1/api-keys/{key_id}/rotate — atomic mint-new + revoke-old.
1390
1449
  * Returns the full new secret only on this call. The new key inherits
@@ -1410,13 +1469,16 @@ export async function rotateApiKey(keyId) {
1410
1469
  }
1411
1470
  /**
1412
1471
  * DELETE /v1/api-keys/{key_id} — soft-revoke. The key row stays for
1413
- * audit; `is_active` flips to false and `revoked_at` is timestamped.
1472
+ * audit; `is_active` flips to false and `revoked_at` is timestamped. Only
1473
+ * a key the signed-in user created (an org key they minted included) is
1474
+ * found; anything else is a 404. `timeoutMs` bounds the call.
1414
1475
  */
1415
- export async function revokeApiKey(keyId) {
1476
+ export async function revokeApiKey(keyId, opts = {}) {
1416
1477
  const url = validateUrl(`${API_BASE}/v1/api-keys/${encodeURIComponent(keyId)}`);
1417
1478
  const response = await fetchWithAuthRetry(url, async () => ({
1418
1479
  method: "DELETE",
1419
1480
  headers: await authHeaders(),
1481
+ signal: opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined,
1420
1482
  }));
1421
1483
  if (!response.ok) {
1422
1484
  if (response.status === 401) {
@@ -6,17 +6,14 @@
6
6
  * Read-only; no network. Only token counts, model ids and timestamps are read from a
7
7
  * transcript line — never message content.
8
8
  *
9
- * The curve (lower 95% bootstrap bound of the size-matched per-request saving, rounded
10
- * DOWN to 0.1 pt) comes from the S43 / S44 chain45 and d240p1 marathon paired arms:
11
- * mnemom-adw scratchpad/wow-first-run/CURVE-REPORT.md, curve/curve.json. The strategy is
12
- * chosen PER REQUEST from that request's original context (operator policy 2026-09-30):
13
- *
14
- * < 200k none (no fold) → 0 on screen (the measured balanced rate there is
15
- * slightly negative; floored, raw kept for the audit)
16
- * 200k – 700k aggressive → the measured lower bound of its bin
17
- * 700k – 1M aggressive → the 400–700k bound, EXTRAPOLATED (no measured pair)
18
- * ≥ 1M balanced → 0 (live sessions only; a stock history cannot get
19
- * there because Claude Code compacts first — counted)
9
+ * The rate is ONE flat number (operator policy 2026-10-02): the median paired total-bill
10
+ * saving of the S46 chain45 C2n arm (context runtime, aggressive profile, no goal) against
11
+ * stock, 23.8% over n = 13 counted rounds (mnemom-adw scratchpad/trustcode-mvp/runs/
12
+ * s46-status.json; early exits and void arms excluded). It applies to every request whose
13
+ * original context is at or above the aggressive profile's fold point (its 60k tail
14
+ * budget); smaller requests never fold, so they save 0. A flat rate over-counts mid-size
15
+ * contexts and under-counts large ones; in aggregate that evens out. Per-size rates come
16
+ * back once there are enough measured pairs.
20
17
  */
21
18
  /** One billed request, deduped by message id. `t` is epoch seconds. */
22
19
  export interface Row {
@@ -59,28 +56,14 @@ export declare function parseUsageLine(line: Buffer, subagent: boolean): {
59
56
  * session copies messages) is skipped, so each request is billed once, to its origin.
60
57
  */
61
58
  export declare function loadTranscript(path: string, seen?: Set<string>): Row[];
62
- /** Original-context bin edges (tokens). */
63
- export declare const CURVE_EDGES: readonly [0, 100000, 200000, 300000, 400000, 700000, 1000000];
64
- /**
65
- * Lower 95% bound per bin, rounded down to 0.1 pt (curve/curve.json `strategies`).
66
- * aggressive: no measured pair below 100k or at/above 700k.
67
- */
68
- export declare const CURVE_LOWER: Readonly<Record<"aggressive" | "balanced", Record<number, number>>>;
69
- export declare const NO_FOLD_BELOW = 200000;
70
- export declare const EXTRAPOLATE_FROM = 700000;
71
- export declare const BALANCED_FROM = 1000000;
72
- export declare const POLICY = "none <200k / aggressive 200k-1M / balanced 1M+ (1M+ applies only to live sessions)";
59
+ /** S46 C2n vs stock, median paired total-bill saving (n = 13). */
60
+ export declare const S46_MEDIAN_SAVING = 0.238;
61
+ /** The aggressive profile's tail budget (cli/src/lib/agent.ts): below it nothing folds. */
62
+ export declare const AGGRESSIVE_FOLD_FROM = 60000;
73
63
  export declare const ESTIMATOR_METHOD: string;
74
- export type Strategy = "none" | "aggressive" | "balanced_1M+";
75
- export type RateNote = "floored_at_0" | "extrapolated" | "exception_1M_plus" | null;
76
- /** The curve bin of an original context (0 = below 100k; the top bin is open-ended). */
77
- export declare function binOf(x: number): number;
78
- /** The screen never claims a negative saving: a negative measured rate shows as 0. */
79
- export declare function floorRate(raw: number): {
80
- screen: number;
81
- note: "floored_at_0" | null;
82
- };
83
- /** The per-request rate the screen uses (`screen`) and the honest measured one (`raw`). */
64
+ export type Strategy = "none" | "aggressive";
65
+ export type RateNote = "below_aggressive" | null;
66
+ /** The per-request rate the screen uses. `raw` equals `screen`: no floor, no curve. */
84
67
  export declare function ruleRate(x: number): {
85
68
  strategy: Strategy;
86
69
  raw: number;
@@ -90,7 +73,7 @@ export declare function ruleRate(x: number): {
90
73
  export interface Estimate {
91
74
  actual: number;
92
75
  saved: number;
93
- /** The saving with the < 200k band NOT floored (audit only; never shown). */
76
+ /** Same as `saved` now that no rate is floored (kept for the audit record). */
94
77
  rawSaved: number;
95
78
  peak: number;
96
79
  requests: number;
@@ -6,17 +6,14 @@
6
6
  * Read-only; no network. Only token counts, model ids and timestamps are read from a
7
7
  * transcript line — never message content.
8
8
  *
9
- * The curve (lower 95% bootstrap bound of the size-matched per-request saving, rounded
10
- * DOWN to 0.1 pt) comes from the S43 / S44 chain45 and d240p1 marathon paired arms:
11
- * mnemom-adw scratchpad/wow-first-run/CURVE-REPORT.md, curve/curve.json. The strategy is
12
- * chosen PER REQUEST from that request's original context (operator policy 2026-09-30):
13
- *
14
- * < 200k none (no fold) → 0 on screen (the measured balanced rate there is
15
- * slightly negative; floored, raw kept for the audit)
16
- * 200k – 700k aggressive → the measured lower bound of its bin
17
- * 700k – 1M aggressive → the 400–700k bound, EXTRAPOLATED (no measured pair)
18
- * ≥ 1M balanced → 0 (live sessions only; a stock history cannot get
19
- * there because Claude Code compacts first — counted)
9
+ * The rate is ONE flat number (operator policy 2026-10-02): the median paired total-bill
10
+ * saving of the S46 chain45 C2n arm (context runtime, aggressive profile, no goal) against
11
+ * stock, 23.8% over n = 13 counted rounds (mnemom-adw scratchpad/trustcode-mvp/runs/
12
+ * s46-status.json; early exits and void arms excluded). It applies to every request whose
13
+ * original context is at or above the aggressive profile's fold point (its 60k tail
14
+ * budget); smaller requests never fold, so they save 0. A flat rate over-counts mid-size
15
+ * contexts and under-counts large ones; in aggregate that evens out. Per-size rates come
16
+ * back once there are enough measured pairs.
20
17
  */
21
18
  import { forEachLine, isoSeconds, parseJson } from "./lines.js";
22
19
  /**
@@ -137,51 +134,19 @@ export function loadTranscript(path, seen) {
137
134
  }
138
135
  return out;
139
136
  }
140
- // ── the empirical curve ──────────────────────────────────────────────────────
141
- /** Original-context bin edges (tokens). */
142
- export const CURVE_EDGES = [0, 100_000, 200_000, 300_000, 400_000, 700_000, 1_000_000];
143
- /**
144
- * Lower 95% bound per bin, rounded down to 0.1 pt (curve/curve.json `strategies`).
145
- * aggressive: no measured pair below 100k or at/above 700k.
146
- */
147
- export const CURVE_LOWER = {
148
- // bin 1 (100-200k) is measured at -0.059 but never used: < 200k runs no fold.
149
- aggressive: { 2: 0.137, 3: 0.282, 4: 0.342 },
150
- balanced: { 0: -0.222, 1: -0.028, 2: 0.055, 3: 0.081, 4: 0.163, 5: 0.43 },
151
- };
152
- export const NO_FOLD_BELOW = 200_000;
153
- export const EXTRAPOLATE_FROM = 700_000;
154
- export const BALANCED_FROM = 1_000_000;
155
- export const POLICY = "none <200k / aggressive 200k-1M / balanced 1M+ (1M+ applies only to live sessions)";
156
- export const ESTIMATOR_METHOD = "empirical curve (lower 95% bound, rounded down), strategy per request by its original context, " +
157
- `policy ${POLICY}: < 200k the balanced arm's measured rate floored at 0, 200k-1M aggressive by bin ` +
158
- "(>= 700k: the 400-700k rate, extrapolated); request count and output held at stock";
159
- /** The curve bin of an original context (0 = below 100k; the top bin is open-ended). */
160
- export function binOf(x) {
161
- let i = 0;
162
- while (i < CURVE_EDGES.length - 2 && x >= CURVE_EDGES[i + 1])
163
- i++;
164
- return i;
165
- }
166
- /** The screen never claims a negative saving: a negative measured rate shows as 0. */
167
- export function floorRate(raw) {
168
- return raw < 0 ? { screen: 0, note: "floored_at_0" } : { screen: raw, note: null };
169
- }
170
- /** The per-request rate the screen uses (`screen`) and the honest measured one (`raw`). */
137
+ // ── the saving rate ──────────────────────────────────────────────────────────
138
+ /** S46 C2n vs stock, median paired total-bill saving (n = 13). */
139
+ export const S46_MEDIAN_SAVING = 0.238;
140
+ /** The aggressive profile's tail budget (cli/src/lib/agent.ts): below it nothing folds. */
141
+ export const AGGRESSIVE_FOLD_FROM = 60_000;
142
+ export const ESTIMATOR_METHOD = "flat S46 median (23.8%, C2n vs stock, n = 13) on every request whose original context is " +
143
+ ">= 60k (the aggressive profile's fold point), 0 below; request count and output held at stock";
144
+ /** The per-request rate the screen uses. `raw` equals `screen`: no floor, no curve. */
171
145
  export function ruleRate(x) {
172
- if (x >= BALANCED_FROM) {
173
- return { strategy: "balanced_1M+", raw: 0, screen: 0, note: "exception_1M_plus" };
174
- }
175
- if (x < NO_FOLD_BELOW) {
176
- const raw = CURVE_LOWER.balanced[binOf(x)];
177
- return { strategy: "none", raw, ...floorRate(raw) };
178
- }
179
- if (x >= EXTRAPOLATE_FROM) {
180
- const r = CURVE_LOWER.aggressive[binOf(400_000)];
181
- return { strategy: "aggressive", raw: r, screen: r, note: "extrapolated" };
146
+ if (x < AGGRESSIVE_FOLD_FROM) {
147
+ return { strategy: "none", raw: 0, screen: 0, note: "below_aggressive" };
182
148
  }
183
- const r = CURVE_LOWER.aggressive[binOf(x)];
184
- return { strategy: "aggressive", raw: r, screen: r, note: null };
149
+ return { strategy: "aggressive", raw: S46_MEDIAN_SAVING, screen: S46_MEDIAN_SAVING, note: null };
185
150
  }
186
151
  /** The one swap point: rows → list-price actual and the conservative saving. */
187
152
  export function estimate(rows) {
@@ -209,7 +209,7 @@ export const LEDGER_SIGNALS = new Set(["registry_cc_id", "registry_conv_id"]);
209
209
  /** The screen's hard budget: a regression in buildScreen's line dropping must fail loudly. */
210
210
  export function assertScreenBudget(lines) {
211
211
  if (lines.length > MAX_LINES || lines.some((l) => l.length > WIDTH)) {
212
- throw new Error("first-run screen exceeded its 16 x 78 budget");
212
+ throw new Error(`first-run screen exceeded its ${MAX_LINES} x ${WIDTH} budget`);
213
213
  }
214
214
  }
215
215
  /** A transcript's mtime (ms), 0 when it vanished since discovery (it then sorts first). */