@mnemom/mnemom 0.17.3-next.1 → 0.17.3-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.
package/README.md CHANGED
@@ -84,6 +84,31 @@ An explicit launch shape (`--remote-control`, `--server`, `MNEMOM_AGENT_REMOTE_C
84
84
  or `launch` in `launcher.toml`) also skips the Remote Control capability probe
85
85
  (`claude --help`), which only feeds the automatic upgrade to Remote Control.
86
86
 
87
+ #### Context profile: how the gateway folds a session's context
88
+
89
+ `--context-profile <off|balanced|aggressive>` picks how the Mnemom gateway folds a
90
+ session's context. Each profile serves a different set of use cases:
91
+
92
+ - **`balanced`** (the default) keeps more of the earlier work verbatim and folds later —
93
+ for long sessions that revisit earlier files and decisions.
94
+ - **`aggressive`** folds earlier and keeps a tighter working set — for cost-sensitive
95
+ sessions and short, focused tasks.
96
+ - **`off`** leaves compaction to the coding agent.
97
+
98
+ Set it per launch, as a standing default, or in the environment (flag > env > `launcher.toml`):
99
+
100
+ ```bash
101
+ mnemom agent my-task --context-profile aggressive # this launch only
102
+ mnemom agent config set context_profile aggressive # persists in ~/.mnemom/launcher.toml
103
+ export MNEMOM_AGENT_CONTEXT_PROFILE=aggressive # for every launch in this shell
104
+ ```
105
+
106
+ `--no-context` (or `context = false` in `launcher.toml`) is the same as `off`. Pass one or the
107
+ other: `--no-context` together with `--context-profile balanced` or `aggressive` is refused. A
108
+ `--context-profile` flag overrides a standing `context = false` in `launcher.toml`. `--dry-run`
109
+ prints the resolved profile and, when the launch sends the per-session config header, the
110
+ decoded key/value list; `mnemom agent show` and the session ledger record the profile.
111
+
87
112
  #### Context handoff (experimental, off by default)
88
113
 
89
114
  **Off by default.** `--context-handoff` (or the standing default `mnemom agent config set
@@ -190,6 +190,9 @@ function deleteKey(cfg, key) {
190
190
  case "context_handoff":
191
191
  delete cfg.contextHandoff;
192
192
  break;
193
+ case "context_profile":
194
+ delete cfg.contextProfile;
195
+ break;
193
196
  default:
194
197
  requireSpec(key); // throws the known-keys message for anything unknown
195
198
  }
@@ -177,6 +177,8 @@ export function buildShowView(local, remote, reason) {
177
177
  launchBits.push(`model ${local.model}`);
178
178
  if (local.effort)
179
179
  launchBits.push(`effort ${local.effort}`);
180
+ if (local.context_profile)
181
+ launchBits.push(`context ${local.context_profile}`);
180
182
  L.push(fmt.label("launch: ", launchBits.join(" · ")));
181
183
  L.push(fmt.label("conversation: ", local.conversation_id));
182
184
  const goalMode = resolveGoalMode(local, remote);
@@ -25,7 +25,7 @@
25
25
  * line, or the proxy's stdin under RC). It is never logged, printed, or passed as
26
26
  * an argv value.
27
27
  */
28
- import { type LaunchShape, type SealedContractEnvelope } from "../lib/agent.js";
28
+ import { type ContextProfile, type LaunchShape, type SealedContractEnvelope } from "../lib/agent.js";
29
29
  export interface AgentOptions {
30
30
  goal?: string;
31
31
  requirement?: string[];
@@ -58,6 +58,12 @@ export interface AgentOptions {
58
58
  * gateway to skip folding for this session only.
59
59
  */
60
60
  context?: boolean;
61
+ /**
62
+ * How the gateway folds this session's context (`--context-profile`): `off`, `balanced` or
63
+ * `aggressive`. Undefined ⇒ MNEMOM_AGENT_CONTEXT_PROFILE (the `context_profile` setting), else
64
+ * `balanced`. `--no-context` is `off`; combined with balanced|aggressive the launch refuses.
65
+ */
66
+ contextProfile?: ContextProfile;
61
67
  /** context-hint seed default: commander `--no-context-hint` sets this false. */
62
68
  contextHint?: boolean;
63
69
  /** Attach the Slack channel server (`--slack`; lib/slack-launch.ts). */
@@ -108,6 +114,8 @@ export declare function recordSessionLaunch(ctx: {
108
114
  goalMode: string | null;
109
115
  /** The Claude Code session id (pickCcSessionId); null/absent when Claude Code picks it. */
110
116
  ccSessionId?: string | null;
117
+ /** The resolved context profile this launch runs under. */
118
+ contextProfile: ContextProfile;
111
119
  }): Promise<void>;
112
120
  /**
113
121
  * Resolve the Anthropic key with NO 1Password dependency (built for external
@@ -233,4 +241,8 @@ export declare function printPlan(ctx: {
233
241
  goalMode: string | null;
234
242
  yolo: boolean;
235
243
  cli: ResolvedCli;
244
+ /** The resolved context profile (`resolveContextProfile`). */
245
+ contextProfile: ContextProfile;
246
+ /** The decoded `x-mnemom-session-config` overlay (`buildSessionConfig`); empty ⇒ no header. */
247
+ sessionConfig: Record<string, string>;
236
248
  }): void;
@@ -40,12 +40,24 @@ import { fmt } from "../lib/format.js";
40
40
  import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
41
41
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
42
42
  import { AgentIdentityMismatchError, defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
43
- import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfigB64, resolveContextFold, resolveContextHandoff, resolveHandoffGatewayHost, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, sessionDisplayName, SESSION_NAME_ENV, } from "../lib/agent.js";
43
+ import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, sessionDisplayName, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
44
44
  import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
45
45
  import { slackChannelArgs } from "../lib/slack-launch.js";
46
46
  /** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
47
47
  const CODE_STORE = LAUNCHER_STORE_PATH;
48
48
  const say = (line = "") => console.error(line);
49
+ /**
50
+ * The one-line launch note for a non-default context profile (`balanced`, the default, is
51
+ * silent — nothing is sent). `off` keeps the wording `--no-context` has always printed.
52
+ */
53
+ function sayContextProfile(profile) {
54
+ if (profile === "off") {
55
+ say("mnemom agent: context folding OFF for this session (--no-context or context = false).");
56
+ }
57
+ else if (profile === "aggressive") {
58
+ say("mnemom agent: context profile aggressive — the gateway folds earlier and keeps a tighter working set.");
59
+ }
60
+ }
49
61
  /** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
50
62
  export async function agentCommand(scenarioArg, options = {}) {
51
63
  // ── session name + goal ── (a bare `mnemom agent` in a terminal asks for both,
@@ -99,12 +111,18 @@ export async function agentCommand(scenarioArg, options = {}) {
99
111
  const cellNote = isProdGateway(env) ? "us-2/prod" : `custom (${gatewayHost})`;
100
112
  const yolo = options.yolo !== false; // default ON
101
113
  const contextHint = options.contextHint !== false; // default ON
102
- // Context folding is ON by default; --no-context (or `context = false` in
103
- // launcher.toml → MNEMOM_AGENT_CONTEXT) turns it off for this session via the
104
- // per-session config header. `undefined` sessionConfigB64 ⇒ nothing sent.
105
- const contextFold = resolveContextFold({ context: options.context, env });
114
+ // Context profile: `balanced` (the gateway's own defaults) unless --context-profile /
115
+ // MNEMOM_AGENT_CONTEXT_PROFILE (the `context_profile` setting) says otherwise; --no-context
116
+ // (or `context = false` in launcher.toml → MNEMOM_AGENT_CONTEXT) is `off`. Anything but
117
+ // balanced rides in the per-session config header; `undefined` sessionConfigB64 ⇒ nothing sent.
118
+ const contextProfile = resolveContextProfile({
119
+ contextProfile: options.contextProfile,
120
+ context: options.context,
121
+ env,
122
+ });
106
123
  const contextHandoff = resolveContextHandoff({ contextHandoff: options.contextHandoff, env });
107
- const sessionConfigB64 = buildSessionConfigB64({ fold: contextFold, contextHandoff });
124
+ const sessionConfig = buildSessionConfig({ profile: contextProfile, contextHandoff });
125
+ const sessionConfigB64 = buildSessionConfigB64({ profile: contextProfile, contextHandoff });
108
126
  // ── which CLI executable (name or full path) ── resolve GRACEFULLY: if it's not
109
127
  // found we don't crash with a technical error — we ask for the path at launch.
110
128
  let cli;
@@ -143,13 +161,13 @@ export async function agentCommand(scenarioArg, options = {}) {
143
161
  goalMode,
144
162
  yolo,
145
163
  cli: planCli,
164
+ contextProfile,
165
+ sessionConfig,
146
166
  });
147
167
  if (previewShape === "remote-control") {
148
168
  say("mnemom agent: the plan shows Remote Control because this machine can run it; the real launch uses it only if your account is entitled (internal Mnemom accounts), else terminal-only.");
149
169
  }
150
- if (!contextFold) {
151
- say("mnemom agent: context folding OFF for this session (--no-context or context = false).");
152
- }
170
+ sayContextProfile(contextProfile);
153
171
  if (contextHandoff) {
154
172
  say("mnemom agent: context handoff ON (experimental) — the `handoff` MCP tool would be registered.");
155
173
  }
@@ -269,12 +287,11 @@ export async function agentCommand(scenarioArg, options = {}) {
269
287
  envelope,
270
288
  goalMode,
271
289
  ccSessionId: cc.id,
290
+ contextProfile,
272
291
  });
273
292
  logLaunch({ shape, door, agent, conversationId, cellNote, envelope, goalMode, cli });
274
293
  say(goalAlignmentLine(goalAlignmentMode(envelope, goalMode)));
275
- if (!contextFold) {
276
- say("mnemom agent: context folding OFF for this session (--no-context or context = false).");
277
- }
294
+ sayContextProfile(contextProfile);
278
295
  // ── handoff MCP tool (MNE-7564) ── OFF by default: absent/false ⇒ handoffMcpConfigPath stays
279
296
  // undefined and buildCliArgs/spawnRcCli are byte-identical to a build without this flag. ON:
280
297
  // the config is written to a 0600 temp file and referenced by PATH only — never inlined into
@@ -377,6 +394,7 @@ export async function recordSessionLaunch(ctx) {
377
394
  cli: ctx.cli.name,
378
395
  contract: toLedgerContract(ctx.envelope?.contract ?? null),
379
396
  goal_mode: goalAlignmentMode(ctx.envelope, ctx.goalMode),
397
+ context_profile: ctx.contextProfile,
380
398
  cc_session_id: ctx.ccSessionId ?? null,
381
399
  }, {
382
400
  dir: MNEMOM_DIR,
@@ -1085,6 +1103,13 @@ export function printPlan(ctx) {
1085
1103
  L.push(` agent: ${ctx.agent}`);
1086
1104
  L.push(` conversation: ${ctx.conversationId}`);
1087
1105
  L.push(` yolo: ${ctx.yolo ? "on (--dangerously-skip-permissions)" : "off"}`);
1106
+ L.push(` context: ${ctx.contextProfile}`);
1107
+ const overlay = Object.entries(ctx.sessionConfig);
1108
+ if (overlay.length > 0) {
1109
+ L.push(` session config (${SESSION_CONFIG_HEADER}):`);
1110
+ for (const [k, v] of overlay)
1111
+ L.push(` ${k}=${v}`);
1112
+ }
1088
1113
  if (ctx.envelope) {
1089
1114
  L.push(` contract: ${ctx.envelope.canonical}`);
1090
1115
  L.push(` contract sha: ${ctx.envelope.sha}`);
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { program, Option } from "commander";
2
+ import { program, InvalidArgumentError, Option } from "commander";
3
3
  import { isEntrypoint } from "./lib/entrypoint.js";
4
4
  import { CLI_VERSION } from "./version.js";
5
5
  import { statusCommand } from "./commands/status.js";
@@ -29,7 +29,7 @@ import { agentDoctorCommand } from "./commands/agent-doctor.js";
29
29
  import { agentSetupCommand } from "./commands/agent-setup.js";
30
30
  import { agentInviteCommand, runAgentInvocation } from "./commands/agent-invite.js";
31
31
  import { applyConfigToEnv, loadLauncherConfig } from "./lib/launcher-config.js";
32
- import { resolveImplicit } from "./lib/agent.js";
32
+ import { parseContextProfile, resolveImplicit } from "./lib/agent.js";
33
33
  import { wrapCommand } from "./commands/wrap.js";
34
34
  import { onboardCommand } from "./commands/onboard.js";
35
35
  import { skillsListCommand, skillsDescribeCommand } from "./commands/skills.js";
@@ -143,6 +143,16 @@ const agentCmd = program
143
143
  .option("--slack", "Attach the Slack channel: messages addressed to this session arrive in it, and it can reply (see `mnemom agent slack`)")
144
144
  .option("--context", "Force context folding ON for this launch even when launcher.toml (context = false) or MNEMOM_AGENT_CONTEXT turns it off")
145
145
  .option("--no-context", "Disable context folding for this launch (folding is on by default; tells the gateway to skip it for this session)")
146
+ .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) => {
147
+ // One validator for all three surfaces (flag, MNEMOM_AGENT_CONTEXT_PROFILE,
148
+ // launcher.toml context_profile): trimmed, case-insensitive, same error wording.
149
+ try {
150
+ return parseContextProfile(v, "--context-profile");
151
+ }
152
+ catch (e) {
153
+ throw new InvalidArgumentError(e.message);
154
+ }
155
+ }))
146
156
  .option("--context-handoff", "EXPERIMENTAL, off by default: register the `handoff(note)` MCP tool and tell the gateway to turn on the context-handoff capability for this session")
147
157
  .option("--no-context-handoff", "Keep context handoff off for this launch even when launcher.toml (context_handoff = true) or MNEMOM_AGENT_CONTEXT_HANDOFF turns it on")
148
158
  .option("--dry-run", "Print the resolved launch plan and exit — no key read, no spawn")
@@ -234,6 +244,7 @@ const agentCmd = program
234
244
  contextHint: opts.contextHint,
235
245
  slack: opts.slack ?? cfg.slackListen,
236
246
  context: opts.context,
247
+ contextProfile: opts.contextProfile,
237
248
  contextHandoff: opts.contextHandoff,
238
249
  setup: opts.setup,
239
250
  keySource: cfg.keySource,
@@ -14,7 +14,7 @@
14
14
  * `sessions.retention_days` (default 30) are dropped when the next one is
15
15
  * recorded. No count cap — a long retention keeps everything in the window.
16
16
  */
17
- import type { LaunchShape, SealedContract } from "./agent.js";
17
+ import type { ContextProfile, LaunchShape, SealedContract } from "./agent.js";
18
18
  /** The ledger's file name under MNEMOM_DIR. */
19
19
  export declare const LEDGER_FILE = "sessions.jsonl";
20
20
  /** Days a launch stays in the ledger when `sessions.retention_days` is unset. */
@@ -75,6 +75,12 @@ export interface SessionRecord {
75
75
  * records written before this field existed omit it (still `v: 1`).
76
76
  */
77
77
  cc_session_id?: string | null;
78
+ /**
79
+ * The context profile the launch ran under (`--context-profile` / `context_profile` /
80
+ * `MNEMOM_AGENT_CONTEXT_PROFILE`, or `balanced` by default). Optional: records written
81
+ * before profiles existed omit it (still `v: 1`).
82
+ */
83
+ context_profile?: ContextProfile;
78
84
  }
79
85
  /** Goal-alignment mode as recorded at launch. */
80
86
  export type GoalMode = "off" | "on" | "implicit";
@@ -161,6 +161,21 @@ export declare const GOAL_MODE_HEADER = "x-mnemom-goal-mode";
161
161
  export declare const SESSION_CONFIG_HEADER = "x-mnemom-session-config";
162
162
  /** The gateway env key that gates context folding (allow-listed for per-session overlay). */
163
163
  export declare const CONTEXT_RUNTIME_KEY = "CONTEXT_RUNTIME_ENABLED";
164
+ /** Gateway fold-tuning keys the `aggressive` context profile sets (all allow-listed per session). */
165
+ export declare const CONTEXT_TAIL_BUDGET_KEY = "CONTEXT_RUNTIME_TAIL_BUDGET_TOKENS";
166
+ export declare const CONTEXT_READ_CACHE_KEY = "CONTEXT_RUNTIME_READ_CACHE_TOKENS";
167
+ export declare const CONTEXT_HEAD_RECLAIM_KEY = "CONTEXT_RUNTIME_HEAD_BREAKPOINT_RECLAIM";
168
+ /**
169
+ * The named presets for how the gateway folds a session's context (`--context-profile`,
170
+ * launcher.toml `context_profile`, `MNEMOM_AGENT_CONTEXT_PROFILE`):
171
+ * - `off` → `CONTEXT_RUNTIME_ENABLED: "false"` — compaction is left to the coding agent
172
+ * (the same bytes `--no-context` has always sent).
173
+ * - `balanced` → NO fold keys: the gateway's own defaults stand. The default.
174
+ * - `aggressive` → a smaller tail budget + read cache and head-breakpoint reclaim on: folds
175
+ * earlier and keeps a tighter working set.
176
+ */
177
+ export declare const CONTEXT_PROFILES: readonly ["off", "balanced", "aggressive"];
178
+ export type ContextProfile = (typeof CONTEXT_PROFILES)[number];
164
179
  /**
165
180
  * The gateway env key that gates the whole context-handoff capability (level lines +
166
181
  * `/mnemom/context/handoff` route + the digest note section) for a single session
@@ -272,12 +287,49 @@ export declare function resolveContextFold(opts: {
272
287
  context?: boolean;
273
288
  env?: NodeJS.ProcessEnv;
274
289
  }): boolean;
290
+ /**
291
+ * Validate a context-profile word — the ONE validator behind all three surfaces (the
292
+ * `--context-profile` flag, launcher.toml `context_profile`, the `MNEMOM_AGENT_CONTEXT_PROFILE`
293
+ * env var); trimmed, case-insensitive. `source` names the surface in the error so a customer
294
+ * knows which setting to fix.
295
+ */
296
+ export declare function parseContextProfile(raw: string, source: string): ContextProfile;
297
+ /**
298
+ * Which context profile this launch runs under. Precedence, highest wins:
299
+ * 1. `--no-context` — always `off`. Combined with an explicit `--context-profile
300
+ * balanced|aggressive` it is a CONFLICT and the launch refuses (never a silent override);
301
+ * `--context-profile off` agrees with it.
302
+ * 2. an explicit `--context-profile <profile>` flag (`--context` alongside it is fine — the
303
+ * profile wins);
304
+ * 3. `MNEMOM_AGENT_CONTEXT_PROFILE` (which `applyConfigToEnv` sets from the `context_profile`
305
+ * config key) — an invalid value is an error, never a silent default;
306
+ * 4. the folding boolean when NO profile is set anywhere: `context = false` in launcher.toml
307
+ * (→ `MNEMOM_AGENT_CONTEXT`, see `resolveContextFold`) means `off`;
308
+ * 5. `balanced` (the default).
309
+ */
310
+ export declare function resolveContextProfile(opts: {
311
+ contextProfile?: ContextProfile;
312
+ context?: boolean;
313
+ env?: NodeJS.ProcessEnv;
314
+ }): ContextProfile;
315
+ /**
316
+ * The decoded per-session env overlay for this launch — the `config` object of the
317
+ * `x-mnemom-session-config` payload, in wire order: the profile's keys first
318
+ * (`CONTEXT_RUNTIME_ENABLED` for `off`; the three fold-tuning keys for `aggressive`; nothing
319
+ * for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON. Empty
320
+ * for `balanced` with handoff off — the common case, where no header is sent at all.
321
+ * Exported so `--dry-run` can list exactly what the header carries.
322
+ */
323
+ export declare function buildSessionConfig(opts: {
324
+ profile: ContextProfile;
325
+ contextHandoff: boolean;
326
+ }): Record<string, string>;
275
327
  /**
276
328
  * The `x-mnemom-session-config` value for this launch, or `undefined` when there is
277
- * nothing to override (the gateway's own defaults stand — the common case). Two
278
- * independent overlays, both OFF by default (folding is ON by default so only its OFF
279
- * state is ever sent; handoff is OFF by default so only its ON state is ever sent):
280
- * - `fold: false` → `CONTEXT_RUNTIME_ENABLED: "false"` (`--no-context`).
329
+ * nothing to override (the gateway's own defaults stand — `balanced` with handoff off,
330
+ * byte-identical to a launch before profiles existed). Two independent overlays:
331
+ * - the context profile (`buildSessionConfig`): `off` → `CONTEXT_RUNTIME_ENABLED: "false"`,
332
+ * `aggressive` → the three fold-tuning keys, `balanced` → nothing;
281
333
  * - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
282
334
  * setting) — the SAME header the `handoff` MCP tool's own call to
283
335
  * `/mnemom/context/handoff` must also send, since the gateway resolves the flag
@@ -285,7 +337,7 @@ export declare function resolveContextFold(opts: {
285
337
  * base64url so it survives the gateway's inbound strip.
286
338
  */
287
339
  export declare function buildSessionConfigB64(opts: {
288
- fold: boolean;
340
+ profile: ContextProfile;
289
341
  contextHandoff: boolean;
290
342
  }): string | undefined;
291
343
  /**
package/dist/lib/agent.js CHANGED
@@ -226,6 +226,30 @@ export const GOAL_MODE_HEADER = "x-mnemom-goal-mode";
226
226
  export const SESSION_CONFIG_HEADER = "x-mnemom-session-config";
227
227
  /** The gateway env key that gates context folding (allow-listed for per-session overlay). */
228
228
  export const CONTEXT_RUNTIME_KEY = "CONTEXT_RUNTIME_ENABLED";
229
+ /** Gateway fold-tuning keys the `aggressive` context profile sets (all allow-listed per session). */
230
+ export const CONTEXT_TAIL_BUDGET_KEY = "CONTEXT_RUNTIME_TAIL_BUDGET_TOKENS";
231
+ export const CONTEXT_READ_CACHE_KEY = "CONTEXT_RUNTIME_READ_CACHE_TOKENS";
232
+ export const CONTEXT_HEAD_RECLAIM_KEY = "CONTEXT_RUNTIME_HEAD_BREAKPOINT_RECLAIM";
233
+ /**
234
+ * The named presets for how the gateway folds a session's context (`--context-profile`,
235
+ * launcher.toml `context_profile`, `MNEMOM_AGENT_CONTEXT_PROFILE`):
236
+ * - `off` → `CONTEXT_RUNTIME_ENABLED: "false"` — compaction is left to the coding agent
237
+ * (the same bytes `--no-context` has always sent).
238
+ * - `balanced` → NO fold keys: the gateway's own defaults stand. The default.
239
+ * - `aggressive` → a smaller tail budget + read cache and head-breakpoint reclaim on: folds
240
+ * earlier and keeps a tighter working set.
241
+ */
242
+ export const CONTEXT_PROFILES = ["off", "balanced", "aggressive"];
243
+ /** The per-session env overlay each profile expands to (key order is the wire order). */
244
+ const CONTEXT_PROFILE_CONFIG = {
245
+ off: [[CONTEXT_RUNTIME_KEY, "false"]],
246
+ balanced: [],
247
+ aggressive: [
248
+ [CONTEXT_TAIL_BUDGET_KEY, "60000"],
249
+ [CONTEXT_READ_CACHE_KEY, "8000"],
250
+ [CONTEXT_HEAD_RECLAIM_KEY, "true"],
251
+ ],
252
+ };
229
253
  /**
230
254
  * The gateway env key that gates the whole context-handoff capability (level lines +
231
255
  * `/mnemom/context/handoff` route + the digest note section) for a single session
@@ -432,12 +456,69 @@ export function resolveContextFold(opts) {
432
456
  return true;
433
457
  return !(raw === "0" || raw === "false" || raw === "off" || raw === "no");
434
458
  }
459
+ /**
460
+ * Validate a context-profile word — the ONE validator behind all three surfaces (the
461
+ * `--context-profile` flag, launcher.toml `context_profile`, the `MNEMOM_AGENT_CONTEXT_PROFILE`
462
+ * env var); trimmed, case-insensitive. `source` names the surface in the error so a customer
463
+ * knows which setting to fix.
464
+ */
465
+ export function parseContextProfile(raw, source) {
466
+ const v = raw.trim().toLowerCase();
467
+ if (CONTEXT_PROFILES.includes(v))
468
+ return v;
469
+ throw new Error(`${source} must be one of ${CONTEXT_PROFILES.join(", ")} (got '${raw}').`);
470
+ }
471
+ /**
472
+ * Which context profile this launch runs under. Precedence, highest wins:
473
+ * 1. `--no-context` — always `off`. Combined with an explicit `--context-profile
474
+ * balanced|aggressive` it is a CONFLICT and the launch refuses (never a silent override);
475
+ * `--context-profile off` agrees with it.
476
+ * 2. an explicit `--context-profile <profile>` flag (`--context` alongside it is fine — the
477
+ * profile wins);
478
+ * 3. `MNEMOM_AGENT_CONTEXT_PROFILE` (which `applyConfigToEnv` sets from the `context_profile`
479
+ * config key) — an invalid value is an error, never a silent default;
480
+ * 4. the folding boolean when NO profile is set anywhere: `context = false` in launcher.toml
481
+ * (→ `MNEMOM_AGENT_CONTEXT`, see `resolveContextFold`) means `off`;
482
+ * 5. `balanced` (the default).
483
+ */
484
+ export function resolveContextProfile(opts) {
485
+ if (opts.context === false) {
486
+ if (opts.contextProfile !== undefined && opts.contextProfile !== "off") {
487
+ throw new Error(`mnemom agent: --no-context and --context-profile ${opts.contextProfile} disagree — drop one (--no-context is the same as --context-profile off).`);
488
+ }
489
+ return "off";
490
+ }
491
+ if (opts.contextProfile !== undefined)
492
+ return opts.contextProfile;
493
+ const raw = (opts.env?.MNEMOM_AGENT_CONTEXT_PROFILE ?? "").trim();
494
+ if (raw !== "")
495
+ return parseContextProfile(raw, "MNEMOM_AGENT_CONTEXT_PROFILE");
496
+ if (!resolveContextFold({ context: opts.context, env: opts.env }))
497
+ return "off";
498
+ return "balanced";
499
+ }
500
+ /**
501
+ * The decoded per-session env overlay for this launch — the `config` object of the
502
+ * `x-mnemom-session-config` payload, in wire order: the profile's keys first
503
+ * (`CONTEXT_RUNTIME_ENABLED` for `off`; the three fold-tuning keys for `aggressive`; nothing
504
+ * for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON. Empty
505
+ * for `balanced` with handoff off — the common case, where no header is sent at all.
506
+ * Exported so `--dry-run` can list exactly what the header carries.
507
+ */
508
+ export function buildSessionConfig(opts) {
509
+ const config = {};
510
+ for (const [k, v] of CONTEXT_PROFILE_CONFIG[opts.profile])
511
+ config[k] = v;
512
+ if (opts.contextHandoff)
513
+ config[CONTEXT_HANDOFF_KEY] = "true";
514
+ return config;
515
+ }
435
516
  /**
436
517
  * The `x-mnemom-session-config` value for this launch, or `undefined` when there is
437
- * nothing to override (the gateway's own defaults stand — the common case). Two
438
- * independent overlays, both OFF by default (folding is ON by default so only its OFF
439
- * state is ever sent; handoff is OFF by default so only its ON state is ever sent):
440
- * - `fold: false` → `CONTEXT_RUNTIME_ENABLED: "false"` (`--no-context`).
518
+ * nothing to override (the gateway's own defaults stand — `balanced` with handoff off,
519
+ * byte-identical to a launch before profiles existed). Two independent overlays:
520
+ * - the context profile (`buildSessionConfig`): `off` → `CONTEXT_RUNTIME_ENABLED: "false"`,
521
+ * `aggressive` → the three fold-tuning keys, `balanced` → nothing;
441
522
  * - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
442
523
  * setting) — the SAME header the `handoff` MCP tool's own call to
443
524
  * `/mnemom/context/handoff` must also send, since the gateway resolves the flag
@@ -445,11 +526,7 @@ export function resolveContextFold(opts) {
445
526
  * base64url so it survives the gateway's inbound strip.
446
527
  */
447
528
  export function buildSessionConfigB64(opts) {
448
- const config = {};
449
- if (!opts.fold)
450
- config[CONTEXT_RUNTIME_KEY] = "false";
451
- if (opts.contextHandoff)
452
- config[CONTEXT_HANDOFF_KEY] = "true";
529
+ const config = buildSessionConfig(opts);
453
530
  if (Object.keys(config).length === 0)
454
531
  return undefined;
455
532
  const payload = JSON.stringify({ v: 1, config });
@@ -15,7 +15,7 @@
15
15
  * resolvers in lib/code.ts keep the built-in default as the final fallback);
16
16
  * the caller folds the plain-value settings under the CLI flags.
17
17
  */
18
- import { type GoalModeDefault, type LaunchShape } from "./agent.js";
18
+ import { type ContextProfile, type GoalModeDefault, type LaunchShape } from "./agent.js";
19
19
  /** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
20
20
  export declare const LAUNCHER_CONFIG_PATH: string;
21
21
  /** Where the CLI resolves the Anthropic key from; advisory ordering/restriction. */
@@ -45,6 +45,12 @@ export interface LauncherConfig {
45
45
  * every launch. Off means byte-identical to a launch with no MNE-7564 change at all.
46
46
  */
47
47
  contextHandoff?: boolean;
48
+ /**
49
+ * How the gateway folds each session's context (`balanced` when unset): `off` leaves
50
+ * compaction to the coding agent, `balanced` keeps more earlier work verbatim, `aggressive`
51
+ * folds earlier with a tighter working set. `--context-profile` overrides it per launch.
52
+ */
53
+ contextProfile?: ContextProfile;
48
54
  guardrails?: {
49
55
  maxTurns?: number;
50
56
  budgetUsd?: number;
@@ -19,7 +19,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
19
19
  import { dirname, join } from "node:path";
20
20
  import { parse as parseToml, stringify as stringifyToml, TomlError } from "smol-toml";
21
21
  import { MNEMOM_DIR } from "./config.js";
22
- import { isPositiveInt, isPositiveNumber, } from "./agent.js";
22
+ import { CONTEXT_PROFILES, isPositiveInt, isPositiveNumber, parseContextProfile, } from "./agent.js";
23
23
  /** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
24
24
  export const LAUNCHER_CONFIG_PATH = join(MNEMOM_DIR, "launcher.toml");
25
25
  const LAUNCH_SHAPES = ["terminal", "remote-control", "server"];
@@ -188,6 +188,14 @@ export const CONFIG_KEYS = [
188
188
  c.contextHandoff = coerceBool("context_handoff", v);
189
189
  },
190
190
  },
191
+ {
192
+ key: "context_profile",
193
+ describe: `How the gateway folds each session's context (${CONTEXT_PROFILES.join("|")}; balanced by default)`,
194
+ get: (c) => c.contextProfile,
195
+ set: (c, v) => {
196
+ c.contextProfile = parseContextProfile(v, "context_profile");
197
+ },
198
+ },
191
199
  {
192
200
  key: "guardrails.max_turns",
193
201
  describe: "Contract guardrail: turn ceiling (positive integer)",
@@ -266,6 +274,8 @@ export function parseLauncherConfig(text) {
266
274
  cfg.slackChannel = coerceNonEmpty("slack_channel", str(raw.slack_channel) ?? "");
267
275
  if (raw.context_handoff !== undefined)
268
276
  cfg.contextHandoff = coerceBool("context_handoff", str(raw.context_handoff) ?? "");
277
+ if (raw.context_profile !== undefined)
278
+ cfg.contextProfile = parseContextProfile(str(raw.context_profile) ?? "", "context_profile");
269
279
  const g = raw.guardrails;
270
280
  if (g !== undefined) {
271
281
  if (typeof g !== "object" || g === null || Array.isArray(g)) {
@@ -334,6 +344,8 @@ export function serializeLauncherConfig(cfg) {
334
344
  doc.slack_allow = cfg.slackAllow;
335
345
  if (cfg.contextHandoff !== undefined)
336
346
  doc.context_handoff = cfg.contextHandoff;
347
+ if (cfg.contextProfile !== undefined)
348
+ doc.context_profile = cfg.contextProfile;
337
349
  const g = cfg.guardrails;
338
350
  if (g && (g.maxTurns !== undefined || g.budgetUsd !== undefined || g.stallTurns !== undefined)) {
339
351
  const gr = {};
@@ -386,4 +398,7 @@ export function applyConfigToEnv(cfg, env = process.env) {
386
398
  if (cfg.contextHandoff !== undefined && !(env.MNEMOM_AGENT_CONTEXT_HANDOFF ?? "").trim()) {
387
399
  env.MNEMOM_AGENT_CONTEXT_HANDOFF = cfg.contextHandoff ? "1" : "0";
388
400
  }
401
+ if (cfg.contextProfile !== undefined && !(env.MNEMOM_AGENT_CONTEXT_PROFILE ?? "").trim()) {
402
+ env.MNEMOM_AGENT_CONTEXT_PROFILE = cfg.contextProfile;
403
+ }
389
404
  }
@@ -64,6 +64,9 @@ export interface UpdateState {
64
64
  method?: InstallMethod;
65
65
  /** Channel the last successful update installed from; feeds defaultChannel. */
66
66
  followChannel?: UpdateChannel;
67
+ /** The background worker startup last spawned, while it runs (cleared when it finishes). */
68
+ workerPid?: number;
69
+ workerSince?: number;
67
70
  }
68
71
  export interface UpdatePrefs {
69
72
  channel?: UpdateChannel;
@@ -121,6 +124,19 @@ export declare function entitlementFresh(cache: EntitlementCache | undefined, no
121
124
  * registry response).
122
125
  */
123
126
  export declare function pickTarget(tags: DistTags, channel: UpdateChannel): string | undefined;
127
+ /**
128
+ * Longest a worker lives: it waits for its parent `mnemom` (a whole agent session)
129
+ * up to this long. A recorded worker older than this is treated as gone, so a
130
+ * recycled pid can't block updates.
131
+ */
132
+ export declare const WORKER_MAX_AGE_MS: number;
133
+ /**
134
+ * A known update is still pending and nothing is working on it: the last worker
135
+ * recorded `available` but was killed before installing (e.g. while waiting out
136
+ * a long agent session). Startup respawns one without waiting for the next
137
+ * scheduled check.
138
+ */
139
+ export declare function shouldResume(state: UpdateState, current: string, now: number, alive: (pid: number) => boolean): boolean;
124
140
  /** Whether the background worker should poll the registry now. */
125
141
  export declare function shouldCheck(state: UpdateState, channel: UpdateChannel, now: number): boolean;
126
142
  export declare function backoffDelay(failures: number): number;
@@ -115,6 +115,33 @@ export function pickTarget(tags, channel) {
115
115
  return next;
116
116
  return compareVersions(next, stable) >= 0 ? next : stable;
117
117
  }
118
+ /**
119
+ * Longest a worker lives: it waits for its parent `mnemom` (a whole agent session)
120
+ * up to this long. A recorded worker older than this is treated as gone, so a
121
+ * recycled pid can't block updates.
122
+ */
123
+ export const WORKER_MAX_AGE_MS = 12 * HOUR;
124
+ /**
125
+ * A known update is still pending and nothing is working on it: the last worker
126
+ * recorded `available` but was killed before installing (e.g. while waiting out
127
+ * a long agent session). Startup respawns one without waiting for the next
128
+ * scheduled check.
129
+ */
130
+ export function shouldResume(state, current, now, alive) {
131
+ if (!state.available || !isValidVersion(state.available))
132
+ return false;
133
+ if (compareVersions(state.available, current) <= 0)
134
+ return false;
135
+ if (state.installBlocked || !canSelfUpdate(state.method ?? "unknown"))
136
+ return false;
137
+ if (state.backoffUntil !== undefined && now < state.backoffUntil)
138
+ return false;
139
+ const working = state.workerPid !== undefined &&
140
+ state.workerSince !== undefined &&
141
+ now - state.workerSince < WORKER_MAX_AGE_MS &&
142
+ alive(state.workerPid);
143
+ return !working;
144
+ }
118
145
  /** Whether the background worker should poll the registry now. */
119
146
  export function shouldCheck(state, channel, now) {
120
147
  if (state.backoffUntil !== undefined && now < state.backoffUntil)
@@ -29,6 +29,8 @@ export interface RunDeps {
29
29
  * `mnemom agent` session). Resolving false skips the install this pass.
30
30
  */
31
31
  beforeInstall?(): Promise<boolean>;
32
+ /** The background worker's own pid: it clears its `workerPid` record when done. */
33
+ pid?: number;
32
34
  }
33
35
  export interface RunResult {
34
36
  /** The channel actually honoured (next falls back to latest when not entitled). */
@@ -37,13 +37,17 @@ function failurePatch(state, now, message) {
37
37
  export async function runUpdate(deps, settings, opts = {}) {
38
38
  const now = deps.now();
39
39
  const state = deps.loadState();
40
+ // Every exit path forgets this worker's record, so startup can tell it's gone.
41
+ const done = deps.pid !== undefined && state.workerPid === deps.pid
42
+ ? { workerPid: undefined, workerSince: undefined }
43
+ : {};
40
44
  let tags;
41
45
  try {
42
46
  tags = await deps.fetchDistTags();
43
47
  }
44
48
  catch (err) {
45
49
  const message = err instanceof Error ? err.message : String(err);
46
- deps.saveState(failurePatch(state, now, message));
50
+ deps.saveState({ ...failurePatch(state, now, message), ...done });
47
51
  return { channel: settings.channel, channelDowngraded: false, error: message };
48
52
  }
49
53
  const entitled = settings.channel === "next" ? await resolveEntitlement(deps, state, now) : false;
@@ -71,12 +75,18 @@ export async function runUpdate(deps, settings, opts = {}) {
71
75
  };
72
76
  const result = { channel, channelDowngraded, method, target, action };
73
77
  if (action.kind !== "install") {
74
- deps.saveState(base);
78
+ deps.saveState({ ...base, ...done });
75
79
  return result;
76
80
  }
77
- const proceed = deps.beforeInstall ? await deps.beforeInstall() : true;
78
- if (!proceed) {
81
+ let proceed = true;
82
+ if (deps.beforeInstall) {
83
+ // Record what we found BEFORE the (possibly hours-long) wait: if this worker
84
+ // is killed meanwhile, the next launch still knows an update is pending.
79
85
  deps.saveState(base);
86
+ proceed = await deps.beforeInstall();
87
+ }
88
+ if (!proceed) {
89
+ deps.saveState({ ...base, ...done });
80
90
  return { ...result, action: { kind: "notify", version: action.version, reason: "deferred" } };
81
91
  }
82
92
  const outcome = await deps.install(action.version);
@@ -87,15 +97,20 @@ export async function runUpdate(deps, settings, opts = {}) {
87
97
  installBlocked: undefined,
88
98
  pending: { from: deps.current, to: action.version, channel },
89
99
  followChannel: channel,
100
+ ...done,
90
101
  });
91
102
  return { ...result, installed: action.version };
92
103
  }
93
104
  if (outcome.eacces) {
94
105
  // Retrying can't fix permissions — switch to notify-with-instructions.
95
- deps.saveState({ ...base, installBlocked: true, lastError: outcome.message });
106
+ deps.saveState({ ...base, installBlocked: true, lastError: outcome.message, ...done });
96
107
  }
97
108
  else {
98
- deps.saveState({ ...failurePatch(state, now, outcome.message), available: base.available });
109
+ deps.saveState({
110
+ ...failurePatch(state, now, outcome.message),
111
+ available: base.available,
112
+ ...done,
113
+ });
99
114
  }
100
115
  return { ...result, error: outcome.message };
101
116
  }
@@ -2,7 +2,8 @@
2
2
  * The per-invocation hook, run before commander parses argv. It must cost
3
3
  * ~nothing and never fail the user's command:
4
4
  * 1. print a one-line stderr notice (completed update, or an available one);
5
- * 2. if a check is due, spawn the detached worker and return immediately.
5
+ * 2. if a check is due — or a known update was left uninstalled by a worker
6
+ * that died — spawn the detached worker and return immediately.
6
7
  * All I/O is injected; `defaultStartupDeps()` wires the real process.
7
8
  */
8
9
  import { type UpdateChannel, type UpdateMode, type UpdatePrefs, type UpdateState } from "./decide.js";
@@ -18,8 +19,10 @@ export interface StartupDeps {
18
19
  saveState(patch: Partial<UpdateState>): void;
19
20
  entryPath: string;
20
21
  writeErr(line: string): void;
21
- spawnWorker(args: [entry: string, channel: UpdateChannel, mode: UpdateMode, parentPid: string]): void;
22
+ /** Returns the worker's pid (undefined if the spawn failed). */
23
+ spawnWorker(args: [entry: string, channel: UpdateChannel, mode: UpdateMode, parentPid: string]): number | undefined;
22
24
  pid: number;
25
+ alive(pid: number): boolean;
23
26
  }
24
27
  export declare function maybeRunUpdateHook(deps: StartupDeps): void;
25
28
  /** dist/lib/update/startup.js → dist/update-worker.js */
@@ -2,16 +2,18 @@
2
2
  * The per-invocation hook, run before commander parses argv. It must cost
3
3
  * ~nothing and never fail the user's command:
4
4
  * 1. print a one-line stderr notice (completed update, or an available one);
5
- * 2. if a check is due, spawn the detached worker and return immediately.
5
+ * 2. if a check is due — or a known update was left uninstalled by a worker
6
+ * that died — spawn the detached worker and return immediately.
6
7
  * All I/O is injected; `defaultStartupDeps()` wires the real process.
7
8
  */
8
9
  import { spawn } from "node:child_process";
9
10
  import { fileURLToPath } from "node:url";
10
11
  import { CLI_VERSION } from "../../version.js";
11
- import { resolveSettings, shouldCheck, startupNotice, } from "./decide.js";
12
+ import { resolveSettings, shouldCheck, shouldResume, startupNotice, } from "./decide.js";
12
13
  import { classifyInstall } from "./install-method.js";
13
14
  import { loadUpdateState, saveUpdateState } from "./state.js";
14
15
  import { entryRealpath, loadUpdatePrefs } from "./system.js";
16
+ import { isAlive } from "./worker.js";
15
17
  /** Commands that manage updates themselves, or must stay side-effect free. */
16
18
  const SKIP_COMMANDS = new Set(["update", "config"]);
17
19
  export function maybeRunUpdateHook(deps) {
@@ -41,11 +43,21 @@ export function maybeRunUpdateHook(deps) {
41
43
  }
42
44
  if (SKIP_COMMANDS.has(deps.argv[0] ?? ""))
43
45
  return;
44
- if (!shouldCheck(state, settings.channel, now))
46
+ const due = shouldCheck(state, settings.channel, now);
47
+ const resume = !due && settings.mode === "auto" && shouldResume(state, deps.current, now, deps.alive);
48
+ if (!due && !resume)
45
49
  return;
46
50
  // Stamp first so a burst of commands doesn't spawn a burst of workers.
47
- deps.saveState({ lastCheck: now });
48
- deps.spawnWorker([deps.entryPath, settings.channel, settings.mode, String(deps.pid)]);
51
+ if (due)
52
+ deps.saveState({ lastCheck: now });
53
+ const pid = deps.spawnWorker([
54
+ deps.entryPath,
55
+ settings.channel,
56
+ settings.mode,
57
+ String(deps.pid),
58
+ ]);
59
+ if (pid !== undefined)
60
+ deps.saveState({ workerPid: pid, workerSince: now });
49
61
  }
50
62
  catch {
51
63
  /* the updater never breaks the user's command */
@@ -75,6 +87,8 @@ export function defaultStartupDeps(argv) {
75
87
  });
76
88
  child.on("error", () => { });
77
89
  child.unref();
90
+ return child.pid;
78
91
  },
92
+ alive: (pid) => isAlive(pid),
79
93
  };
80
94
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { CLI_VERSION } from "../../version.js";
10
10
  import { getApiUrl } from "../config.js";
11
- import { isUpdateChannel, isUpdateMode } from "./decide.js";
11
+ import { isUpdateChannel, isUpdateMode, WORKER_MAX_AGE_MS, } from "./decide.js";
12
12
  import { classifyInstall } from "./install-method.js";
13
13
  import { fetchChannelEntitlement, fetchDistTags } from "./registry.js";
14
14
  import { runUpdate } from "./run.js";
@@ -23,7 +23,7 @@ export function parseWorkerArgs(args) {
23
23
  }
24
24
  export const PARENT_POLL_MS = 5000;
25
25
  /** Give up waiting on a very long session; the next run picks the update up. */
26
- export const PARENT_WAIT_MAX_MS = 12 * 60 * 60 * 1000;
26
+ export const PARENT_WAIT_MAX_MS = WORKER_MAX_AGE_MS;
27
27
  /** `kill(pid, 0)` probes liveness without signalling. EPERM still means alive. */
28
28
  export function isAlive(pid, kill = (p, s) => void process.kill(p, s)) {
29
29
  try {
@@ -60,6 +60,7 @@ export function realRunDeps(entry, credential) {
60
60
  fetchEntitlement: async () => fetchChannelEntitlement(fetch, getApiUrl(), await credential()),
61
61
  installMethod: async () => classifyInstall(entry, await npmGlobalRoot()),
62
62
  install: (version) => npmInstallGlobal(version),
63
+ pid: process.pid,
63
64
  };
64
65
  }
65
66
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.3-next.1",
3
+ "version": "0.17.3-next.3",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {