@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.
package/README.md CHANGED
@@ -44,6 +44,32 @@ That's it. `mnemom init` detects your configured AI provider API keys (Anthropic
44
44
  | `mnemom card publish <file>` | Publish alignment card from JSON file |
45
45
  | `mnemom card validate <file>` | Validate card JSON locally |
46
46
  | `mnemom usage --org <id>` | Show per-person token and request consumption |
47
+ | `mnemom agent [name]` | Launch a governed coding agent through the gateway |
48
+
49
+ ### `mnemom agent` — goal alignment
50
+
51
+ `mnemom agent` launches your coding-agent CLI (Claude Code today) with its model traffic
52
+ routed through the Mnemom gateway under a governed identity. Goal alignment — the judge
53
+ nudging the agent back when it drifts from your goal — comes in three modes; a bare
54
+ `mnemom agent` in a terminal asks which one you want (Enter = off, for now):
55
+
56
+ | Mode | How | What is sent |
57
+ | ------------ | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
58
+ | **Implicit** | `--implicit` — the gateway infers the goal from your own messages and revises it as you steer; nothing to type | `x-mnemom-goal-mode: implicit`, no contract |
59
+ | **Sealed** | `--goal "<statement>"` (+ `--requirement/--allow/--forbid/--goal-id`) — sealed as a contract on the first turn | `x-mnemom-contract` |
60
+ | **Off** | neither | neither header |
61
+
62
+ `--implicit` cannot be combined with the sealed-contract flags: a sealed contract always
63
+ wins on the gateway and would silently switch implicit mode off, so the launch refuses the
64
+ mix. The guardrail ceilings (`--budget`, `--max-turns`, `--stall`, or `guardrails.*` in
65
+ `~/.mnemom/launcher.toml`) apply in both modes — in implicit mode they ride on the mode
66
+ header. In implicit mode the goal lags your steering by one request: a response to a new
67
+ message carries `x-mnemom-goal-steer: queued` and the revision the agent acted under;
68
+ the next request reads the new revision (`x-mnemom-goal-revision`).
69
+
70
+ Make one mode the standing default with `mnemom agent config set goal_mode implicit`
71
+ (or `off`); `--goal` still seals for that launch and `--no-implicit` turns it off for one
72
+ launch. A sealed contract needs a per-launch statement, so it cannot be the default.
47
73
 
48
74
  ## How It Works
49
75
 
@@ -15,3 +15,15 @@
15
15
  */
16
16
  /** Entry point for `mnemom agent config <verb> [args...]`. Throws on misuse. */
17
17
  export declare function agentConfigCommand(argv: string[]): Promise<void>;
