@mnemom/mnemom 0.17.0-next.8 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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>;
@@ -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
+ }
@@ -73,6 +73,25 @@ export interface AgentOptions {
73
73
  }
74
74
  /** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
75
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>;
76
95
  /**
77
96
  * Resolve the Anthropic key with NO 1Password dependency (built for external
78
97
  * customer adoption), in order:
@@ -31,6 +31,8 @@ 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
38
  import { askInput, askSelect, askYesNo, isInteractive } from "../lib/prompt.js";
@@ -160,8 +162,9 @@ export async function agentCommand(scenarioArg, options = {}) {
160
162
  // there isn't one — and (2) enforce a positive µ balance on the org the agent
161
163
  // lives in (default: the user's personal org). No µ ⇒ blocked with a
162
164
  // portal-style top-up message. Skipped only on --dry-run (handled above).
165
+ let account;
163
166
  try {
164
- await ensureSessionAndBalance({ interactive: isInteractive() });
167
+ account = await ensureSessionAndBalance({ interactive: isInteractive() });
165
168
  }
166
169
  catch (err) {
167
170
  if (isQuietPreambleExit(err))
@@ -195,6 +198,21 @@ export async function agentCommand(scenarioArg, options = {}) {
195
198
  say(fmt.warn(`mnemom agent: agent setup did not complete (${msg}) — launching with the unclaimed identity.`));
196
199
  }
197
200
  }
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
+ });
198
216
  logLaunch({ shape, door, agent, conversationId, cellNote, envelope, goalMode, cli });
199
217
  say(goalAlignmentLine(goalAlignmentMode(envelope, goalMode)));
200
218
  if (shape === "terminal") {
@@ -227,6 +245,40 @@ export async function agentCommand(scenarioArg, options = {}) {
227
245
  });
228
246
  }
229
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
+ // ───────────────────────────────────────────────────────────────────────────
230
282
  // Credential resolution (zero 1Password)
231
283
  // ───────────────────────────────────────────────────────────────────────────
232
284
  /**
@@ -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 {
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { cardShowCommand, cardPublishCommand, cardValidateCommand, cardEditComma
10
10
  import { policyInitCommand, policyValidateCommand, policyPublishCommand, policyListCommand, policyTestCommand, policyEvaluateCommand, } from "./commands/policy.js";
11
11
  import { protectionShowCommand, protectionPublishCommand, protectionValidateCommand, protectionEditCommand, protectionDriftCommand, } from "./commands/protection.js";
12
12
  import { agentsListCommand, agentsClaimCommand, agentsMoveCommand } from "./commands/agents.js";
13
+ import { agentSessionsCommand, agentShowCommand, liveSessionDeps, } from "./commands/agent-sessions.js";
13
14
  import { orgListCommand, orgShowCommand, orgUseCommand } from "./commands/org.js";
14
15
  import { teamListCommand, teamShowCommand, teamTemplateCommand, teamPreviewComposeCommand, teamAdminGrantCommand, teamAdminRevokeCommand, teamAdminListCommand, teamCoverageCommand, } from "./commands/team.js";
15
16
  import { advisoriesListCommand, advisoriesShowCommand } from "./commands/advisories.js";
@@ -103,7 +104,7 @@ const collectRepeatable = (value, previous) => {
103
104
  const agentCmd = program
104
105
  .command("agent [session] [passthrough...]")
105
106
  .description("Launch your governed agent (Claude Code in v1) through the Mnemom gateway — governed identity + optional sealed goal contract. Plain `mnemom agent` asks for a session name and an optional goal. " +
106
- "Sub-verbs: `setup` provisions your governed agent; `doctor` preflights prerequisites; `config …` manages persistent settings (~/.mnemom/launcher.toml); `invite <token>` redeems a Mnemom Agent invitation; `list` / `claim` / `move` manage the agents in your orgs.")
107
+ "Sub-verbs: `setup` provisions your governed agent; `doctor` preflights prerequisites; `config …` manages persistent settings (~/.mnemom/launcher.toml); `invite <token>` redeems a Mnemom Agent invitation; `list` / `claim` / `move` manage the agents in your orgs; `sessions` lists your past launches and `show [ref]` merges one with its live goal state.")
107
108
  .option("--goal <statement>", "Contract goal statement (required if any contract flag is given)")
108
109
  .option("--requirement <text>", "Contract requirement (repeatable)", collectRepeatable, [])
109
110
  .option("--allow <glob>", "Allowed path/glob (repeatable)", collectRepeatable, [])
@@ -698,6 +699,34 @@ agentCmd
698
699
  process.exit(1);
699
700
  }
700
701
  });
702
+ agentCmd
703
+ .command("sessions")
704
+ .description("List your Mnemom Agent sessions from the local ledger (~/.mnemom/sessions.jsonl), newest first — the last 20 unless --all")
705
+ .option("--all", "Every session, not just the newest 20")
706
+ .option("--json", "Output the rows as JSON instead of a table")
707
+ .action(async (opts) => {
708
+ try {
709
+ await agentSessionsCommand({ all: opts.all, json: opts.json }, liveSessionDeps);
710
+ }
711
+ catch (error) {
712
+ console.error("Error:", error instanceof Error ? error.message : error);
713
+ process.exit(1);
714
+ }
715
+ });
716
+ agentCmd
717
+ .command("show [ref]")
718
+ .description("Show one session — the local launch record merged with the gateway's live goal state (sealed contract, latest verdict, guardrails). " +
719
+ "`ref` is a session name (newest match wins) or a unique conversation-id prefix; omitted = the newest session")
720
+ .option("--json", "Output the merged view as JSON (each section tagged with its source)")
721
+ .action(async (ref, opts) => {
722
+ try {
723
+ await agentShowCommand(ref, { json: opts.json }, liveSessionDeps);
724
+ }
725
+ catch (error) {
726
+ console.error("Error:", error instanceof Error ? error.message : error);
727
+ process.exit(1);
728
+ }
729
+ });
701
730
  // ============================================================================
702
731
  // Organization management (ADR-044, Piece 1 of T1-3.1)
703
732
  // ============================================================================
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The local session ledger for `mnemom agent` — `~/.mnemom/sessions.jsonl`, one
3
+ * JSON line per REAL launch (never `--dry-run`), newest last.
4
+ *
5
+ * Pure module: the directory, the clock and the pid are injected so the append
6
+ * and the read are unit-testable with a temp dir. The record is deliberately a
7
+ * closed shape — the Anthropic key, the custom-headers line and the child env
8
+ * are never part of it (see `SessionRecord`), so nothing secret can ride along.
9
+ *
10
+ * Writes are best-effort: `appendSessionRecord` never throws — a failure comes
11
+ * back as `{ ok: false, error }` and the caller decides how loudly to warn.
12
+ */
13
+ import type { LaunchShape, SealedContract } from "./agent.js";
14
+ /** The ledger's file name under MNEMOM_DIR. */
15
+ export declare const LEDGER_FILE = "sessions.jsonl";
16
+ /** The ledger keeps only the newest this-many launches. */
17
+ export declare const LEDGER_CAP = 200;
18
+ /** The sealed-contract fields the ledger keeps (no goal_id — the statement is the identity). */
19
+ export interface SessionRecordContract {
20
+ statement: string;
21
+ requirements?: string[];
22
+ allowed_paths?: string[];
23
+ forbidden_paths?: string[];
24
+ max_turns?: number;
25
+ budget_usd?: number;
26
+ stall_turns?: number;
27
+ }
28
+ /** One launch, as written to the ledger. */
29
+ export interface SessionRecord {
30
+ v: 1;
31
+ /** ISO-8601 UTC. */
32
+ started_at: string;
33
+ /** The session (scenario) name. */
34
+ session: string;
35
+ conversation_id: string;
36
+ /** The x-mnemom-agent slug the launch ran under. */
37
+ agent_slug: string;
38
+ /** The agent record id when it resolved at launch, else null. */
39
+ agent_id: string | null;
40
+ org_id: string;
41
+ org_label: string;
42
+ gateway_host: string;
43
+ launch_shape: LaunchShape;
44
+ model: string | null;
45
+ effort: string | null;
46
+ /** The coding-agent CLI's basename, e.g. "claude". */
47
+ cli: string;
48
+ contract: SessionRecordContract | null;
49
+ }
50
+ /** What the call site supplies — the ledger stamps `v` and `started_at`. */
51
+ export type SessionRecordInput = Omit<SessionRecord, "v" | "started_at">;
52
+ /** The injected environment of the ledger. */
53
+ export interface LedgerDeps {
54
+ /** The directory holding the ledger (MNEMOM_DIR in production). */
55
+ dir: string;
56
+ /** The clock (stamps `started_at` and names the rewrite temp file). */
57
+ now: () => Date;
58
+ /** The writing process's pid (names the rewrite temp file). */
59
+ pid: number;
60
+ }
61
+ export type AppendResult = {
62
+ ok: true;
63
+ record: SessionRecord;
64
+ path: string;
65
+ } | {
66
+ ok: false;
67
+ error: string;
68
+ };
69
+ /**
70
+ * Project a sealed contract onto the ledger's contract shape (drops `goal_id`,
71
+ * keeps only defined fields). `null` in, `null` out.
72
+ */
73
+ export declare function toLedgerContract(sealed: SealedContract | null): SessionRecordContract | null;
74
+ /** The ledger's path under `dir`. */
75
+ export declare function ledgerPath(dir: string): string;
76
+ /**
77
+ * Append one launch to the ledger, atomically and capped:
78
+ * - `dir` is created 0o700 when missing; the file is created 0o600;
79
+ * - the new line is appended when the ledger stays within LEDGER_CAP lines,
80
+ * otherwise the newest LEDGER_CAP lines (this one included) are rewritten
81
+ * via a temp file + rename, so a reader never sees a torn file.
82
+ * Never throws — a failure is returned as `{ ok: false, error }`.
83
+ */
84
+ export declare function appendSessionRecord(input: SessionRecordInput, deps: LedgerDeps): AppendResult;
85
+ /**
86
+ * Read the ledger, oldest first (file order). A missing file is an empty
87
+ * ledger; a line that is not a v1 record object is skipped, never fatal.
88
+ */
89
+ export declare function readSessionRecords(deps: Pick<LedgerDeps, "dir">): SessionRecord[];
90
+ /** One ledger line → a record, or null when it is not a v1 record object. */
91
+ export declare function parseRecord(line: string): SessionRecord | null;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The local session ledger for `mnemom agent` — `~/.mnemom/sessions.jsonl`, one
3
+ * JSON line per REAL launch (never `--dry-run`), newest last.
4
+ *
5
+ * Pure module: the directory, the clock and the pid are injected so the append
6
+ * and the read are unit-testable with a temp dir. The record is deliberately a
7
+ * closed shape — the Anthropic key, the custom-headers line and the child env
8
+ * are never part of it (see `SessionRecord`), so nothing secret can ride along.
9
+ *
10
+ * Writes are best-effort: `appendSessionRecord` never throws — a failure comes
11
+ * back as `{ ok: false, error }` and the caller decides how loudly to warn.
12
+ */
13
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ /** The ledger's file name under MNEMOM_DIR. */
16
+ export const LEDGER_FILE = "sessions.jsonl";
17
+ /** The ledger keeps only the newest this-many launches. */
18
+ export const LEDGER_CAP = 200;
19
+ /**
20
+ * Project a sealed contract onto the ledger's contract shape (drops `goal_id`,
21
+ * keeps only defined fields). `null` in, `null` out.
22
+ */
23
+ export function toLedgerContract(sealed) {
24
+ if (!sealed)
25
+ return null;
26
+ const out = { statement: sealed.statement };
27
+ if (sealed.requirements !== undefined)
28
+ out.requirements = sealed.requirements;
29
+ if (sealed.allowed_paths !== undefined)
30
+ out.allowed_paths = sealed.allowed_paths;
31
+ if (sealed.forbidden_paths !== undefined)
32
+ out.forbidden_paths = sealed.forbidden_paths;
33
+ if (sealed.max_turns !== undefined)
34
+ out.max_turns = sealed.max_turns;
35
+ if (sealed.budget_usd !== undefined)
36
+ out.budget_usd = sealed.budget_usd;
37
+ if (sealed.stall_turns !== undefined)
38
+ out.stall_turns = sealed.stall_turns;
39
+ return out;
40
+ }
41
+ /** The ledger's path under `dir`. */
42
+ export function ledgerPath(dir) {
43
+ return join(dir, LEDGER_FILE);
44
+ }
45
+ /**
46
+ * Read the ledger text, or null when the file does not exist. Read-and-catch
47
+ * rather than exists-then-read so there is no window in which the file can
48
+ * change between the check and the read (CodeQL js/file-system-race).
49
+ */
50
+ function readLedgerText(path) {
51
+ try {
52
+ return readFileSync(path, "utf8");
53
+ }
54
+ catch (err) {
55
+ if (err.code === "ENOENT")
56
+ return null;
57
+ throw err;
58
+ }
59
+ }
60
+ /**
61
+ * Append one launch to the ledger, atomically and capped:
62
+ * - `dir` is created 0o700 when missing; the file is created 0o600;
63
+ * - the new line is appended when the ledger stays within LEDGER_CAP lines,
64
+ * otherwise the newest LEDGER_CAP lines (this one included) are rewritten
65
+ * via a temp file + rename, so a reader never sees a torn file.
66
+ * Never throws — a failure is returned as `{ ok: false, error }`.
67
+ */
68
+ export function appendSessionRecord(input, deps) {
69
+ const path = ledgerPath(deps.dir);
70
+ try {
71
+ const record = { v: 1, started_at: deps.now().toISOString(), ...input };
72
+ mkdirSync(deps.dir, { recursive: true, mode: 0o700 });
73
+ const line = `${JSON.stringify(record)}\n`;
74
+ const text = readLedgerText(path);
75
+ const existing = text === null ? [] : nonEmptyLines(text);
76
+ if (existing.length + 1 > LEDGER_CAP) {
77
+ const kept = [...existing, JSON.stringify(record)].slice(-LEDGER_CAP);
78
+ const tmp = join(deps.dir, `.${LEDGER_FILE}.tmp-${deps.pid}-${deps.now().getTime()}`);
79
+ writeFileSync(tmp, `${kept.join("\n")}\n`, { mode: 0o600 });
80
+ renameSync(tmp, path);
81
+ }
82
+ else {
83
+ appendFileSync(path, line, { mode: 0o600 });
84
+ }
85
+ return { ok: true, record, path };
86
+ }
87
+ catch (err) {
88
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
89
+ }
90
+ }
91
+ /**
92
+ * Read the ledger, oldest first (file order). A missing file is an empty
93
+ * ledger; a line that is not a v1 record object is skipped, never fatal.
94
+ */
95
+ export function readSessionRecords(deps) {
96
+ const path = ledgerPath(deps.dir);
97
+ const text = readLedgerText(path);
98
+ if (text === null)
99
+ return [];
100
+ const out = [];
101
+ for (const line of nonEmptyLines(text)) {
102
+ const parsed = parseRecord(line);
103
+ if (parsed)
104
+ out.push(parsed);
105
+ }
106
+ return out;
107
+ }
108
+ /** One ledger line → a record, or null when it is not a v1 record object. */
109
+ export function parseRecord(line) {
110
+ let value;
111
+ try {
112
+ value = JSON.parse(line);
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ if (!value || typeof value !== "object" || Array.isArray(value))
118
+ return null;
119
+ const rec = value;
120
+ if (rec.v !== 1)
121
+ return null;
122
+ return rec;
123
+ }
124
+ function nonEmptyLines(text) {
125
+ return text.split("\n").filter((l) => l.trim().length > 0);
126
+ }
package/dist/lib/api.d.ts CHANGED
@@ -1219,6 +1219,68 @@ export declare const CODE_NOT_ENABLED = "code_not_enabled";
1219
1219
  * for membership/transport failures.
1220
1220
  */
1221
1221
  export declare function getCodeEntitlement(orgId?: string): Promise<CodeEntitlement>;
1222
+ /** The gateway's sealed contract for a conversation (absent arrays are `[]`). */
1223
+ export interface AgentSessionSealed {
1224
+ contract: {
1225
+ goal_id?: string;
1226
+ statement: string;
1227
+ requirements: string[];
1228
+ allowed_paths: string[];
1229
+ forbidden_paths: string[];
1230
+ max_turns?: number;
1231
+ budget_usd?: number;
1232
+ stall_turns?: number;
1233
+ };
1234
+ /** The gateway's hash of the sealed contract — NOT the CLI's launch-time sha. */
1235
+ hash: string;
1236
+ sealed_at: string;
1237
+ }
1238
+ /** The latest goal-judge verdict stored for the conversation. */
1239
+ export interface AgentSessionVerdict {
1240
+ turn: number;
1241
+ verdict: "aligned" | "drifting" | "violation";
1242
+ reason: string;
1243
+ at: string;
1244
+ streak_start?: number;
1245
+ }
1246
+ export interface AgentSessionGuardrails {
1247
+ ceilings: {
1248
+ /** null = inactive */
1249
+ max_turns: number | null;
1250
+ budget_usd: number | null;
1251
+ stall_turns: number | null;
1252
+ };
1253
+ state: {
1254
+ turns: number;
1255
+ spend_usd: number;
1256
+ stall_run: number;
1257
+ fired: {
1258
+ turns?: boolean;
1259
+ budget?: boolean;
1260
+ stall?: boolean;
1261
+ };
1262
+ } | null;
1263
+ }
1264
+ /** `GET /v1/agent/sessions/{conversationId}` 200 body. Nulls mean "nothing stored" — absence is data. */
1265
+ export interface AgentSessionResponse {
1266
+ conversation_id: string;
1267
+ agent: {
1268
+ id: string;
1269
+ name: string | null;
1270
+ };
1271
+ org_id: string;
1272
+ sealed: AgentSessionSealed | null;
1273
+ verdict: AgentSessionVerdict | null;
1274
+ turn: number | null;
1275
+ guardrails: AgentSessionGuardrails | null;
1276
+ }
1277
+ /**
1278
+ * The live goal state of one `mnemom agent` session, read through mnemom-api
1279
+ * (org-scoped; the api proxies the gateway's internal read). Non-OK responses
1280
+ * keep the server's `code` + `details` (`agent_not_found`, `gateway_unavailable`)
1281
+ * so `mnemom agent show` can name the reason.
1282
+ */
1283
+ export declare function getAgentSession(conversationId: string, agentId: string, orgId: string): Promise<AgentSessionResponse>;
1222
1284
  /** `POST /v1/invites/accept` 200 body. Never carries an amount — only whether the
1223
1285
  * starter grant fired or was deferred for a later backfill. */
1224
1286
  export interface AcceptProductInviteResult {
package/dist/lib/api.js CHANGED
@@ -1538,6 +1538,17 @@ export async function getCodeEntitlement(orgId) {
1538
1538
  const q = orgId ? `?org_id=${encodeURIComponent(orgId)}` : "";
1539
1539
  return codeFetch(`/v1/code/entitlement${q}`, {}, "Could not check Mnemom Agent access");
1540
1540
  }
1541
+ /**
1542
+ * The live goal state of one `mnemom agent` session, read through mnemom-api
1543
+ * (org-scoped; the api proxies the gateway's internal read). Non-OK responses
1544
+ * keep the server's `code` + `details` (`agent_not_found`, `gateway_unavailable`)
1545
+ * so `mnemom agent show` can name the reason.
1546
+ */
1547
+ export async function getAgentSession(conversationId, agentId, orgId) {
1548
+ // `org_id` is the org the launch resolved (the ledger record's), so an agent that
1549
+ // lives in a non-primary org resolves instead of 404ing against the caller's primary org.
1550
+ return codeFetch(`/v1/agent/sessions/${encodeURIComponent(conversationId)}?agent_id=${encodeURIComponent(agentId)}&org_id=${encodeURIComponent(orgId)}`, {}, "Could not read the session's live state");
1551
+ }
1541
1552
  /** Redeem a `pinv_…` product invite (a Mnemom Agent invitation) for the signed-in user. */
1542
1553
  export async function acceptProductInvite(token) {
1543
1554
  return codeFetch("/v1/invites/accept", { method: "POST", body: JSON.stringify({ token }) }, "Could not redeem the invitation");
@@ -37,3 +37,9 @@ export declare const fmt: {
37
37
  */
38
38
  badge(text: string, color?: BadgeColor): string;
39
39
  };
40
+ /**
41
+ * Human-friendly age of an ISO timestamp relative to now — "<1m ago", "42m ago",
42
+ * "3h 5m ago", "2d 4h ago"; a timestamp ahead of the clock reads "in the future?".
43
+ * Shared by `mnemom validate` and `mnemom agent sessions` / `show`.
44
+ */
45
+ export declare function formatRelativeAge(startedAt: string): string;
@@ -64,3 +64,23 @@ export const fmt = {
64
64
  return colorFn(`[${text}]`);
65
65
  },
66
66
  };
67
+ /**
68
+ * Human-friendly age of an ISO timestamp relative to now — "<1m ago", "42m ago",
69
+ * "3h 5m ago", "2d 4h ago"; a timestamp ahead of the clock reads "in the future?".
70
+ * Shared by `mnemom validate` and `mnemom agent sessions` / `show`.
71
+ */
72
+ export function formatRelativeAge(startedAt) {
73
+ const ageMs = Date.now() - Date.parse(startedAt);
74
+ if (ageMs < 0)
75
+ return "in the future?";
76
+ const m = Math.floor(ageMs / 60_000);
77
+ if (m < 1)
78
+ return "<1m ago";
79
+ if (m < 60)
80
+ return `${m}m ago`;
81
+ const h = Math.floor(m / 60);
82
+ if (h < 24)
83
+ return `${h}h ${m % 60}m ago`;
84
+ const d = Math.floor(h / 24);
85
+ return `${d}d ${h % 24}h ago`;
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.0-next.8",
3
+ "version": "0.17.0",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {