@mnemom/mnemom 0.17.0-next.7 → 0.17.0-next.9

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.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * `mnemom agent sessions [--all] [--json]` and `mnemom agent show [ref] [--json]`
3
+ * — the read side of the local session ledger (`~/.mnemom/sessions.jsonl`, one
4
+ * line per real launch) merged with the gateway's live goal state for one
5
+ * session (`GET /v1/agent/sessions/{conversationId}` via mnemom-api).
6
+ *
7
+ * Pure apart from the injected deps (`readRecords`, `fetchSession`, `out`,
8
+ * `err`), so every arm is unit-tested; `liveSessionDeps` is the thin driver
9
+ * index.ts wires in.
10
+ *
11
+ * Hash note: the CLI's launch-time envelope sha will NOT equal the gateway's
12
+ * sealed hash in general (the gateway normalises absent arrays to `[]`), so the
13
+ * local sha is never printed as "the sealed hash" and no mismatch is flagged —
14
+ * only the server's hash is shown, labelled "sealed hash".
15
+ */
16
+ import { readSessionRecords } from "../lib/agent-ledger.js";
17
+ import { MNEMOM_DIR } from "../lib/config.js";
18
+ import { getAgentSession, MnemomApiError } from "../lib/api.js";
19
+ import { fmt, formatRelativeAge } from "../lib/format.js";
20
+ /** The production wiring (index.ts). Lazy so a test that mocks lib/api without
21
+ * this export can still import index.ts. */
22
+ export const liveSessionDeps = {
23
+ // The ledger is oldest-first on disk; every consumer here wants newest-first.
24
+ readRecords: () => readSessionRecords({ dir: MNEMOM_DIR }).reverse(),
25
+ fetchSession: (conversationId, agentId, orgId) => getAgentSession(conversationId, agentId, orgId),
26
+ out: (line) => console.log(line),
27
+ err: (line) => console.error(line),
28
+ };
29
+ export const NO_SESSIONS_MESSAGE = "No sessions yet — run `mnemom agent` to start one.";
30
+ export const DEFAULT_SESSIONS_LIMIT = 20;
31
+ /**
32
+ * Resolve `[ref]` against the ledger (newest first):
33
+ * - no ref → the newest record;
34
+ * - an exact session name → the newest record with that name, older ones in `others`;
35
+ * - else a conversation-id prefix → the unique match, or `{record: null, candidates}`
36
+ * when several share the prefix;
37
+ * - nothing matches (or the ledger is empty) → null.
38
+ */
39
+ export function resolveSessionRef(records, ref) {
40
+ const needle = ref?.trim() ?? "";
41
+ if (needle === "") {
42
+ const newest = records[0];
43
+ return newest ? { record: newest, others: [] } : null;
44
+ }
45
+ const newestByName = records.find((r) => r.session === needle);
46
+ if (newestByName) {
47
+ const others = records.filter((r) => r !== newestByName && r.session === needle);
48
+ return { record: newestByName, others };
49
+ }
50
+ const byConv = records.filter((r) => r.conversation_id.startsWith(needle));
51
+ if (byConv.length === 1)
52
+ return { record: byConv[0], others: [] };
53
+ if (byConv.length > 1)
54
+ return { record: null, candidates: byConv };
55
+ return null;
56
+ }
57
+ // ─── sessions table ───────────────────────────────────────────────────────────
58
+ const COL = { session: 26, started: 16, agent: 22, goal: 6, conv: 8 };
59
+ function cell(text, width) {
60
+ const max = width - 2;
61
+ const shown = text.length > max ? `${text.slice(0, max - 1)}…` : text;
62
+ return shown.padEnd(width);
63
+ }
64
+ /** Goal alignment as the ledger recorded it: a contract at launch ⇒ on. */
65
+ export function localGoalFlag(record) {
66
+ return record.contract ? "on" : "off";
67
+ }
68
+ /**
69
+ * Render the ledger as a table, newest first — the newest
70
+ * `DEFAULT_SESSIONS_LIMIT` rows unless `all`, with a footer naming the rest.
71
+ */
72
+ export function formatSessionsTable(rows, opts) {
73
+ const shown = opts.all ? rows : rows.slice(0, DEFAULT_SESSIONS_LIMIT);
74
+ const lines = [];
75
+ lines.push(" " +
76
+ cell("Session", COL.session) +
77
+ cell("Started", COL.started) +
78
+ cell("Agent", COL.agent) +
79
+ cell("Goal", COL.goal) +
80
+ "Conv");
81
+ lines.push(" " + "─".repeat(COL.session + COL.started + COL.agent + COL.goal + COL.conv));
82
+ for (const r of shown) {
83
+ lines.push(" " +
84
+ cell(r.session, COL.session) +
85
+ cell(formatRelativeAge(r.started_at), COL.started) +
86
+ cell(r.agent_slug, COL.agent) +
87
+ cell(localGoalFlag(r), COL.goal) +
88
+ r.conversation_id.slice(0, 8));
89
+ }
90
+ if (shown.length < rows.length) {
91
+ lines.push(fmt.dim(` showing ${shown.length} of ${rows.length} sessions — pass --all for every session`));
92
+ }
93
+ return lines;
94
+ }
95
+ // ─── show view ────────────────────────────────────────────────────────────────
96
+ function listOrDash(items) {
97
+ return items && items.length > 0 ? items.join(", ") : "—";
98
+ }
99
+ function numOrDash(n) {
100
+ return n === null || n === undefined ? "—" : String(n);
101
+ }
102
+ /** `turns 3/10 (crossed)` — value against its ceiling (none ⇒ just the value). */
103
+ export function gauge(label, value, ceiling, fired) {
104
+ const ratio = ceiling === null ? String(value) : `${value}/${ceiling}`;
105
+ return `${label} ${ratio}${fired ? " (crossed)" : ""}`;
106
+ }
107
+ /**
108
+ * The human view for `mnemom agent show`: the local record, then the live
109
+ * gateway state (or a dim "live state unavailable (<reason>)" line when
110
+ * `remote` is null).
111
+ */
112
+ export function buildShowView(local, remote, reason) {
113
+ const L = [];
114
+ L.push(fmt.header(`Session ${local.session}`));
115
+ L.push(fmt.label("started: ", `${local.started_at} (${formatRelativeAge(local.started_at)})`));
116
+ L.push(fmt.label("agent: ", `${local.agent_slug} ${local.agent_id ? `(${local.agent_id})` : fmt.dim("(id unresolved at launch)")}`));
117
+ L.push(fmt.label("org: ", `${local.org_label} (${local.org_id})`));
118
+ L.push(fmt.label("gateway: ", local.gateway_host));
119
+ const launchBits = [local.launch_shape, `cli ${local.cli}`];
120
+ if (local.model)
121
+ launchBits.push(`model ${local.model}`);
122
+ if (local.effort)
123
+ launchBits.push(`effort ${local.effort}`);
124
+ L.push(fmt.label("launch: ", launchBits.join(" · ")));
125
+ L.push(fmt.label("conversation: ", local.conversation_id));
126
+ const goalOn = remote ? remote.sealed !== null : local.contract !== null;
127
+ L.push(fmt.label("goal alignment:", goalOn ? fmt.badge("ON", "green") : fmt.badge("OFF", "yellow")));
128
+ if (local.contract) {
129
+ L.push(fmt.label("goal (at launch):", local.contract.statement));
130
+ }
131
+ L.push(fmt.section("Live state (gateway)"));
132
+ if (!remote) {
133
+ L.push(fmt.dim(` live state unavailable (${reason ?? "unknown"})`));
134
+ return L;
135
+ }
136
+ if (remote.sealed) {
137
+ const c = remote.sealed.contract;
138
+ L.push(fmt.label("statement: ", c.statement));
139
+ L.push(fmt.label("requirements: ", listOrDash(c.requirements)));
140
+ L.push(fmt.label("allowed paths: ", listOrDash(c.allowed_paths)));
141
+ L.push(fmt.label("forbidden paths:", listOrDash(c.forbidden_paths)));
142
+ L.push(fmt.label("ceilings: ", `max_turns ${numOrDash(c.max_turns)} · budget_usd ${numOrDash(c.budget_usd)} · stall_turns ${numOrDash(c.stall_turns)}`));
143
+ L.push(fmt.label("sealed hash: ", remote.sealed.hash));
144
+ L.push(fmt.label("sealed at: ", remote.sealed.sealed_at));
145
+ }
146
+ else {
147
+ L.push(fmt.dim(" no sealed contract on the gateway for this conversation"));
148
+ }
149
+ if (remote.verdict) {
150
+ const v = remote.verdict;
151
+ L.push(fmt.label("verdict: ", `${v.verdict.toUpperCase()} at turn ${v.turn} — ${v.reason}`));
152
+ }
153
+ else {
154
+ L.push(fmt.label("verdict: ", fmt.dim("none yet")));
155
+ }
156
+ if (remote.guardrails) {
157
+ const { ceilings, state } = remote.guardrails;
158
+ if (state) {
159
+ const parts = [
160
+ gauge("turns", state.turns, ceilings.max_turns, state.fired.turns),
161
+ gauge("spend $", state.spend_usd, ceilings.budget_usd, state.fired.budget),
162
+ gauge("stall", state.stall_run, ceilings.stall_turns, state.fired.stall),
163
+ ];
164
+ L.push(fmt.label("guardrails: ", parts.join(" · ")));
165
+ }
166
+ else {
167
+ L.push(fmt.label("guardrails: ", `ceilings max_turns ${numOrDash(ceilings.max_turns)} · budget_usd ${numOrDash(ceilings.budget_usd)} · stall_turns ${numOrDash(ceilings.stall_turns)} ${fmt.dim("(no turns counted yet)")}`));
168
+ }
169
+ }
170
+ else {
171
+ L.push(fmt.label("guardrails: ", fmt.dim("none")));
172
+ }
173
+ if (remote.turn !== null) {
174
+ L.push(fmt.label("turn: ", String(remote.turn)));
175
+ }
176
+ return L;
177
+ }
178
+ export function buildShowJson(local, remote, reason) {
179
+ const goalOn = remote ? remote.sealed !== null : local.contract !== null;
180
+ return {
181
+ session: { ...local, source: "local" },
182
+ goal_alignment: goalOn ? "on" : "off",
183
+ live: remote
184
+ ? {
185
+ source: "gateway",
186
+ available: true,
187
+ sealed: remote.sealed,
188
+ verdict: remote.verdict,
189
+ turn: remote.turn,
190
+ guardrails: remote.guardrails,
191
+ }
192
+ : { source: "gateway", available: false, reason: reason ?? "unknown" },
193
+ };
194
+ }
195
+ /** `mnemom agent sessions [--all] [--json]` */
196
+ export async function agentSessionsCommand(opts, deps) {
197
+ const records = deps.readRecords();
198
+ const rows = opts.all ? records : records.slice(0, DEFAULT_SESSIONS_LIMIT);
199
+ if (opts.json) {
200
+ deps.out(JSON.stringify(rows, null, 2));
201
+ return;
202
+ }
203
+ if (records.length === 0) {
204
+ deps.out(NO_SESSIONS_MESSAGE);
205
+ return;
206
+ }
207
+ deps.out(fmt.header("Mnemom Agent sessions"));
208
+ deps.out("");
209
+ for (const line of formatSessionsTable(records, { all: opts.all }))
210
+ deps.out(line);
211
+ deps.out("");
212
+ }
213
+ /** Why the live read could not happen, from the thrown error. */
214
+ export function unavailableReason(err) {
215
+ if (err instanceof MnemomApiError && err.code)
216
+ return `${err.code}: ${err.message}`;
217
+ return err instanceof Error ? err.message : String(err);
218
+ }
219
+ /** `mnemom agent show [ref] [--json]` */
220
+ export async function agentShowCommand(ref, opts, deps) {
221
+ const records = deps.readRecords();
222
+ if (records.length === 0) {
223
+ if (opts.json)
224
+ deps.out(JSON.stringify(null));
225
+ else
226
+ deps.out(NO_SESSIONS_MESSAGE);
227
+ return;
228
+ }
229
+ const resolved = resolveSessionRef(records, ref);
230
+ if (resolved === null) {
231
+ deps.err(fmt.error(`No session matches '${ref ?? ""}' — try \`mnemom agent sessions\`.`));
232
+ process.exitCode = 1;
233
+ return;
234
+ }
235
+ if (resolved.record === null) {
236
+ deps.err(fmt.error(`'${ref ?? ""}' matches ${resolved.candidates.length} sessions — give more of the conversation id:`));
237
+ for (const c of resolved.candidates) {
238
+ deps.err(` ${c.conversation_id} ${c.session} ${formatRelativeAge(c.started_at)}`);
239
+ }
240
+ process.exitCode = 1;
241
+ return;
242
+ }
243
+ const { record, others } = resolved;
244
+ let remote = null;
245
+ let reason;
246
+ if (record.agent_id === null) {
247
+ reason = "agent id was not resolved at launch";
248
+ }
249
+ else {
250
+ try {
251
+ remote = await deps.fetchSession(record.conversation_id, record.agent_id, record.org_id);
252
+ }
253
+ catch (e) {
254
+ reason = unavailableReason(e);
255
+ }
256
+ }
257
+ if (opts.json) {
258
+ deps.out(JSON.stringify(buildShowJson(record, remote, reason), null, 2));
259
+ return;
260
+ }
261
+ for (const line of buildShowView(record, remote, reason))
262
+ deps.out(line);
263
+ if (others.length > 0) {
264
+ deps.out("");
265
+ deps.out(fmt.dim(` ${others.length} older session(s) share this name — \`mnemom agent show <conv-id prefix>\` picks one:`));
266
+ for (const o of others) {
267
+ deps.out(fmt.dim(` ${o.conversation_id.slice(0, 8)} ${formatRelativeAge(o.started_at)}`));
268
+ }
269
+ }
270
+ deps.out("");
271
+ }
@@ -25,6 +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
29
  export interface AgentOptions {
29
30
  goal?: string;
30
31
  requirement?: string[];
@@ -34,6 +35,12 @@ export interface AgentOptions {
34
35
  budget?: string;
35
36
  maxTurns?: string;
36
37
  stall?: string;
38
+ /**
39
+ * `--implicit`: goal alignment with no sealed contract — the gateway infers the
40
+ * goal from the user's messages and revises it as they steer; the ceilings above
41
+ * ride on the `x-mnemom-goal-mode` header. Exclusive with the contract flags.
42
+ */
43
+ implicit?: boolean;
37
44
  /** x-mnemom-agent value (defaults to "code-agent"). */
38
45
  agent?: string;
39
46
  conversationId?: string;
@@ -66,6 +73,25 @@ export interface AgentOptions {
66
73
  }
67
74
  /** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
68
75
  export declare function agentCommand(scenarioArg: string | undefined, options?: AgentOptions): Promise<void>;
76
+ /**
77
+ * Record this launch in ~/.mnemom/sessions.jsonl. The agent record id is
78
+ * resolved best-effort (a lookup failure or an unknown slug records null); a
79
+ * ledger write failure warns to stderr and the launch proceeds regardless.
80
+ */
81
+ export declare function recordSessionLaunch(ctx: {
82
+ session: string;
83
+ conversationId: string;
84
+ agent: string;
85
+ account: {
86
+ orgId: string;
87
+ orgLabel: string;
88
+ };
89
+ gatewayHost: string;
90
+ shape: LaunchShape;
91
+ options: AgentOptions;
92
+ cli: ResolvedCli;
93
+ envelope: SealedContractEnvelope | null;
94
+ }): Promise<void>;
69
95
  /**
70
96
  * Resolve the Anthropic key with NO 1Password dependency (built for external
71
97
  * customer adoption), in order:
@@ -131,3 +157,32 @@ export declare function buildCliArgs(options: AgentOptions, ctx: {
131
157
  yolo: boolean;
132
158
  allowCliFlags: boolean;
133
159
  }): string[];
160
+ /**
161
+ * Which goal-alignment mode the launch status line names: a sealed contract →
162
+ * "on"; `--implicit` (mode header, no contract) → "implicit"; neither → "off".
163
+ * A sealed contract and a mode header never coexist (`assembleContract` refuses
164
+ * the combination), so the first arm winning is not a precedence choice.
165
+ */
166
+ export declare function goalAlignmentMode(envelope: SealedContractEnvelope | null, goalMode: string | null): "on" | "implicit" | "off";
167
+ export declare function logLaunch(ctx: {
168
+ shape: LaunchShape;
169
+ door: string;
170
+ agent: string;
171
+ conversationId: string;
172
+ cellNote: string;
173
+ envelope: SealedContractEnvelope | null;
174
+ goalMode: string | null;
175
+ cli: ResolvedCli;
176
+ }): void;
177
+ export declare function printPlan(ctx: {
178
+ scenario: string;
179
+ shape: LaunchShape;
180
+ agent: string;
181
+ conversationId: string;
182
+ door: string;
183
+ cellNote: string;
184
+ envelope: SealedContractEnvelope | null;
185
+ goalMode: string | null;
186
+ yolo: boolean;
187
+ cli: ResolvedCli;
188
+ }): void;
@@ -31,20 +31,32 @@ import { tmpdir, homedir } from "node:os";
31
31
  import { basename, join, dirname } from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
33
  import { MNEMOM_DIR } from "../lib/config.js";
34
+ import { getAgentByName } from "../lib/api.js";
35
+ import { LEDGER_FILE, appendSessionRecord, toLedgerContract } from "../lib/agent-ledger.js";
34
36
  import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
35
37
  import { fmt } from "../lib/format.js";
36
- import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
38
+ import { askInput, askSelect, askYesNo, isInteractive } from "../lib/prompt.js";
37
39
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
38
40
  import { defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, } from "./agent-setup.js";
39
- import { assembleContract, buildCustomHeaders, isProdGateway, launchPreferenceExplicit, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, sanitizeAgentName, } from "../lib/agent.js";
41
+ import { assembleContract, buildGoalModeHeader, buildCustomHeaders, isProdGateway, launchPreferenceExplicit, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, sanitizeAgentName, } from "../lib/agent.js";
40
42
  /** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
41
43
  const CODE_STORE = join(MNEMOM_DIR, "launcher.json");
42
44
  const say = (line = "") => console.error(line);
43
45
  /** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
44
46
  export async function agentCommand(scenarioArg, options = {}) {
45
47
  // ── session name + goal ── (a bare `mnemom agent` in a terminal asks for both,
46
- // explaining goal alignment; no goal ⇒ no contract header ⇒ goal alignment off)
47
- const session = await resolveSessionSetup({ scenario: scenarioArg, goal: options.goal, interactive: isInteractive() }, { askInput: (q) => askInput(q), askYesNo: (q, d) => askYesNo(q, d) });
48
+ // explaining goal alignment: infer it from your messages (implicit), type a
49
+ // one-line goal (sealed), or off — no contract header and no mode header)
50
+ const session = await resolveSessionSetup({
51
+ scenario: scenarioArg,
52
+ goal: options.goal,
53
+ implicit: options.implicit,
54
+ interactive: isInteractive(),
55
+ }, {
56
+ askInput: (q) => askInput(q),
57
+ askYesNo: (q, d) => askYesNo(q, d),
58
+ askSelect: (q, o) => askSelect(q, o),
59
+ });
48
60
  const scenario = session.scenario;
49
61
  const env = process.env;
50
62
  let shape = resolveLaunchShape({ remoteControl: options.remoteControl, server: options.server }, env);
@@ -68,6 +80,15 @@ export async function agentCommand(scenarioArg, options = {}) {
68
80
  budget: options.budget,
69
81
  maxTurns: options.maxTurns,
70
82
  stall: options.stall,
83
+ implicit: options.implicit,
84
+ });
85
+ // ── implicit goal mode (no contract; the gateway infers the goal from the user's
86
+ // messages) — the guardrail ceilings ride on the mode header instead ──
87
+ const goalMode = buildGoalModeHeader({
88
+ implicit: options.implicit,
89
+ budget: options.budget,
90
+ maxTurns: options.maxTurns,
91
+ stall: options.stall,
71
92
  });
72
93
  // ── door ──
73
94
  const gatewayHost = resolveGatewayHost(env);
@@ -117,6 +138,7 @@ export async function agentCommand(scenarioArg, options = {}) {
117
138
  door,
118
139
  cellNote,
119
140
  envelope,
141
+ goalMode,
120
142
  yolo,
121
143
  cli: planCli,
122
144
  });
@@ -140,8 +162,9 @@ export async function agentCommand(scenarioArg, options = {}) {
140
162
  // there isn't one — and (2) enforce a positive µ balance on the org the agent
141
163
  // lives in (default: the user's personal org). No µ ⇒ blocked with a
142
164
  // portal-style top-up message. Skipped only on --dry-run (handled above).
165
+ let account;
143
166
  try {
144
- await ensureSessionAndBalance({ interactive: isInteractive() });
167
+ account = await ensureSessionAndBalance({ interactive: isInteractive() });
145
168
  }
146
169
  catch (err) {
147
170
  if (isQuietPreambleExit(err))
@@ -175,8 +198,23 @@ export async function agentCommand(scenarioArg, options = {}) {
175
198
  say(fmt.warn(`mnemom agent: agent setup did not complete (${msg}) — launching with the unclaimed identity.`));
176
199
  }
177
200
  }
178
- logLaunch({ shape, door, agent, conversationId, cellNote, envelope, cli });
179
- say(goalAlignmentLine(envelope !== null));
201
+ // ── local session ledger (~/.mnemom/sessions.jsonl) ── one line per real
202
+ // launch so `mnemom agent sessions` / `show` can find this conversation later.
203
+ // Written after provisioning so a freshly claimed agent's id resolves. Holds
204
+ // NO secret (never the key, the headers, or the child env); best-effort.
205
+ await recordSessionLaunch({
206
+ session: scenario,
207
+ conversationId,
208
+ agent,
209
+ account,
210
+ gatewayHost,
211
+ shape,
212
+ options,
213
+ cli,
214
+ envelope,
215
+ });
216
+ logLaunch({ shape, door, agent, conversationId, cellNote, envelope, goalMode, cli });
217
+ say(goalAlignmentLine(goalAlignmentMode(envelope, goalMode)));
180
218
  if (shape === "terminal") {
181
219
  launchTerminal({
182
220
  cli,
@@ -185,6 +223,7 @@ export async function agentCommand(scenarioArg, options = {}) {
185
223
  agent,
186
224
  conversationId,
187
225
  envelope,
226
+ goalMode,
188
227
  options,
189
228
  yolo,
190
229
  contextHint,
@@ -200,11 +239,46 @@ export async function agentCommand(scenarioArg, options = {}) {
200
239
  conversationId,
201
240
  scenario,
202
241
  envelope,
242
+ goalMode,
203
243
  options,
204
244
  yolo,
205
245
  });
206
246
  }
207
247
  // ───────────────────────────────────────────────────────────────────────────
248
+ // Session ledger (thin driver over lib/agent-ledger.ts)
249
+ // ───────────────────────────────────────────────────────────────────────────
250
+ /**
251
+ * Record this launch in ~/.mnemom/sessions.jsonl. The agent record id is
252
+ * resolved best-effort (a lookup failure or an unknown slug records null); a
253
+ * ledger write failure warns to stderr and the launch proceeds regardless.
254
+ */
255
+ export async function recordSessionLaunch(ctx) {
256
+ let agentId;
257
+ try {
258
+ agentId = (await getAgentByName(ctx.agent))?.id ?? null;
259
+ }
260
+ catch {
261
+ agentId = null; // unresolvable right now — the ledger still records the launch
262
+ }
263
+ const result = appendSessionRecord({
264
+ session: ctx.session,
265
+ conversation_id: ctx.conversationId,
266
+ agent_slug: ctx.agent,
267
+ agent_id: agentId,
268
+ org_id: ctx.account.orgId,
269
+ org_label: ctx.account.orgLabel,
270
+ gateway_host: ctx.gatewayHost,
271
+ launch_shape: ctx.shape,
272
+ model: ctx.options.model?.trim() || null,
273
+ effort: ctx.options.effort?.trim() || null,
274
+ cli: ctx.cli.name,
275
+ contract: toLedgerContract(ctx.envelope?.contract ?? null),
276
+ }, { dir: MNEMOM_DIR, now: () => new Date(), pid: process.pid });
277
+ if (!result.ok) {
278
+ say(fmt.warn(`mnemom agent: could not record this session in ~/.mnemom/${LEDGER_FILE} (${result.error}) — launching anyway.`));
279
+ }
280
+ }
281
+ // ───────────────────────────────────────────────────────────────────────────
208
282
  // Credential resolution (zero 1Password)
209
283
  // ───────────────────────────────────────────────────────────────────────────
210
284
  /**
@@ -220,7 +294,7 @@ export async function resolveAnthropicKey(env, opts) {
220
294
  const source = opts.keySource;
221
295
  // "prompt" forces the interactive prompt (skips env + store).
222
296
  if (source !== "prompt") {
223
- const fromEnv = (env.MNEMOM_AGENT_ANTHROPIC_KEY || env.ANTHROPIC_API_KEY || "").trim();
297
+ const fromEnv = stripCrlf(env.MNEMOM_AGENT_ANTHROPIC_KEY || env.ANTHROPIC_API_KEY || "");
224
298
  if (fromEnv)
225
299
  return fromEnv;
226
300
  // "env" restricts to env only — no store, no prompt.
@@ -231,11 +305,17 @@ export async function resolveAnthropicKey(env, opts) {
231
305
  const stored = readStoredKey();
232
306
  if (stored)
233
307
  return stored;
308
+ // "store" restricts to env → store — never the interactive prompt.
309
+ if (source === "store") {
310
+ throw new Error("mnemom agent: key_source is 'store' but no Anthropic key is set in env or ~/.mnemom/launcher.json.\n" +
311
+ " Set one: export MNEMOM_AGENT_ANTHROPIC_KEY=sk-ant-... (or ANTHROPIC_API_KEY)\n" +
312
+ " Or store it: `mnemom agent config set key_source prompt`, run `mnemom agent` once and accept the save.");
313
+ }
234
314
  }
235
- // (source can only be "store", "prompt", or undefined here — "env" threw above.)
315
+ // (source can only be "prompt" or undefined here — "env" and "store" threw above.)
236
316
  if (opts.allowPrompt && isInteractive()) {
237
317
  say("mnemom agent: no Anthropic key in env (MNEMOM_AGENT_ANTHROPIC_KEY / ANTHROPIC_API_KEY) or ~/.mnemom/launcher.json.");
238
- const entered = (await askInput("Enter your Anthropic API key (sk-ant-…):", true)).trim();
318
+ const entered = stripCrlf(await askInput("Enter your Anthropic API key (sk-ant-…):", true));
239
319
  if (entered) {
240
320
  if (await askYesNo("Save it to ~/.mnemom/launcher.json (0600) for next time?", true)) {
241
321
  storeKey(entered);
@@ -265,7 +345,7 @@ export function anthropicKeySource(env = process.env) {
265
345
  function readStoredKey() {
266
346
  try {
267
347
  const data = JSON.parse(readFileSync(CODE_STORE, "utf8"));
268
- const key = typeof data.anthropic_key === "string" ? data.anthropic_key.trim() : "";
348
+ const key = typeof data.anthropic_key === "string" ? stripCrlf(data.anthropic_key) : "";
269
349
  return key || undefined;
270
350
  }
271
351
  catch {
@@ -442,6 +522,7 @@ function launchTerminal(ctx) {
442
522
  agent: ctx.agent,
443
523
  conversationId: ctx.conversationId,
444
524
  contractB64: ctx.envelope?.base64,
525
+ goalMode: ctx.goalMode ?? undefined,
445
526
  });
446
527
  const args = buildCliArgs(ctx.options, { yolo: ctx.yolo, allowCliFlags: true });
447
528
  // Claude Code logged in via claude.ai (OAuth) PREEMPTS ANTHROPIC_API_KEY, so
@@ -489,6 +570,7 @@ function launchRemoteControl(ctx) {
489
570
  MNEMOM_AGENT_RC_AGENT: ctx.agent,
490
571
  MNEMOM_AGENT_RC_CONVERSATION_ID: ctx.conversationId,
491
572
  MNEMOM_AGENT_RC_CONTRACT_B64: ctx.envelope?.base64 ?? "",
573
+ MNEMOM_AGENT_RC_GOAL_MODE: ctx.goalMode ?? "",
492
574
  MNEMOM_AGENT_RC_PROXY_LOG: proxyLog,
493
575
  MNEMOM_AGENT_RC_PARENT_PID: String(process.pid),
494
576
  },
@@ -720,14 +802,30 @@ function seedContextHintFlag(childEnv) {
720
802
  // ───────────────────────────────────────────────────────────────────────────
721
803
  // Human-facing logging (never prints the key)
722
804
  // ───────────────────────────────────────────────────────────────────────────
723
- function logLaunch(ctx) {
805
+ /**
806
+ * Which goal-alignment mode the launch status line names: a sealed contract →
807
+ * "on"; `--implicit` (mode header, no contract) → "implicit"; neither → "off".
808
+ * A sealed contract and a mode header never coexist (`assembleContract` refuses
809
+ * the combination), so the first arm winning is not a precedence choice.
810
+ */
811
+ export function goalAlignmentMode(envelope, goalMode) {
812
+ if (envelope)
813
+ return "on";
814
+ if (goalMode)
815
+ return "implicit";
816
+ return "off";
817
+ }
818
+ export function logLaunch(ctx) {
724
819
  say(`mnemom agent: routing ${ctx.cli.name} through ${ctx.door} [${ctx.cellNote}] as '${ctx.agent}' (conversation ${ctx.conversationId}).`);
725
820
  if (ctx.envelope) {
726
821
  say(`mnemom agent: contract statement: ${ctx.envelope.contract.statement}`);
727
822
  say(`mnemom agent: contract sha256[0:16]: ${ctx.envelope.sha}`);
728
823
  }
824
+ else if (ctx.goalMode) {
825
+ say(`mnemom agent: goal mode: ${ctx.goalMode} (inferred from your messages; watch x-mnemom-goal-revision / x-mnemom-goal-steer on each response)`);
826
+ }
729
827
  }
730
- function printPlan(ctx) {
828
+ export function printPlan(ctx) {
731
829
  const L = [];
732
830
  L.push("mnemom agent — launch plan (dry run)");
733
831
  L.push(` scenario: ${ctx.scenario}`);
@@ -741,6 +839,10 @@ function printPlan(ctx) {
741
839
  L.push(` contract: ${ctx.envelope.canonical}`);
742
840
  L.push(` contract sha: ${ctx.envelope.sha}`);
743
841
  }
842
+ else if (ctx.goalMode) {
843
+ L.push(" contract: (none — implicit goal mode: the gateway infers the goal from your messages)");
844
+ L.push(` goal mode: ${ctx.goalMode}`);
845
+ }
744
846
  else {
745
847
  L.push(" contract: (none — no contract flag given)");
746
848
  }
@@ -748,3 +850,11 @@ function printPlan(ctx) {
748
850
  L.push(" Dry run — no key was read and nothing was launched.");
749
851
  process.stdout.write(`${L.join("\n")}\n`);
750
852
  }
853
+ /**
854
+ * Header-value hygiene for the Anthropic key: the key is spliced into the newline-joined
855
+ * ANTHROPIC_CUSTOM_HEADERS block (`x-api-key: <key>`), so an embedded CR/LF from a multi-line
856
+ * paste would inject extra header lines. Same rule resolveConversationId applies to its input.
857
+ */
858
+ function stripCrlf(raw) {
859
+ return raw.replace(/[\r\n]+/g, "").trim();
860
+ }
@@ -12,7 +12,7 @@
12
12
  * green — useful for shell scripts and the `mnemom/deploy` gate-guard
13
13
  * workflow which can call the CLI rather than parse the JSON itself.
14
14
  */
15
- import { fmt } from "../lib/format.js";
15
+ import { fmt, formatRelativeAge } from "../lib/format.js";
16
16
  import { requireAuth } from "../lib/auth.js";
17
17
  import { getSafeHouseHarnessState } from "../lib/api.js";
18
18
  /**
@@ -32,21 +32,6 @@ function parseMaxAge(input) {
32
32
  const ms = n * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit];
33
33
  return ms;
34
34
  }
35
- function formatRelativeAge(startedAt) {
36
- const ageMs = Date.now() - Date.parse(startedAt);
37
- if (ageMs < 0)
38
- return "in the future?";
39
- const m = Math.floor(ageMs / 60_000);
40
- if (m < 1)
41
- return "<1m ago";
42
- if (m < 60)
43
- return `${m}m ago`;
44
- const h = Math.floor(m / 60);
45
- if (h < 24)
46
- return `${h}h ${m % 60}m ago`;
47
- const d = Math.floor(h / 24);
48
- return `${d}d ${h % 24}h ago`;
49
- }
50
35
  function evaluateLane(lane, run, maxAgeMs) {
51
36
  if (!run) {
52
37
  return {