18
+ /**
19
+ * `$VISUAL` / `$EDITOR` is a program plus optional plain flags (`code --wait`, `emacsclient -t`),
20
+ * never shell syntax: the value is split on whitespace and every token must be a bare
21
+ * program name, path, or flag. Anything else (quotes, `;`, `$(`, backticks, redirections, …) is
22
+ * refused with a remediation rather than handed to spawnSync — the editor is launched WITHOUT a
23
+ * shell, so such a value could only ever fail confusingly or, via a lookalike binary on PATH, run
24
+ * something the user did not mean. Exported for the tests.
25
+ */
26
+ export declare function parseEditorSpec(spec: string): {
27
+ bin: string;
28
+ args: string[];
29
+ };
@@ -98,8 +98,8 @@ function editConfig() {
98
98
  // Seed a header-only file so $EDITOR opens something with guidance, not a blank.
99
99
  if (!existsSync(LAUNCHER_CONFIG_PATH))
100
100
  writeLauncherConfig(loadLauncherConfig());
101
- const editor = process.env.VISUAL || process.env.EDITOR || fallbackEditor();
102
- const res = spawnSync(editor, [LAUNCHER_CONFIG_PATH], { stdio: "inherit" });
101
+ const { bin: editor, args: editorArgs } = parseEditorSpec(process.env.VISUAL || process.env.EDITOR || fallbackEditor());
102
+ const res = spawnSync(editor, [...editorArgs, LAUNCHER_CONFIG_PATH], { stdio: "inherit" });
103
103
  if (res.error) {
104
104
  throw new Error(`mnemom agent config edit: could not launch editor '${editor}': ${res.error.message}\n` +
105
105
  ` Set $EDITOR, or edit ${LAUNCHER_CONFIG_PATH} directly.`);
@@ -108,6 +108,23 @@ function editConfig() {
108
108
  loadLauncherConfig();
109
109
  out(`Saved ${LAUNCHER_CONFIG_PATH}.`);
110
110
  }
111
+ /**
112
+ * `$VISUAL` / `$EDITOR` is a program plus optional plain flags (`code --wait`, `emacsclient -t`),
113
+ * never shell syntax: the value is split on whitespace and every token must be a bare
114
+ * program name, path, or flag. Anything else (quotes, `;`, `$(`, backticks, redirections, …) is
115
+ * refused with a remediation rather than handed to spawnSync — the editor is launched WITHOUT a
116
+ * shell, so such a value could only ever fail confusingly or, via a lookalike binary on PATH, run
117
+ * something the user did not mean. Exported for the tests.
118
+ */
119
+ export function parseEditorSpec(spec) {
120
+ const tokens = spec.trim().split(/\s+/).filter(Boolean);
121
+ const SAFE = /^[A-Za-z0-9_./+=:@,-]+$/;
122
+ if (tokens.length === 0 || !tokens.every((t) => SAFE.test(t))) {
123
+ throw new Error(`mnemom agent config edit: refusing to run $VISUAL/$EDITOR value '${spec}' — only a program name or path plus plain flags is accepted (no shell syntax).\n` +
124
+ ` Set $EDITOR to e.g. 'code --wait', or edit ${LAUNCHER_CONFIG_PATH} directly.`);
125
+ }
126
+ return { bin: tokens[0], args: tokens.slice(1) };
127
+ }
111
128
  function fallbackEditor() {
112
129
  for (const cand of ["nano", "vim", "vi"]) {
113
130
  const r = spawnSync("sh", ["-c", `command -v "$1" >/dev/null 2>&1`, "sh", cand], {
@@ -158,6 +175,9 @@ function deleteKey(cfg, key) {
158
175
  case "effort":
159
176
  delete cfg.effort;
160
177
  break;
178
+ case "goal_mode":
179
+ delete cfg.goalMode;
180
+ break;
161
181
  case "cli":
162
182
  delete cfg.cli;
163
183
  break;
@@ -7,25 +7,35 @@
7
7
  *
8
8
  * 1. a SESSION NAME — labels the session in traces and the claude.ai session
9
9
  * list (defaults to the current folder's name);
10
- * 2. an optional GOAL — one line. With a goal, Mnemom seals a goal contract
11
- * for the session and the judge nudges the agent back when it drifts (a
12
- * little µ per turn). Without one, the session runs governed but with goal
13
- * alignment OFF: no contract header is sent, so the gateway's goal hook is
14
- * simply not engaged for the conversation.
10
+ * 2. GOAL ALIGNMENT — one of three:
11
+ * - INFER it from your messages (implicit mode): nothing to type; the
12
+ * gateway reads your own messages, revises the goal as you steer, and the
13
+ * judge nudges the agent back when it drifts (a little µ per new message
14
+ * and per judged turn). Sends `x-mnemom-goal-mode: implicit`, no contract.
15
+ * - TYPE a one-line goal: sealed as a contract for the session; the judge
16
+ * nudges against it (a little µ per turn). Sends `x-mnemom-contract`.
17
+ * - OFF: the session runs governed with goal alignment off — neither header
18
+ * is sent, so the gateway's goal hooks are simply not engaged.
15
19
  *
16
20
  * Non-interactive runs (pipes, CI) never prompt — they get a clear error asking
17
- * for a name. `--goal` on the command line always wins over the prompt. Pure
18
- * apart from the injected `ask` functions, so every arm is unit-tested.
21
+ * for a name. `--goal` on the command line always wins over the prompt, and a
22
+ * resolved implicit launch (`--implicit`, or launcher.toml `goal_mode =
23
+ * "implicit"` with no `--no-implicit`) skips it too. Pure apart from the
24
+ * injected `ask` functions, so every arm is unit-tested.
19
25
  */
20
26
  export interface SessionAsk {
21
27
  askInput(question: string): Promise<string>;
22
28
  askYesNo(question: string, defaultYes: boolean): Promise<boolean>;
29
+ /** Numbered single-select; resolves the chosen label, or null on no/invalid choice. */
30
+ askSelect(question: string, options: string[]): Promise<string | null>;
23
31
  }
24
32
  export interface SessionSetupInput {
25
33
  /** The positional `[scenario]` (session name), if given. */
26
34
  scenario?: string;
27
35
  /** `--goal <statement>`, if given — skips the goal prompt. */
28
36
  goal?: string;
37
+ /** Resolved implicit mode (flag or config default) — skips the goal prompt; alignment on without a contract. */
38
+ implicit?: boolean;
29
39
  interactive: boolean;
30
40
  /** For the default session name (tests inject one). */
31
41
  cwd?: string;
@@ -34,17 +44,23 @@ export interface SessionSetupInput {
34
44
  }
35
45
  export interface SessionSetup {
36
46
  scenario: string;
37
- /** The goal statement to seal, or undefined = goal alignment off. */
47
+ /** The goal statement to seal, or undefined = no sealed contract (implicit or off). */
38
48
  goal?: string;
39
- goalAlignment: "on" | "off";
49
+ goalAlignment: "on" | "implicit" | "off";
40
50
  /** True when the terminal prompts ran. */
41
51
  prompted: boolean;
42
52
  }
43
53
  export declare const NO_SESSION_NAME_MESSAGE: string;
54
+ /** The three goal-alignment choices offered by the interactive prompt (labels are the select options). */
55
+ export declare const GOAL_CHOICE_IMPLICIT = "Infer my goal from my messages \u2014 nothing to type; revised as I steer (implicit mode)";
56
+ export declare const GOAL_CHOICE_TYPED = "Type a one-line goal \u2014 sealed as a contract for this session";
57
+ export declare const GOAL_CHOICE_OFF = "Off \u2014 run governed with goal alignment off (Enter)";
58
+ export declare const GOAL_CHOICES: readonly string[];
59
+ export declare const GOAL_CHOICE_QUESTION = "Goal alignment for this session?";
44
60
  /** Current folder name, sanitised like an agent slug; "session" when that is empty. */
45
61
  export declare function defaultSessionName(cwd?: string): string;
46
62
  /** The one-line status printed at launch so the customer always knows the mode. */
47
- export declare function goalAlignmentLine(on: boolean): string;
63
+ export declare function goalAlignmentLine(mode: boolean | "on" | "implicit" | "off"): string;
48
64
  /**
49
65
  * Resolve the session name + goal for this launch. See the module doc for the
50
66
  * rules. Throws NO_SESSION_NAME_MESSAGE when no name was given and we cannot ask.
@@ -7,29 +7,50 @@
7
7
  *
8
8
  * 1. a SESSION NAME — labels the session in traces and the claude.ai session
9
9
  * list (defaults to the current folder's name);
10
- * 2. an optional GOAL — one line. With a goal, Mnemom seals a goal contract
11
- * for the session and the judge nudges the agent back when it drifts (a
12
- * little µ per turn). Without one, the session runs governed but with goal
13
- * alignment OFF: no contract header is sent, so the gateway's goal hook is
14
- * simply not engaged for the conversation.
10
+ * 2. GOAL ALIGNMENT — one of three:
11
+ * - INFER it from your messages (implicit mode): nothing to type; the
12
+ * gateway reads your own messages, revises the goal as you steer, and the
13
+ * judge nudges the agent back when it drifts (a little µ per new message
14
+ * and per judged turn). Sends `x-mnemom-goal-mode: implicit`, no contract.
15
+ * - TYPE a one-line goal: sealed as a contract for the session; the judge
16
+ * nudges against it (a little µ per turn). Sends `x-mnemom-contract`.
17
+ * - OFF: the session runs governed with goal alignment off — neither header
18
+ * is sent, so the gateway's goal hooks are simply not engaged.
15
19
  *
16
20
  * Non-interactive runs (pipes, CI) never prompt — they get a clear error asking
17
- * for a name. `--goal` on the command line always wins over the prompt. Pure
18
- * apart from the injected `ask` functions, so every arm is unit-tested.
21
+ * for a name. `--goal` on the command line always wins over the prompt, and a
22
+ * resolved implicit launch (`--implicit`, or launcher.toml `goal_mode =
23
+ * "implicit"` with no `--no-implicit`) skips it too. Pure apart from the
24
+ * injected `ask` functions, so every arm is unit-tested.
19
25
  */
20
26
  import { basename } from "node:path";
21
27
  import { sanitizeAgentName } from "../lib/agent.js";
22
28
  export const NO_SESSION_NAME_MESSAGE = "mnemom agent: a session name is required here (e.g. `mnemom agent onboarding`). " +
23
29
  "In a terminal, plain `mnemom agent` asks for one.";
30
+ /** The three goal-alignment choices offered by the interactive prompt (labels are the select options). */
31
+ export const GOAL_CHOICE_IMPLICIT = "Infer my goal from my messages — nothing to type; revised as I steer (implicit mode)";
32
+ export const GOAL_CHOICE_TYPED = "Type a one-line goal — sealed as a contract for this session";
33
+ export const GOAL_CHOICE_OFF = "Off — run governed with goal alignment off (Enter)";
34
+ export const GOAL_CHOICES = [
35
+ GOAL_CHOICE_IMPLICIT,
36
+ GOAL_CHOICE_TYPED,
37
+ GOAL_CHOICE_OFF,
38
+ ];
39
+ export const GOAL_CHOICE_QUESTION = "Goal alignment for this session?";
24
40
  /** Current folder name, sanitised like an agent slug; "session" when that is empty. */
25
41
  export function defaultSessionName(cwd = process.cwd()) {
26
42
  return sanitizeAgentName(basename(cwd)) || "session";
27
43
  }
28
44
  /** The one-line status printed at launch so the customer always knows the mode. */
29
- export function goalAlignmentLine(on) {
30
- return on
45
+ export function goalAlignmentLine(mode) {
46
+ const m = mode === true ? "on" : mode === false ? "off" : mode;
47
+ if (m === "implicit") {
48
+ return ("mnemom agent: goal alignment: on, implicit (no sealed contract — the gateway infers your goal " +
49
+ "from your own messages, revises it as you steer, and the judge nudges the agent back when it drifts)");
50
+ }
51
+ return m === "on"
31
52
  ? "mnemom agent: goal alignment: on (the judge nudges the agent back when it drifts from your goal)"
32
- : "mnemom agent: goal alignment: off (no goal set — pass --goal or set one when prompted)";
53
+ : "mnemom agent: goal alignment: off (no goal set — pass --goal or --implicit, or set one when prompted)";
33
54
  }
34
55
  const defaultSay = (line = "") => console.error(line);
35
56
  /**
@@ -39,12 +60,13 @@ const defaultSay = (line = "") => console.error(line);
39
60
  export async function resolveSessionSetup(input, ask) {
40
61
  const say = input.say ?? defaultSay;
41
62
  const flagGoal = (input.goal ?? "").trim();
63
+ const implicit = input.implicit === true;
42
64
  const given = (input.scenario ?? "").trim();
43
65
  if (given) {
44
66
  return {
45
67
  scenario: given,
46
68
  goal: flagGoal || undefined,
47
- goalAlignment: flagGoal ? "on" : "off",
69
+ goalAlignment: flagGoal ? "on" : implicit ? "implicit" : "off",
48
70
  prompted: false,
49
71
  };
50
72
  }
@@ -57,22 +79,36 @@ export async function resolveSessionSetup(input, ask) {
57
79
  say(` (Next time you can pass it directly: mnemom agent <name>.)`);
58
80
  const typed = await ask.askInput(`Session name [${fallback}]:`);
59
81
  const scenario = sanitizeAgentName(typed.trim()) || fallback;
60
- // ── 2. goal (skipped when --goal was given) ──
82
+ // ── 2. goal alignment (skipped when --goal or implicit mode was resolved) ──
61
83
  if (flagGoal) {
62
84
  return { scenario, goal: flagGoal, goalAlignment: "on", prompted: true };
63
85
  }
86
+ if (implicit) {
87
+ return { scenario, goalAlignment: "implicit", prompted: true };
88
+ }
64
89
  say();
65
- say("Goal alignment — an optional one-line goal for this session.");
66
- say(" With a goal, Mnemom's judge watches each turn and nudges the agent back when it drifts");
67
- say(" (a little µ per turn). Without one, the session runs governed with goal alignment off.");
68
- const wantsGoal = await ask.askYesNo("Set a goal for this session?", true);
69
- if (!wantsGoal) {
70
- return { scenario, goalAlignment: "off", prompted: true };
90
+ say("Goal alignment — how Mnemom keeps this session on track. Three choices:");
91
+ say(" Infer: nothing to type — the gateway reads your own messages, revises the goal as you");
92
+ say(" steer, and the judge nudges the agent back when it drifts (a little µ per message + turn).");
93
+ say(" Type a goal: one line, sealed as a contract; the judge nudges against it (a little µ per turn).");
94
+ say(" Off: the session runs governed with goal alignment off (the default — just press Enter).");
95
+ say(" (Make one the default: mnemom agent config set goal_mode implicit|off.)");
96
+ const choice = await ask.askSelect(GOAL_CHOICE_QUESTION, [...GOAL_CHOICES]);
97
+ if (choice === GOAL_CHOICE_IMPLICIT) {
98
+ return { scenario, goalAlignment: "implicit", prompted: true };
99
+ }
100
+ if (choice === GOAL_CHOICE_TYPED) {
101
+ const goal = (await ask.askInput("Goal (one line):")).trim();
102
+ if (!goal) {
103
+ say(" No goal entered — continuing with goal alignment off.");
104
+ return { scenario, goalAlignment: "off", prompted: true };
105
+ }
106
+ return { scenario, goal, goalAlignment: "on", prompted: true };
71
107
  }
72
- const goal = (await ask.askInput("Goal (one line):")).trim();
73
- if (!goal) {
74
- say(" No goal entered — continuing with goal alignment off.");
75
- return { scenario, goalAlignment: "off", prompted: true };
108
+ // Enter (no pick) and any invalid pick both resolve to null → off. Off is the
109
+ // deliberate default for now: a µ-costing mode is never picked by a bare Enter.
110
+ if (choice !== GOAL_CHOICE_OFF) {
111
+ say(" Goal alignment off (press 1 or 2 next time to turn it on).");
76
112
  }
77
- return { scenario, goal, goalAlignment: "on", prompted: true };
113
+ return { scenario, goalAlignment: "off", prompted: true };
78
114
  }
@@ -0,0 +1,99 @@
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 { type SessionRecord } from "../lib/agent-ledger.js";
17
+ import { type AgentSessionResponse } from "../lib/api.js";
18
+ export interface SessionCommandDeps {
19
+ /** The ledger, newest first. */
20
+ readRecords: () => SessionRecord[];
21
+ /** The live state from mnemom-api; rejects when unreachable / not found. */
22
+ fetchSession: (conversationId: string, agentId: string, orgId: string) => Promise<AgentSessionResponse>;
23
+ out: (line: string) => void;
24
+ err: (line: string) => void;
25
+ }
26
+ /** The production wiring (index.ts). Lazy so a test that mocks lib/api without
27
+ * this export can still import index.ts. */
28
+ export declare const liveSessionDeps: SessionCommandDeps;
29
+ export declare const NO_SESSIONS_MESSAGE = "No sessions yet \u2014 run `mnemom agent` to start one.";
30
+ export declare const DEFAULT_SESSIONS_LIMIT = 20;
31
+ export interface SessionRefMatch {
32
+ record: SessionRecord;
33
+ /** Older records sharing the same session name (only for name matches). */
34
+ others: SessionRecord[];
35
+ }
36
+ export interface SessionRefAmbiguous {
37
+ record: null;
38
+ /** Every record whose conversation id starts with the ref. */
39
+ candidates: SessionRecord[];
40
+ }
41
+ /**
42
+ * Resolve `[ref]` against the ledger (newest first):
43
+ * - no ref → the newest record;
44
+ * - an exact session name → the newest record with that name, older ones in `others`;
45
+ * - else a conversation-id prefix → the unique match, or `{record: null, candidates}`
46
+ * when several share the prefix;
47
+ * - nothing matches (or the ledger is empty) → null.
48
+ */
49
+ export declare function resolveSessionRef(records: SessionRecord[], ref?: string): SessionRefMatch | SessionRefAmbiguous | null;
50
+ /** Goal alignment as the ledger recorded it: a contract at launch ⇒ on. */
51
+ export declare function localGoalFlag(record: SessionRecord): "on" | "off";
52
+ /**
53
+ * Render the ledger as a table, newest first — the newest
54
+ * `DEFAULT_SESSIONS_LIMIT` rows unless `all`, with a footer naming the rest.
55
+ */
56
+ export declare function formatSessionsTable(rows: SessionRecord[], opts: {
57
+ all?: boolean;
58
+ }): string[];
59
+ /** `turns 3/10 (crossed)` — value against its ceiling (none ⇒ just the value). */
60
+ export declare function gauge(label: string, value: number, ceiling: number | null, fired: boolean | undefined): string;
61
+ /**
62
+ * The human view for `mnemom agent show`: the local record, then the live
63
+ * gateway state (or a dim "live state unavailable (<reason>)" line when
64
+ * `remote` is null).
65
+ */
66
+ export declare function buildShowView(local: SessionRecord, remote: AgentSessionResponse | null, reason?: string): string[];
67
+ /** The `--json` shape: the local record and the live section each tagged with its source. */
68
+ export interface ShowJson {
69
+ session: SessionRecord & {
70
+ source: "local";
71
+ };
72
+ goal_alignment: "on" | "off";
73
+ live: {
74
+ source: "gateway";
75
+ available: true;
76
+ sealed: AgentSessionResponse["sealed"];
77
+ verdict: AgentSessionResponse["verdict"];
78
+ turn: AgentSessionResponse["turn"];
79
+ guardrails: AgentSessionResponse["guardrails"];
80
+ } | {
81
+ source: "gateway";
82
+ available: false;
83
+ reason: string;
84
+ };
85
+ }
86
+ export declare function buildShowJson(local: SessionRecord, remote: AgentSessionResponse | null, reason?: string): ShowJson;
87
+ export interface AgentSessionsOptions {
88
+ all?: boolean;
89
+ json?: boolean;
90
+ }
91
+ /** `mnemom agent sessions [--all] [--json]` */
92
+ export declare function agentSessionsCommand(opts: AgentSessionsOptions, deps: SessionCommandDeps): Promise<void>;
93
+ export interface AgentShowOptions {
94
+ json?: boolean;
95
+ }
96
+ /** Why the live read could not happen, from the thrown error. */
97
+ export declare function unavailableReason(err: unknown): string;
98
+ /** `mnemom agent show [ref] [--json]` */
99
+ export declare function agentShowCommand(ref: string | undefined, opts: AgentShowOptions, deps: SessionCommandDeps): Promise<void>;