@mnemom/mnemom 0.17.20-next.0 → 0.17.21

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,1476 @@
1
+ /**
2
+ * Mnemom for Claude Code — the hooks module.
3
+ *
4
+ * `mnemom agent` launches stock Claude Code with its model traffic routed through the Mnemom
5
+ * gateway (ANTHROPIC_BASE_URL → <gateway>/anthropic, the Mnemom key and the governed identity in
6
+ * ANTHROPIC_CUSTOM_HEADERS; under Remote Control a local proxy does the same). This mod is what
7
+ * the person SEES of that: the Mnemom band above the prompt, `/mnemom` commands, the Mnemom sign on
8
+ * the spinner and the turn line, Mnemom trailers in commit attribution, a governance section in
9
+ * the system prompt — and a guard that keeps the session routed.
10
+ *
11
+ * The launcher tells the mod about the launch through NON-SECRET env vars (`MNEMOM_CC_*`, set by
12
+ * cli/src/lib/claude-plugin.ts). No key, no header value ever reaches this module: a mod reads
13
+ * `$.env.get` by literal name and `claude plugin validate` lists every name it reads.
14
+ *
15
+ * Mnemom is the trust plane for the agentic internet: https://www.mnemom.ai
16
+ */
17
+
18
+ import { atom, read, update, type Register, type StateDollar } from "claude-code";
19
+
20
+ import type {
21
+ MnemomCeilings,
22
+ MnemomContract,
23
+ MnemomGoal,
24
+ MnemomLaunch,
25
+ MnemomReputation,
26
+ MnemomVerdict,
27
+ } from "../types";
28
+
29
+ const PLUGIN = "mnemom";
30
+ const SIGN = "◆";
31
+ /** The status pane's id (`$.ui.open`, the `Pane` render hook, `/mnemom panel`). */
32
+ const PANE = "mnemom-status";
33
+
34
+ /**
35
+ * The intro: on session start the band reveals the header left to right, holds it with a slow
36
+ * pulse of the name, then drops to the one-row band. 15 reveal frames + 24 hold frames at
37
+ * 60 ms ≈ 2.3 s.
38
+ */
39
+ const INTRO_FRAME_MS = 60;
40
+ const INTRO_REVEAL_FRAMES = 15;
41
+ const INTRO_HOLD_FRAMES = 24;
42
+ const INTRO_FRAMES = INTRO_REVEAL_FRAMES + INTRO_HOLD_FRAMES;
43
+
44
+ // Brand: the name in the site's teal (mnemom-website/client/global.css); the mark is the
45
+ // logo's glyph, traced once from the logo PNG into cells in its own gold (MARK_MU below).
46
+ // Terminal colours are hex where the surface supports truecolor and the nearest ANSI colour
47
+ // elsewhere.
48
+ const TEAL = "#14b8a6";
49
+ const TEAL_BRIGHT = "#2dd4bf";
50
+ const WARN = "#f59e0b";
51
+ const BAD = "#ef4444";
52
+ /** The gold of the logo's glyph: the ◆ sign on the one-row band. */
53
+ const GOLD = "#e0a040";
54
+
55
+ // ── the logo's glyph, traced once from the brand PNG (the tracer is not in the tree; the
56
+ // rows below are the result and are edited by hand if the mark ever changes) ──
57
+ /** The glyph's gold as the trace found it in the logo. */
58
+ const MARK_GOLD = "#d89838";
59
+ /** The logo's glyph at 4 rows × 11 cells, a quadrant block per cell (its four dots), `.` where all four are off. */
60
+ // prettier-ignore
61
+ const MARK_MU: readonly string[] = [
62
+ "▗█▀▀█▙..██.",
63
+ "██..██..██.",
64
+ "██..██..██.",
65
+ "██..▜█▄▄▜█▄",
66
+ ];
67
+ // ── end of the traced glyph ──
68
+
69
+ /** One run of same-coloured cells in a row of the artwork; `dim`/`bold` mark text, not painted cells. */
70
+ type Seg = { t: string; fg: string; bg?: string; dim?: boolean; bold?: boolean; inverse?: boolean };
71
+
72
+ /** The quadrant block for each dot pattern; bits top-left 1, top-right 2, bottom-left 4, bottom-right 8. */
73
+ const QUADRANTS = " ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█";
74
+
75
+ /**
76
+ * The traced mark's rows → runs of terminal cells. A row is one character per cell: a quadrant
77
+ * block (the cell's four square dots, lit where the glyph is) in the glyph's gold, the way Claude
78
+ * Code draws its own mascot; `.` a cell with no dot lit. Only the Block Elements glyphs (█ ▀ ▄ ▌ ▐
79
+ * and the quarters), which every monospace font carries; the finer sextant and octant blocks are
80
+ * drawn from a fallback font and leave seams. A FULL cell (█) is PAINTED, not drawn: a space on
81
+ * the gold as its background. A glyph is drawn inside the glyph box, which is smaller than the
82
+ * cell wherever the terminal has line spacing or letter spacing, so a mark drawn in glyphs alone
83
+ * tiles with a grid of gaps (the nu showed one); a background fills the whole cell, spacing and
84
+ * all, so the stems and the body run unbroken, and only the partial cells at the crown, the floor,
85
+ * the corners and the tail are glyphs, meeting the painted cells where the glyph box ends. Where
86
+ * the glyph box sits is the terminal's: in one that draws the blocks from the font (cmux does)
87
+ * it starts about a fifth of the way down the cell and ends near its bottom, inset a little at
88
+ * the sides, so a glyph lit across its TOP (▀ ▜ ▛) drawn plainly hangs a fifth of a cell below
89
+ * the painted cells beside it — the crown sank below the stems' tops, and ▜ at a stem's foot left
90
+ * a dark slot across the stem — and drawn inverse (gold behind, the complement cut out) it shows
91
+ * the cell's margin as a gold rim around the cut-out, in a duller reverse-video gold. So the
92
+ * forms are these. A cell lit in its top half alone (▀, the crown) is PAINTED WHOLE: the logo's
93
+ * stroke is one width throughout and the stems are two dots wide, so the crown is that stroke,
94
+ * which the dot grid happened to split across a row boundary. A cell lit across its top and in
95
+ * ONE bottom dot (▛ ▜: a stem's foot, its outer corner rounded by the one dot that is off) is
96
+ * drawn INVERSE, its gold as the background and the missing dot cut out of it in the terminal's
97
+ * own background colour: the rounding is the point of the cell, a
98
+ * plain glyph there leaves a slot across the stem, and the rim is the price of the curve. A cell
99
+ * lit only below, or in one top corner (▄ ▗ ▙ ▟), stays a plain glyph: it rests on the cell's
100
+ * bottom, and it is what rounds the shoulders and flicks the tail. Where a terminal draws the
101
+ * blocks to the whole cell this changes nothing but the crown's weight, which is the logo's.
102
+ */
103
+ function markRows(mark: readonly string[]): Seg[][] {
104
+ return mark.map((line) => {
105
+ const row: Seg[] = [];
106
+ for (const ch of Array.from(line)) {
107
+ const bits = QUADRANTS.indexOf(ch === "." ? " " : ch);
108
+ const seg: Seg =
109
+ bits === 15 || bits === 3
110
+ ? { t: " ", fg: MARK_GOLD, bg: MARK_GOLD }
111
+ : bits === 7 || bits === 11
112
+ ? { t: QUADRANTS[15 - bits] ?? " ", fg: MARK_GOLD, inverse: true }
113
+ : { t: bits > 0 ? ch : " ", fg: MARK_GOLD };
114
+ const last = row[row.length - 1];
115
+ if (last !== undefined && last.bg === seg.bg && last.inverse === seg.inverse) last.t += seg.t;
116
+ else row.push(seg);
117
+ }
118
+ return row;
119
+ });
120
+ }
121
+
122
+ /**
123
+ * The header, the shape of Claude Code's own (its mark beside "Claude Code v…", a line of text
124
+ * under that): the logo's glyph alone — the gold "nu" at its centre, traced from the logo PNG
125
+ * into quadrant blocks, four square dots to a cell, full cells painted; the whole logo is a colour
126
+ * blob at this size — beside `Mnemom` in bold teal with the launching CLI's version after it, and
127
+ * the tagline beneath, the two lines centred on the mark's rows. The glyph is four rows (eight
128
+ * dots) tall, a row over Claude Code's own header. The band's intro shows it, the status pane
129
+ * heads with it.
130
+ */
131
+ const MARK_HEADER = markRows(MARK_MU);
132
+ const WORD = "Mnemom";
133
+ const TAGLINE = "the trust plane for the agentic internet";
134
+
135
+ function cellsOf(row: readonly Seg[]): number {
136
+ return row.reduce((n, s) => n + s.t.length, 0);
137
+ }
138
+ function markColumns(mark: readonly (readonly Seg[])[]): number {
139
+ return Math.max(0, ...mark.map(cellsOf));
140
+ }
141
+
142
+ /** The text lines sit two cells right of the mark, centred on its rows. */
143
+ const HEADER_INDENT = markColumns(MARK_HEADER) + 2;
144
+ const HEADER_ROWS = Math.max(MARK_HEADER.length, 2);
145
+ const HEADER_TEXT_ROW = Math.floor((HEADER_ROWS - 2) / 2);
146
+ function headerColumns(version: string): number {
147
+ return HEADER_INDENT + Math.max(WORD.length + (version ? version.length + 2 : 0), TAGLINE.length);
148
+ }
149
+ /** The header in the given word colour (the glyph keeps the logo's gold); `version` may be empty. */
150
+ function header(wordColor: string, version: string): Seg[][] {
151
+ const lines: Seg[][] = [
152
+ version
153
+ ? [
154
+ { t: WORD, fg: wordColor, bold: true },
155
+ { t: ` v${version}`, fg: wordColor, dim: true },
156
+ ]
157
+ : [{ t: WORD, fg: wordColor, bold: true }],
158
+ [{ t: TAGLINE, fg: wordColor, dim: true }],
159
+ ];
160
+ const rows = Array.from({ length: HEADER_ROWS }, (_, i) => [...(MARK_HEADER[i] ?? [])]);
161
+ return rows.map((row, i) => {
162
+ const line = lines[i - HEADER_TEXT_ROW];
163
+ if (line === undefined) return row;
164
+ return [...row, { t: " ".repeat(HEADER_INDENT - cellsOf(row)), fg: wordColor }, ...line];
165
+ });
166
+ }
167
+
168
+ /** The first `n` cells of a row of segments (the intro reveals the artwork left to right). */
169
+ function cut(row: readonly Seg[], n: number): Seg[] {
170
+ const out: Seg[] = [];
171
+ let left = n;
172
+ for (const s of row) {
173
+ if (left <= 0) break;
174
+ out.push(left >= s.t.length ? s : { ...s, t: s.t.slice(0, left) });
175
+ left -= s.t.length;
176
+ }
177
+ return out;
178
+ }
179
+
180
+ const launch = atom({ plugin: "mnemom", key: "launch" } as const, null);
181
+ const reputation = atom({ plugin: "mnemom", key: "reputation" } as const, null);
182
+ const isHidden = atom({ plugin: "mnemom", key: "isHidden" } as const, false);
183
+ const turns = atom({ plugin: "mnemom", key: "turns" } as const, 0);
184
+ /** The intro's frame while it plays; -1 once it is over (the band is then one row). */
185
+ const introFrame = atom({ plugin: "mnemom", key: "introFrame" } as const, -1);
186
+ /** The live goal state (verdict, inferred goal, guardrail usage); null when not governed. */
187
+ const goal = atom({ plugin: "mnemom", key: "goal" } as const, null);
188
+
189
+ /** Refresh the Trust Rating every this many completed turns. */
190
+ const REPUTATION_REFRESH_TURNS = 10;
191
+ /**
192
+ * How long after a turn completes the live goal state is re-read: the gateway stores the turn's
193
+ * verdict (and, implicit, any goal revision) as the response closes, a moment before the turn ends
194
+ * here; the short wait lets that write land before `mnemom agent show` reads it.
195
+ */
196
+ const GOAL_REFRESH_DELAY_MS = 1500;
197
+ /** How long one `mnemom agent … --json` may take before it reads as unavailable. */
198
+ const GOAL_READ_TIMEOUT_MS = 8000;
199
+
200
+ /**
201
+ * The Trust Rating lookup in flight, if any. Started beside the session start (never in its way),
202
+ * awaited by `/mnemom status` and `/mnemom doctor` so they report a settled rating, and swallowed
203
+ * when it fails: the atom already carries the error state for the band to show.
204
+ */
205
+ let reputationInFlight: Promise<unknown> | undefined;
206
+ /** The live goal read in flight, if any; `/mnemom goal` and `/mnemom status` await it. */
207
+ let goalInFlight: Promise<unknown> | undefined;
208
+
209
+ /** A Bash command that would point the session away from the gateway, or drop its identity. */
210
+ const REROUTE_RE =
211
+ /(^|[\s;&|(`])(unset\s+ANTHROPIC_(BASE_URL|CUSTOM_HEADERS|AUTH_TOKEN)\b|(export\s+)?ANTHROPIC_(BASE_URL|CUSTOM_HEADERS|AUTH_TOKEN)=)/;
212
+
213
+ type Options = {
214
+ showBand: boolean;
215
+ requireGovernance: boolean;
216
+ guardReroute: boolean;
217
+ commitTrailer: boolean;
218
+ spinnerMark: boolean;
219
+ };
220
+
221
+ function readOptions(raw: Readonly<Record<string, unknown>>): Options {
222
+ const flag = (name: keyof Options, fallback: boolean): boolean =>
223
+ typeof raw[name] === "boolean" ? (raw[name] as boolean) : fallback;
224
+ return {
225
+ showBand: flag("showBand", true),
226
+ requireGovernance: flag("requireGovernance", false),
227
+ guardReroute: flag("guardReroute", true),
228
+ commitTrailer: flag("commitTrailer", true),
229
+ spinnerMark: flag("spinnerMark", true),
230
+ };
231
+ }
232
+
233
+ /** The launch facts, when `mnemom agent` exported them; null for a session it did not launch. */
234
+ async function readLaunch($: {
235
+ env: { get: (name: string) => Promise<string | undefined> };
236
+ }): Promise<MnemomLaunch | null> {
237
+ // Literal names only: `$.env.get` is pinned to the names the module spells out.
238
+ const gatewayHost = ((await $.env.get("MNEMOM_CC_GATEWAY_HOST")) ?? "").trim();
239
+ const agent = ((await $.env.get("MNEMOM_CC_AGENT")) ?? "").trim();
240
+ if (!gatewayHost || !agent) return null;
241
+ const mode = ((await $.env.get("MNEMOM_CC_GOAL_MODE")) ?? "off").trim();
242
+ const goalMode = mode === "sealed" || mode === "implicit" ? mode : "off";
243
+ const sealed =
244
+ goalMode === "sealed" ? parseContract((await $.env.get("MNEMOM_CC_CONTRACT")) ?? "") : null;
245
+ const ceilings =
246
+ sealed !== null
247
+ ? sealed.ceilings
248
+ : goalMode === "implicit"
249
+ ? parseGuardrails((await $.env.get("MNEMOM_CC_GUARDRAILS")) ?? "")
250
+ : NO_CEILINGS;
251
+ return {
252
+ gatewayHost: gatewayHost.replace(/\/+$/, ""),
253
+ apiUrl: (((await $.env.get("MNEMOM_CC_API_URL")) ?? "") || "https://api.mnemom.ai")
254
+ .trim()
255
+ .replace(/\/+$/, ""),
256
+ agent,
257
+ conversationId: ((await $.env.get("MNEMOM_CC_CONVERSATION_ID")) ?? "").trim(),
258
+ cell: ((await $.env.get("MNEMOM_CC_CELL")) ?? "").trim() || gatewayHost,
259
+ shape: ((await $.env.get("MNEMOM_CC_SHAPE")) ?? "terminal").trim(),
260
+ goalMode,
261
+ contractSha: ((await $.env.get("MNEMOM_CC_CONTRACT_SHA")) ?? "").trim(),
262
+ contract: sealed?.contract ?? null,
263
+ ceilings,
264
+ launcherExec: parseExec((await $.env.get("MNEMOM_CC_LAUNCHER_EXEC")) ?? ""),
265
+ session: ((await $.env.get("MNEMOM_CC_SESSION")) ?? "").trim(),
266
+ launcherVersion: ((await $.env.get("MNEMOM_CC_LAUNCHER_VERSION")) ?? "").trim(),
267
+ };
268
+ }
269
+
270
+ function reputationUrl(l: MnemomLaunch): string {
271
+ return `${l.apiUrl}/v1/reputation/${encodeURIComponent(l.agent)}`;
272
+ }
273
+
274
+ function asRecord(text: string): Record<string, unknown> | null {
275
+ try {
276
+ const v: unknown = JSON.parse(text);
277
+ return v !== null && typeof v === "object" && !Array.isArray(v)
278
+ ? (v as Record<string, unknown>)
279
+ : null;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+
285
+ function num(v: unknown): number | null {
286
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
287
+ }
288
+
289
+ function str(v: unknown): string | null {
290
+ return typeof v === "string" && v.trim() ? v.trim() : null;
291
+ }
292
+
293
+ function strList(v: unknown): string[] {
294
+ return Array.isArray(v) ? v.map(str).filter((x): x is string => x !== null) : [];
295
+ }
296
+
297
+ function rec(v: unknown): Record<string, unknown> | null {
298
+ return v !== null && typeof v === "object" && !Array.isArray(v)
299
+ ? (v as Record<string, unknown>)
300
+ : null;
301
+ }
302
+
303
+ /** One line of it: whitespace runs (and the newlines a statement may carry) become one space. */
304
+ function oneLine(text: string): string {
305
+ return text.replace(/\s+/g, " ").trim();
306
+ }
307
+
308
+ const NO_CEILINGS: MnemomCeilings = { maxTurns: null, budgetUsd: null, stallTurns: null };
309
+
310
+ /** A positive finite number, else null (the gateway's `null = inactive` reads the same way). */
311
+ function ceiling(v: unknown): number | null {
312
+ const n = num(v);
313
+ return n !== null && n > 0 ? n : null;
314
+ }
315
+
316
+ /** The sealed contract's canonical JSON (MNEMOM_CC_CONTRACT), as the launcher sealed it. */
317
+ function parseContract(
318
+ text: string,
319
+ ): { contract: MnemomContract; ceilings: MnemomCeilings } | null {
320
+ const body = asRecord(text);
321
+ if (!body) return null;
322
+ const statement = str(body.statement);
323
+ if (statement === null) return null;
324
+ return {
325
+ contract: {
326
+ goalId: str(body.goal_id),
327
+ statement,
328
+ requirements: strList(body.requirements),
329
+ allowedPaths: strList(body.allowed_paths),
330
+ forbiddenPaths: strList(body.forbidden_paths),
331
+ },
332
+ ceilings: {
333
+ maxTurns: ceiling(body.max_turns),
334
+ budgetUsd: ceiling(body.budget_usd),
335
+ stallTurns: ceiling(body.stall_turns),
336
+ },
337
+ };
338
+ }
339
+
340
+ /** `max_turns=40; budget_usd=5; stall_turns=6` (MNEMOM_CC_GUARDRAILS) → ceilings. */
341
+ function parseGuardrails(text: string): MnemomCeilings {
342
+ const out: MnemomCeilings = { ...NO_CEILINGS };
343
+ for (const part of text.split(";")) {
344
+ const [k, v] = part.split("=").map((x) => x.trim());
345
+ if (!k || v === undefined) continue;
346
+ const n = ceiling(Number(v));
347
+ if (k === "max_turns") out.maxTurns = n;
348
+ else if (k === "budget_usd") out.budgetUsd = n;
349
+ else if (k === "stall_turns") out.stallTurns = n;
350
+ }
351
+ return out;
352
+ }
353
+
354
+ /** MNEMOM_CC_LAUNCHER_EXEC: a JSON argv, every element a non-empty string; else empty. */
355
+ function parseExec(text: string): string[] {
356
+ try {
357
+ const v: unknown = JSON.parse(text);
358
+ return Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === "string" && x.trim())
359
+ ? (v as string[])
360
+ : [];
361
+ } catch {
362
+ return [];
363
+ }
364
+ }
365
+
366
+ /** GET /v1/reputation/{agent}: the public Trust Rating. Never throws. */
367
+ async function fetchReputation(
368
+ $: { http: { fetch: (url: string) => Promise<{ ok: boolean; status: number; text: string }> } },
369
+ l: MnemomLaunch,
370
+ now: number,
371
+ ): Promise<MnemomReputation> {
372
+ try {
373
+ const res = await $.http.fetch(reputationUrl(l));
374
+ if (res.status === 404) return { state: "unrated", checkpointCount: null, fetchedAt: now };
375
+ if (!res.ok) return { state: "error", reason: `HTTP ${res.status}`, fetchedAt: now };
376
+ const body = asRecord(res.text);
377
+ if (!body) return { state: "error", reason: "unreadable body", fetchedAt: now };
378
+ const grade = str(body.grade);
379
+ const score = num(body.score);
380
+ const checkpointCount = num(body.checkpoint_count);
381
+ const eligible = body.is_eligible;
382
+ if (!grade || grade === "NR" || score === null || eligible === false) {
383
+ return { state: "unrated", checkpointCount, fetchedAt: now };
384
+ }
385
+ return {
386
+ state: "rated",
387
+ grade,
388
+ score,
389
+ tier: str(body.tier),
390
+ checkpointCount,
391
+ fetchedAt: now,
392
+ };
393
+ } catch (err) {
394
+ return {
395
+ state: "error",
396
+ reason: err instanceof Error ? err.message : String(err),
397
+ fetchedAt: now,
398
+ };
399
+ }
400
+ }
401
+
402
+ type RunDollar = {
403
+ process: {
404
+ run: (
405
+ argv: readonly string[],
406
+ init?: { timeoutMs?: number },
407
+ ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
408
+ };
409
+ };
410
+
411
+ /**
412
+ * `mnemom agent <args> --json` through the launcher's own argv: the CLI authenticates with the
413
+ * launch key from its ledger, so the key never passes through here. Resolves the parsed object or
414
+ * throws with the CLI's first stderr line.
415
+ */
416
+ async function runCli(
417
+ $: RunDollar,
418
+ l: MnemomLaunch,
419
+ args: readonly string[],
420
+ ): Promise<Record<string, unknown>> {
421
+ const what = `mnemom ${args.filter((a) => a !== "--json").join(" ")}`;
422
+ const res = await $.process.run([...l.launcherExec, ...args], {
423
+ timeoutMs: GOAL_READ_TIMEOUT_MS,
424
+ });
425
+ if (res.exitCode !== 0) {
426
+ const why = oneLine(res.stderr.split("\n").find((x) => x.trim()) ?? "");
427
+ throw new Error(`${what} exited ${res.exitCode}${why ? `: ${why}` : ""}`);
428
+ }
429
+ // The JSON is the whole of stdout; a notice printed before it is skipped.
430
+ const at = res.stdout.indexOf("{");
431
+ const body = at >= 0 ? asRecord(res.stdout.slice(at)) : null;
432
+ if (!body) throw new Error(`${what}: unreadable output`);
433
+ return body;
434
+ }
435
+
436
+ function parseVerdict(v: unknown): MnemomVerdict | null {
437
+ const r = rec(v);
438
+ const word = r ? str(r.verdict) : null;
439
+ if (!r || word === null) return null;
440
+ return { verdict: word, turn: num(r.turn), reason: str(r.reason), at: str(r.at) };
441
+ }
442
+
443
+ /**
444
+ * The live goal state. Sealed: `mnemom agent show` (the gateway's hash, the verdict, guardrail
445
+ * usage). Implicit: that plus `mnemom agent goal show` (the inferred goal, its revision and pin).
446
+ * Never throws: a failed read is the `unavailable` state with its reason.
447
+ */
448
+ async function fetchGoal($: RunDollar, l: MnemomLaunch, now: number): Promise<MnemomGoal> {
449
+ const unavailable = (reason: string): MnemomGoal => ({
450
+ state: "unavailable",
451
+ reason,
452
+ fetchedAt: now,
453
+ });
454
+ // Every caller already skips `off`; this line is what narrows `l.goalMode` to the two live modes
455
+ // for `live.mode` below, so the plugin type-checks (tsc -p cli/plugins/mnemom).
456
+ if (l.goalMode === "off") return unavailable("goal alignment is off for this launch");
457
+ if (l.launcherExec.length === 0) return unavailable("the launcher did not say how to run mnemom");
458
+ if (!l.conversationId) return unavailable("the launcher did not say the conversation id");
459
+
460
+ const live: Extract<MnemomGoal, { state: "live" }> = {
461
+ state: "live",
462
+ fetchedAt: now,
463
+ mode: l.goalMode,
464
+ hash: null,
465
+ revision: null,
466
+ pinned: null,
467
+ pendingCount: null,
468
+ statement: l.contract?.statement ?? null,
469
+ requirements: l.contract?.requirements ?? [],
470
+ allowedPaths: l.contract?.allowedPaths ?? [],
471
+ forbiddenPaths: l.contract?.forbiddenPaths ?? [],
472
+ ceilings: { ...l.ceilings },
473
+ usage: null,
474
+ verdict: null,
475
+ turn: null,
476
+ };
477
+ const reasons: string[] = [];
478
+ let answered = false;
479
+
480
+ try {
481
+ const show = await runCli($, l, ["agent", "show", l.conversationId, "--json"]);
482
+ const view = rec(show.live);
483
+ if (view && view.available === true) {
484
+ answered = true;
485
+ const sealed = rec(view.sealed);
486
+ const contract = sealed ? rec(sealed.contract) : null;
487
+ if (sealed) live.hash = str(sealed.hash);
488
+ if (contract) {
489
+ live.statement = str(contract.statement) ?? live.statement;
490
+ live.requirements = strList(contract.requirements);
491
+ live.allowedPaths = strList(contract.allowed_paths);
492
+ live.forbiddenPaths = strList(contract.forbidden_paths);
493
+ }
494
+ const guardrails = rec(view.guardrails);
495
+ const ceilings = guardrails ? rec(guardrails.ceilings) : null;
496
+ if (ceilings) {
497
+ live.ceilings = {
498
+ maxTurns: ceiling(ceilings.max_turns) ?? live.ceilings.maxTurns,
499
+ budgetUsd: ceiling(ceilings.budget_usd) ?? live.ceilings.budgetUsd,
500
+ stallTurns: ceiling(ceilings.stall_turns) ?? live.ceilings.stallTurns,
501
+ };
502
+ }
503
+ const usage = guardrails ? rec(guardrails.state) : null;
504
+ if (usage) {
505
+ const fired = rec(usage.fired) ?? {};
506
+ live.usage = {
507
+ turns: num(usage.turns) ?? 0,
508
+ spendUsd: num(usage.spend_usd) ?? 0,
509
+ stallRun: num(usage.stall_run) ?? 0,
510
+ fired: Object.keys(fired).filter((k) => fired[k] === true),
511
+ };
512
+ }
513
+ live.verdict = parseVerdict(view.verdict);
514
+ live.turn = num(view.turn);
515
+ } else {
516
+ reasons.push(str(view?.reason) ?? "the gateway did not answer `mnemom agent show`");
517
+ }
518
+ } catch (err) {
519
+ reasons.push(err instanceof Error ? err.message : String(err));
520
+ }
521
+
522
+ if (l.goalMode === "implicit") {
523
+ try {
524
+ const g = await runCli($, l, ["agent", "goal", "show", l.conversationId, "--json"]);
525
+ // `mnemom agent goal show --json` → `{ gateway: {…}, ledger: {…} }`, the gateway part the live one.
526
+ const gw = rec(g.gateway) ?? g;
527
+ const intent = rec(gw.intent);
528
+ answered = true;
529
+ if (intent) {
530
+ live.hash = str(intent.contract_hash) ?? live.hash;
531
+ live.revision = num(intent.revision);
532
+ live.pinned = rec(intent.pinned) !== null;
533
+ live.pendingCount = num(intent.pending_count);
534
+ live.statement = str(intent.statement);
535
+ live.requirements = strList(intent.requirements);
536
+ live.allowedPaths = strList(intent.allowed_paths);
537
+ live.forbiddenPaths = strList(intent.forbidden_paths);
538
+ } else {
539
+ live.revision = num(gw.last_revision) ?? 0;
540
+ live.pinned = false;
541
+ }
542
+ live.verdict ??= parseVerdict(gw.verdict);
543
+ } catch (err) {
544
+ reasons.push(err instanceof Error ? err.message : String(err));
545
+ }
546
+ }
547
+
548
+ // Every path that leaves `answered` false pushed a reason first, so the join is never empty.
549
+ return answered ? live : unavailable(reasons.join("; "));
550
+ }
551
+
552
+ function gradeColor(grade: string): string {
553
+ if (/^A/.test(grade)) return TEAL_BRIGHT;
554
+ if (/^B/.test(grade)) return TEAL;
555
+ if (/^C/.test(grade)) return WARN;
556
+ return BAD;
557
+ }
558
+
559
+ function trustWord(r: MnemomReputation | null): string {
560
+ if (r === null || r.state === "loading") return "trust …";
561
+ if (r.state === "rated") return `trust ${r.grade} ${r.score}`;
562
+ if (r.state === "unrated") {
563
+ return r.checkpointCount !== null ? `trust NR (building ${r.checkpointCount}/50)` : "trust NR";
564
+ }
565
+ return "trust ?";
566
+ }
567
+
568
+ /** How long ago, for the pane: `just now`, `42s ago`, `3m ago`, `2h ago`. */
569
+ function ago(now: number, then: number): string {
570
+ const s = Math.max(0, Math.round((now - then) / 1000));
571
+ if (s < 5) return "just now";
572
+ if (s < 90) return `${s}s ago`;
573
+ const m = Math.round(s / 60);
574
+ if (m < 90) return `${m}m ago`;
575
+ return `${Math.round(m / 60)}h ago`;
576
+ }
577
+
578
+ /** The Trust Rating spelled out for the pane. */
579
+ function trustDetail(r: MnemomReputation | null, now: number): string {
580
+ if (r === null || r.state === "loading") return "fetching …";
581
+ if (r.state === "rated") {
582
+ const parts = [`${r.grade} ${r.score}`];
583
+ if (r.tier) parts.push(`tier ${r.tier}`);
584
+ if (r.checkpointCount !== null) parts.push(`${r.checkpointCount} checkpoints`);
585
+ parts.push(`refreshed ${ago(now, r.fetchedAt)}`);
586
+ return parts.join(" · ");
587
+ }
588
+ if (r.state === "unrated") {
589
+ return r.checkpointCount !== null
590
+ ? `not rated yet · building ${r.checkpointCount}/50 checkpoints · checked ${ago(now, r.fetchedAt)}`
591
+ : `not rated yet · checked ${ago(now, r.fetchedAt)}`;
592
+ }
593
+ return `unavailable · ${r.reason} · tried ${ago(now, r.fetchedAt)}`;
594
+ }
595
+
596
+ type LiveGoal = Extract<MnemomGoal, { state: "live" }>;
597
+
598
+ function liveGoal(g: MnemomGoal | null): LiveGoal | null {
599
+ return g !== null && g.state === "live" ? g : null;
600
+ }
601
+
602
+ function verdictColor(v: MnemomVerdict | null): string | undefined {
603
+ if (v === null) return undefined;
604
+ if (v.verdict === "aligned") return TEAL;
605
+ if (v.verdict === "drifting") return WARN;
606
+ if (v.verdict === "violation") return BAD;
607
+ return undefined;
608
+ }
609
+
610
+ /** The goal's mode, hash/revision and pin for the pane's `goal` row. */
611
+ function goalDetail(l: MnemomLaunch, g: MnemomGoal | null): string {
612
+ const live = liveGoal(g);
613
+ if (l.goalMode === "sealed") {
614
+ const parts = ["sealed contract"];
615
+ if (l.contractSha) parts.push(l.contractSha);
616
+ if (live?.hash && !l.contractSha.startsWith(live.hash) && !live.hash.startsWith(l.contractSha))
617
+ parts.push(`gateway hash ${live.hash}`);
618
+ if (l.contract?.goalId) parts.push(`goal id ${l.contract.goalId}`);
619
+ return parts.join(" · ");
620
+ }
621
+ if (l.goalMode === "implicit") {
622
+ const parts = ["implicit · inferred from your messages"];
623
+ if (live) {
624
+ parts.push(
625
+ live.revision === null || live.revision === 0
626
+ ? "no revision yet"
627
+ : `revision ${live.revision}`,
628
+ );
629
+ if (live.pinned) parts.push("pinned");
630
+ if (live.pendingCount) parts.push(`${live.pendingCount} pending`);
631
+ }
632
+ return parts.join(" · ");
633
+ }
634
+ return 'off · launch with --goal "…" (sealed) or --implicit';
635
+ }
636
+
637
+ /** The goal statement as best known: the gateway's (live) over the launch's (sealed contract). */
638
+ function goalStatement(l: MnemomLaunch, g: MnemomGoal | null): string | null {
639
+ return liveGoal(g)?.statement ?? l.contract?.statement ?? null;
640
+ }
641
+
642
+ function goalRequirements(l: MnemomLaunch, g: MnemomGoal | null): string[] {
643
+ const live = liveGoal(g);
644
+ return live !== null ? live.requirements : (l.contract?.requirements ?? []);
645
+ }
646
+
647
+ function goalPaths(
648
+ l: MnemomLaunch,
649
+ g: MnemomGoal | null,
650
+ ): { allowed: string[]; forbidden: string[] } {
651
+ const live = liveGoal(g);
652
+ return {
653
+ allowed: live?.allowedPaths ?? l.contract?.allowedPaths ?? [],
654
+ forbidden: live?.forbiddenPaths ?? l.contract?.forbiddenPaths ?? [],
655
+ };
656
+ }
657
+
658
+ function goalCeilings(l: MnemomLaunch, g: MnemomGoal | null): MnemomCeilings {
659
+ return liveGoal(g)?.ceilings ?? l.ceilings;
660
+ }
661
+
662
+ function usd(n: number): string {
663
+ return `$${n.toFixed(n < 10 ? 2 : 0)}`;
664
+ }
665
+
666
+ /** The pane's `paths` row: `allowed: a, b · forbidden: c`, or `no path fences`. */
667
+ function pathsDetail(paths: { allowed: string[]; forbidden: string[] }): string {
668
+ const parts: string[] = [];
669
+ if (paths.allowed.length > 0) parts.push(`allowed: ${paths.allowed.join(", ")}`);
670
+ if (paths.forbidden.length > 0) parts.push(`forbidden: ${paths.forbidden.join(", ")}`);
671
+ return parts.length > 0 ? parts.join(" · ") : "no path fences";
672
+ }
673
+
674
+ /**
675
+ * The guardrails row: each ceiling with its usage, `max turns 40 (12 used)`, `budget $5.00
676
+ * ($1.23 spent)`, `stall 6 (run 2)`; `FIRED` on one that tripped; `none set` when there are none.
677
+ */
678
+ function guardrailDetail(
679
+ l: MnemomLaunch,
680
+ g: MnemomGoal | null,
681
+ ): { text: string; color: string | undefined } {
682
+ const c = goalCeilings(l, g);
683
+ const u = liveGoal(g)?.usage ?? null;
684
+ const parts: string[] = [];
685
+ let color: string | undefined;
686
+ const warnAt = (used: number, cap: number) => {
687
+ if (used / cap >= 0.8 && color !== BAD) color = WARN;
688
+ };
689
+ if (c.maxTurns !== null) {
690
+ let t = `max turns ${c.maxTurns}`;
691
+ if (u) {
692
+ t += ` (${u.turns} used)`;
693
+ warnAt(u.turns, c.maxTurns);
694
+ }
695
+ if (u?.fired.includes("turns")) t += " FIRED";
696
+ parts.push(t);
697
+ }
698
+ if (c.budgetUsd !== null) {
699
+ let t = `budget ${usd(c.budgetUsd)}`;
700
+ if (u) {
701
+ t += ` (${usd(u.spendUsd)} spent)`;
702
+ warnAt(u.spendUsd, c.budgetUsd);
703
+ }
704
+ if (u?.fired.includes("budget")) t += " FIRED";
705
+ parts.push(t);
706
+ }
707
+ if (c.stallTurns !== null) {
708
+ let t = `stall ${c.stallTurns} turns`;
709
+ if (u) {
710
+ t += ` (run ${u.stallRun})`;
711
+ warnAt(u.stallRun, c.stallTurns);
712
+ }
713
+ if (u?.fired.includes("stall")) t += " FIRED";
714
+ parts.push(t);
715
+ }
716
+ if (u && u.fired.length > 0) color = BAD;
717
+ return { text: parts.length > 0 ? parts.join(" · ") : "none set", color };
718
+ }
719
+
720
+ /** The verdict row: `aligned · turn 3 · <reason> · 12s ago`, or why there is none. */
721
+ function verdictDetail(g: MnemomGoal | null, now: number): string {
722
+ if (g === null || g.state === "loading") return "reading …";
723
+ if (g.state === "unavailable")
724
+ return `unavailable · ${g.reason} · tried ${ago(now, g.fetchedAt)}`;
725
+ if (g.verdict === null) return `no verdict yet · read ${ago(now, g.fetchedAt)}`;
726
+ const parts = [g.verdict.verdict];
727
+ if (g.verdict.turn !== null) parts.push(`turn ${g.verdict.turn}`);
728
+ if (g.verdict.reason) parts.push(oneLine(g.verdict.reason));
729
+ parts.push(`read ${ago(now, g.fetchedAt)}`);
730
+ return parts.join(" · ");
731
+ }
732
+
733
+ /**
734
+ * The goal rows of the pane and `/mnemom status`: goal, statement, requirements, paths, guardrails,
735
+ * verdict. Only the `goal` row when goal alignment is off.
736
+ */
737
+ function goalRows(
738
+ l: MnemomLaunch,
739
+ g: MnemomGoal | null,
740
+ now: number,
741
+ ): Array<[string, string, string | undefined]> {
742
+ const rows: Array<[string, string, string | undefined]> = [["goal", goalDetail(l, g), undefined]];
743
+ if (l.goalMode === "off") return rows;
744
+ const statement = goalStatement(l, g);
745
+ rows.push([
746
+ "statement",
747
+ statement !== null
748
+ ? oneLine(statement)
749
+ : l.goalMode === "implicit"
750
+ ? "(not inferred yet)"
751
+ : "(none)",
752
+ statement !== null ? TEAL : undefined,
753
+ ]);
754
+ const reqs = goalRequirements(l, g);
755
+ rows.push([
756
+ "requirements",
757
+ reqs.length > 0 ? `${reqs.length} · ${reqs.map(oneLine).join(" · ")}` : "none",
758
+ undefined,
759
+ ]);
760
+ rows.push(["paths", pathsDetail(goalPaths(l, g)), undefined]);
761
+ const guard = guardrailDetail(l, g);
762
+ rows.push(["guardrails", guard.text, guard.color]);
763
+ rows.push(["verdict", verdictDetail(g, now), verdictColor(liveGoal(g)?.verdict ?? null)]);
764
+ return rows;
765
+ }
766
+
767
+ /** `/mnemom goal`: the whole of it, every requirement and path on its own line. */
768
+ function goalText(l: MnemomLaunch, g: MnemomGoal | null, now: number): string {
769
+ const lines = [`${SIGN} Mnemom goal — ${goalDetail(l, g)}`, ""];
770
+ if (l.goalMode === "off") {
771
+ lines.push(
772
+ ' Goal alignment is off for this session. Launch with --goal "…" (plus --requirement, --allow,',
773
+ " --forbid, --max-turns, --budget, --stall) to seal a contract the gateway judges every turn",
774
+ " against, or with --implicit to have the gateway infer the goal from your messages.",
775
+ );
776
+ return lines.join("\n");
777
+ }
778
+ const statement = goalStatement(l, g);
779
+ lines.push(
780
+ ` statement: ${statement !== null ? oneLine(statement) : l.goalMode === "implicit" ? "(not inferred yet — it forms from your first messages)" : "(none)"}`,
781
+ );
782
+ const reqs = goalRequirements(l, g);
783
+ if (reqs.length === 0) lines.push(" requirements: none");
784
+ else
785
+ reqs.forEach((r, i) =>
786
+ lines.push(` ${i === 0 ? "requirements:" : " "} ${i + 1}. ${oneLine(r)}`),
787
+ );
788
+ const paths = goalPaths(l, g);
789
+ lines.push(` allowed paths: ${paths.allowed.length > 0 ? paths.allowed.join(", ") : "any"}`);
790
+ lines.push(
791
+ ` forbidden: ${paths.forbidden.length > 0 ? paths.forbidden.join(", ") : "none"}`,
792
+ );
793
+ lines.push(` guardrails: ${guardrailDetail(l, g).text}`);
794
+ lines.push(` verdict: ${verdictDetail(g, now)}`);
795
+ const live = liveGoal(g);
796
+ if (live?.hash) lines.push(` gateway hash: ${live.hash}`);
797
+ if (live?.turn !== null && live?.turn !== undefined) lines.push(` gateway turn: ${live.turn}`);
798
+ lines.push("");
799
+ if (l.goalMode === "sealed") {
800
+ lines.push(
801
+ " The contract is sealed: the gateway judges every turn against these words and never revises them.",
802
+ ` manage: mnemom agent show ${l.conversationId || "<conversation>"}`,
803
+ );
804
+ } else {
805
+ lines.push(
806
+ " The goal is implicit: the gateway infers it from your messages and revises it as you steer.",
807
+ ` manage: mnemom agent goal show|pin|reset ${l.conversationId || "<conversation>"}`,
808
+ );
809
+ }
810
+ return lines.join("\n");
811
+ }
812
+
813
+ type PaneDollar = {
814
+ ui: {
815
+ open: (pane: {
816
+ id: string;
817
+ title?: string;
818
+ rows?: number;
819
+ closeOnEscape?: true;
820
+ }) => Promise<unknown>;
821
+ close: (pane: { id: string }) => Promise<void>;
822
+ };
823
+ };
824
+
825
+ /** Open (or raise) the Mnemom status pane; taller when it has a goal to show. */
826
+ function openStatusPane($: PaneDollar, l: MnemomLaunch | null): Promise<unknown> {
827
+ const rows = l !== null && l.goalMode !== "off" ? 22 : 16;
828
+ return $.ui.open({ id: PANE, title: "Mnemom", rows, closeOnEscape: true });
829
+ }
830
+
831
+ function closeStatusPane($: PaneDollar): Promise<void> {
832
+ return $.ui.close({ id: PANE });
833
+ }
834
+
835
+ /** The band's goal word: `goal sealed 01234567 · aligned`, `goal implicit rev 2 · drifting`. */
836
+ function goalWord(l: MnemomLaunch, g: MnemomGoal | null = null): string {
837
+ const live = liveGoal(g);
838
+ let word: string;
839
+ if (l.goalMode === "sealed") {
840
+ word = `goal sealed${l.contractSha ? ` ${l.contractSha.slice(0, 8)}` : ""}`;
841
+ } else if (l.goalMode === "implicit") {
842
+ word = "goal implicit";
843
+ if (live?.revision) word += ` rev ${live.revision}`;
844
+ if (live?.pinned) word += " pinned";
845
+ } else {
846
+ return "goal off";
847
+ }
848
+ if (live?.verdict) word += ` · ${live.verdict.verdict}`;
849
+ return word;
850
+ }
851
+
852
+ function statusText(
853
+ l: MnemomLaunch | null,
854
+ r: MnemomReputation | null,
855
+ g: MnemomGoal | null,
856
+ turnCount: number,
857
+ now: number,
858
+ ): string {
859
+ if (l === null) {
860
+ return [
861
+ `${SIGN} Mnemom for Claude Code — NOT governed`,
862
+ "",
863
+ "This session was not launched by `mnemom agent`, so its model traffic is not routed",
864
+ "through the Mnemom gateway: no governed identity, no integrity record, no Trust Rating.",
865
+ "",
866
+ "Launch a governed session: mnemom agent <session-name>",
867
+ "Learn more: https://docs.mnemom.ai",
868
+ ].join("\n");
869
+ }
870
+ const lines = [
871
+ `${SIGN} Mnemom for Claude Code — governed`,
872
+ "",
873
+ ` agent: ${l.agent}`,
874
+ ` conversation: ${l.conversationId || "(unknown)"}`,
875
+ ` cell: ${l.cell}`,
876
+ ` gateway: ${l.gatewayHost}`,
877
+ ` shape: ${l.shape}`,
878
+ ` ${goalWord(l, g).replace(/^goal /, "goal: ")}`,
879
+ ];
880
+ for (const [label, value] of goalRows(l, g, now).slice(1)) {
881
+ lines.push(` ${`${label}:`.padEnd(15)}${value}`);
882
+ }
883
+ lines.push(
884
+ ` ${trustWord(r).replace(/^trust /, "trust rating: ")}`,
885
+ ` turns: ${turnCount}`,
886
+ );
887
+ if (l.session) lines.push(` session: ${l.session}`);
888
+ if (l.launcherVersion) lines.push(` launcher: mnemom ${l.launcherVersion}`);
889
+ lines.push(
890
+ "",
891
+ ` public page: https://www.mnemom.ai/agents/${encodeURIComponent(l.agent)}/reputation`,
892
+ );
893
+ lines.push(
894
+ " commands: /mnemom goal · /mnemom panel · /mnemom doctor · /mnemom verify · /mnemom hide|show",
895
+ );
896
+ return lines.join("\n");
897
+ }
898
+
899
+ const HELP = [
900
+ `${SIGN} /mnemom — Mnemom for Claude Code`,
901
+ "",
902
+ " /mnemom this session's governance status (same as `status`)",
903
+ " /mnemom goal the goal in full: statement, requirements, path fences, guardrails, verdict",
904
+ " /mnemom panel open the Mnemom status pane (the ▸ status mark on the band does the same)",
905
+ " /mnemom close close it",
906
+ " /mnemom doctor reach the gateway (/health) and the Trust Rating API, report both",
907
+ " /mnemom verify fetch the agent's reputation attestation (hash chain, Merkle root)",
908
+ " /mnemom hide hide the band above the prompt for this session",
909
+ " /mnemom show show it again",
910
+ "",
911
+ " Mnemom is the trust plane for the agentic internet — https://www.mnemom.ai",
912
+ ].join("\n");
913
+
914
+ async function refreshReputation(
915
+ $: StateDollar & {
916
+ http: { fetch: (url: string) => Promise<{ ok: boolean; status: number; text: string }> };
917
+ clock: { now: () => Promise<number> };
918
+ ui: { status: (text: string | undefined) => void };
919
+ },
920
+ l: MnemomLaunch,
921
+ ): Promise<MnemomReputation> {
922
+ const r = await fetchReputation($, l, await $.clock.now());
923
+ // `update` resolves once the band has been redrawn with the new value.
924
+ await update($, reputation, () => r);
925
+ return r;
926
+ }
927
+
928
+ /** Re-read the live goal state and land it on the atom (the band and the pane redraw). */
929
+ async function refreshGoal(
930
+ $: StateDollar & RunDollar & { clock: { now: () => Promise<number> } },
931
+ l: MnemomLaunch,
932
+ ): Promise<MnemomGoal> {
933
+ const g = await fetchGoal($, l, await $.clock.now());
934
+ await update($, goal, () => g);
935
+ return g;
936
+ }
937
+
938
+ export const register: Register = (on, rawOptions) => {
939
+ const options = readOptions(rawOptions);
940
+ let composeCache: { key: string; text: string } | null = null;
941
+
942
+ // ── session.start: read the launch facts, register /mnemom, start the intro ──
943
+ on("session.start", async ($, e, next) => {
944
+ const l = await readLaunch($);
945
+ await update($, launch, () => l);
946
+ await update($, reputation, () => (l ? { state: "loading" } : null));
947
+ await update($, goal, () => (l && l.goalMode !== "off" ? { state: "loading" } : null));
948
+
949
+ await $.command.register({
950
+ name: "mnemom",
951
+ description:
952
+ "Mnemom governance: status, the goal, status pane, doctor, verify, hide/show the band",
953
+ argumentHint: "[status|goal|panel|close|doctor|verify|hide|show|help]",
954
+ immediate: true,
955
+ });
956
+
957
+ // The intro: the header reveals itself on the band, holds, and the band drops to one row.
958
+ if (e.isInteractive && options.showBand) {
959
+ await update($, introFrame, () => 0);
960
+ const tick = $.clock.every(INTRO_FRAME_MS, () => {
961
+ update($, introFrame, (f) => (f < 0 ? f : f + 1))
962
+ .then((f) => {
963
+ if (f < 0 || f >= INTRO_FRAMES) {
964
+ tick.cancel();
965
+ return f < 0 ? undefined : update($, introFrame, () => -1).then(() => undefined);
966
+ }
967
+ return undefined;
968
+ })
969
+ .catch(() => tick.cancel());
970
+ });
971
+ }
972
+
973
+ if (l === null) {
974
+ if (e.isInteractive) {
975
+ $.ui.toast(
976
+ options.requireGovernance
977
+ ? `${SIGN} Mnemom: this session is not governed — tool calls are refused until it is launched with \`mnemom agent\`.`
978
+ : `${SIGN} Mnemom: this session is not routed through the Mnemom gateway. Launch with \`mnemom agent\` for a governed session.`,
979
+ { timeoutMs: 8000 },
980
+ );
981
+ }
982
+ return next(e);
983
+ }
984
+
985
+ // The Trust Rating lookup and the goal read run beside the session start, never in its way.
986
+ reputationInFlight = refreshReputation($, l).catch(() => undefined);
987
+ if (l.goalMode !== "off") goalInFlight = refreshGoal($, l).catch(() => undefined);
988
+ return next(e);
989
+ });
990
+
991
+ // ── /mnemom ──
992
+ on("command.run", { command: "mnemom" }, async ($, e) => {
993
+ // Before the first state read: a hook's reads are consistent from its first access on, so the
994
+ // rating the lookup lands must be in place before the launch is read.
995
+ await reputationInFlight;
996
+ await goalInFlight;
997
+ const l = await read($, launch);
998
+ const verb = (e.args ?? "").trim().split(/\s+/)[0]?.toLowerCase() ?? "";
999
+
1000
+ if (verb === "help" || verb === "-h" || verb === "--help") return { text: HELP };
1001
+
1002
+ if (verb === "goal" || verb === "contract") {
1003
+ if (l === null) return { text: statusText(null, null, null, await read($, turns), 0) };
1004
+ // A fresh read, so the verdict and the inferred goal are this moment's.
1005
+ const g = l.goalMode === "off" ? null : await refreshGoal($, l);
1006
+ return { text: goalText(l, g, await $.clock.now()) };
1007
+ }
1008
+
1009
+ if (verb === "panel" || verb === "pane" || verb === "open") {
1010
+ await openStatusPane($, l);
1011
+ return {
1012
+ text: `${SIGN} Mnemom status pane opened (Escape or its ✕ closes it; /mnemom close too).`,
1013
+ };
1014
+ }
1015
+ if (verb === "close") {
1016
+ await closeStatusPane($);
1017
+ return { text: `${SIGN} Mnemom status pane closed.` };
1018
+ }
1019
+
1020
+ if (verb === "hide" || verb === "show") {
1021
+ const hide = verb === "hide";
1022
+ await update($, isHidden, () => hide);
1023
+ return {
1024
+ text: hide
1025
+ ? `${SIGN} Mnemom band hidden for this session (/mnemom show brings it back).`
1026
+ : `${SIGN} Mnemom band shown.`,
1027
+ };
1028
+ }
1029
+
1030
+ if (verb === "doctor") {
1031
+ if (l === null) return { text: statusText(null, null, null, await read($, turns), 0) };
1032
+ const out = [`${SIGN} Mnemom doctor — ${l.cell}`, ""];
1033
+ try {
1034
+ const health = await $.http.fetch(`${l.gatewayHost}/health`);
1035
+ out.push(
1036
+ ` gateway ${l.gatewayHost}/health → ${health.ok ? "ok" : "FAIL"} (HTTP ${health.status})`,
1037
+ );
1038
+ } catch (err) {
1039
+ out.push(
1040
+ ` gateway ${l.gatewayHost}/health → unreachable (${err instanceof Error ? err.message : String(err)})`,
1041
+ );
1042
+ }
1043
+ const r = await refreshReputation($, l);
1044
+ out.push(` trust rating ${reputationUrl(l)} → ${trustWord(r).replace(/^trust /, "")}`);
1045
+ if (r.state === "error") out.push(` (${r.reason})`);
1046
+ const g = l.goalMode === "off" ? null : await refreshGoal($, l);
1047
+ if (g !== null)
1048
+ out.push(
1049
+ ` live goal (mnemom agent show --json) → ${verdictDetail(g, await $.clock.now())}`,
1050
+ );
1051
+ out.push(
1052
+ "",
1053
+ ` identity: agent ${l.agent}, conversation ${l.conversationId || "(unknown)"}, ${goalWord(l, g)}`,
1054
+ ` shape: ${l.shape}${l.launcherVersion ? ` · launcher mnemom ${l.launcherVersion}` : ""}`,
1055
+ );
1056
+ return { text: out.join("\n") };
1057
+ }
1058
+
1059
+ if (verb === "verify") {
1060
+ if (l === null) return { text: statusText(null, null, null, await read($, turns), 0) };
1061
+ const url = `${reputationUrl(l)}/verify`;
1062
+ try {
1063
+ const res = await $.http.fetch(url);
1064
+ if (res.status === 404) {
1065
+ return {
1066
+ text: `${SIGN} Mnemom verify — ${l.agent} has no published Trust Rating yet (HTTP 404 from ${url}); nothing to verify until the score publishes.`,
1067
+ };
1068
+ }
1069
+ if (!res.ok) return { text: `${SIGN} Mnemom verify — HTTP ${res.status} from ${url}` };
1070
+ const body = asRecord(res.text) ?? {};
1071
+ const lines = [`${SIGN} Mnemom verify — ${l.agent}`, "", ` ${url}`, ""];
1072
+ const chain = body.hash_chain_valid;
1073
+ if (typeof chain === "boolean") {
1074
+ lines.push(
1075
+ ` hash chain: ${chain ? "VALID — unbroken append-only checkpoint chain" : "INVALID"}`,
1076
+ );
1077
+ }
1078
+ for (const [label, key] of [
1079
+ ["merkle root: ", "merkle_root"],
1080
+ ["checkpoints: ", "checkpoint_count"],
1081
+ ["certificate: ", "certificate_url"],
1082
+ ["verified at: ", "verified_at"],
1083
+ ] as const) {
1084
+ const v = body[key];
1085
+ if (typeof v === "string" || typeof v === "number") lines.push(` ${label} ${v}`);
1086
+ }
1087
+ if (lines.length === 4) lines.push(res.text.slice(0, 1200));
1088
+ lines.push(
1089
+ "",
1090
+ " This is a chain-integrity attestation (the rating derives from an unbroken checkpoint",
1091
+ " chain), not an in-band signature check. Public keys: https://api.mnemom.ai/.well-known/jwks.json",
1092
+ );
1093
+ return { text: lines.join("\n") };
1094
+ } catch (err) {
1095
+ return {
1096
+ text: `${SIGN} Mnemom verify — ${url} unreachable (${err instanceof Error ? err.message : String(err)})`,
1097
+ };
1098
+ }
1099
+ }
1100
+
1101
+ // `status` and the bare command.
1102
+ const text = statusText(
1103
+ l,
1104
+ await read($, reputation),
1105
+ await read($, goal),
1106
+ await read($, turns),
1107
+ await $.clock.now(),
1108
+ );
1109
+ // Report what is known now, and refresh behind the reply for the next look.
1110
+ if (l !== null && verb === "status") {
1111
+ reputationInFlight = refreshReputation($, l).catch(() => undefined);
1112
+ if (l.goalMode !== "off") goalInFlight = refreshGoal($, l).catch(() => undefined);
1113
+ }
1114
+ return { text };
1115
+ });
1116
+
1117
+ // ── the band above the prompt ──
1118
+ // While the intro plays: the header revealing itself. After it: one row —
1119
+ // the sign, the identity, the Trust Rating and a `▸ status` mark that opens the status pane.
1120
+ // ctrl+x ctrl+a collapses the band; `/mnemom hide` hides it for the session.
1121
+ on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
1122
+ const l = await read($, launch);
1123
+ if (e.props.hasSurvey || !options.showBand || (await read($, isHidden))) return next(e);
1124
+ const { Box, Button, Text } = $.ui.resolve(e);
1125
+ const brand = l === null ? WARN : TEAL_BRIGHT;
1126
+
1127
+ const frame = await read($, introFrame);
1128
+ // The intro: the header — the glyph beside `Mnemom` and the CLI's version, the tagline
1129
+ // beneath — revealed left to right, held with a slow teal pulse, then the band drops to one
1130
+ // row. A band too short or too narrow for the header skips the intro.
1131
+ const version = l?.launcherVersion ?? "";
1132
+ const columns = headerColumns(version);
1133
+ if (frame >= 0 && e.props.maxRows >= HEADER_ROWS && e.props.bodyColumns >= columns) {
1134
+ const pulse = frame >= INTRO_REVEAL_FRAMES && (frame - INTRO_REVEAL_FRAMES) % 8 >= 4;
1135
+ const shown = Math.min(columns, Math.ceil(((frame + 1) * columns) / INTRO_REVEAL_FRAMES));
1136
+ return (
1137
+ <Box flexDirection="column">
1138
+ {header(pulse ? TEAL : brand, version).map((row, i) => (
1139
+ <Box flexDirection="row" key={`header-${i}`}>
1140
+ {cut(row, shown).map((s) => (
1141
+ <Text
1142
+ color={s.fg}
1143
+ backgroundColor={s.bg}
1144
+ dimColor={s.dim}
1145
+ bold={s.bold}
1146
+ inverse={s.inverse}
1147
+ >
1148
+ {s.t}
1149
+ </Text>
1150
+ ))}
1151
+ </Box>
1152
+ ))}
1153
+ </Box>
1154
+ );
1155
+ }
1156
+
1157
+ const open = (
1158
+ <Button
1159
+ key="status"
1160
+ label="▸ status"
1161
+ plain
1162
+ dimColor
1163
+ hotkey="s"
1164
+ onPress={() => openStatusPane($, l)}
1165
+ />
1166
+ );
1167
+
1168
+ if (l === null) {
1169
+ return (
1170
+ <Box flexDirection="row" gap={1}>
1171
+ <Text color={GOLD} bold>
1172
+ {SIGN}
1173
+ </Text>
1174
+ <Text color={TEAL_BRIGHT} bold>
1175
+ Mnemom
1176
+ </Text>
1177
+ <Text color={WARN} wrap="truncate-end">
1178
+ · not governed — launch with: mnemom agent &lt;session&gt;
1179
+ </Text>
1180
+ <Text dimColor>·</Text>
1181
+ {open}
1182
+ </Box>
1183
+ );
1184
+ }
1185
+
1186
+ const r = await read($, reputation);
1187
+ const g = await read($, goal);
1188
+ const narrow = e.props.bodyColumns < 100;
1189
+ const live = liveGoal(g);
1190
+ return (
1191
+ <Box flexDirection="row" gap={1}>
1192
+ <Text color={GOLD} bold>
1193
+ {SIGN}
1194
+ </Text>
1195
+ <Text color={TEAL_BRIGHT} bold>
1196
+ Mnemom
1197
+ </Text>
1198
+ <Text dimColor>· governed · {l.cell} ·</Text>
1199
+ <Text color={TEAL} wrap="truncate-end">
1200
+ {l.agent}
1201
+ </Text>
1202
+ {!narrow && (
1203
+ <Text
1204
+ dimColor={live?.verdict === null || live === null}
1205
+ color={verdictColor(live?.verdict ?? null)}
1206
+ >
1207
+ · {goalWord(l, g)}
1208
+ </Text>
1209
+ )}
1210
+ <Text dimColor>·</Text>
1211
+ <Text
1212
+ color={r !== null && r.state === "rated" ? gradeColor(r.grade) : undefined}
1213
+ dimColor={r === null || r.state !== "rated"}
1214
+ >
1215
+ {trustWord(r)}
1216
+ </Text>
1217
+ {!narrow && e.props.isWorking && <Text dimColor>· working</Text>}
1218
+ <Text dimColor>·</Text>
1219
+ {open}
1220
+ </Box>
1221
+ );
1222
+ });
1223
+
1224
+ // ── the status pane ──
1225
+ // Opened by the band's `▸ status` mark or `/mnemom panel`: the header and everything Mnemom
1226
+ // knows about this session. Redraws as the rating and the turn count change.
1227
+ on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
1228
+ const { Box, Button, Text } = $.ui.resolve(e);
1229
+ const l = await read($, launch);
1230
+ const r = l === null ? null : await read($, reputation);
1231
+ const g = l === null ? null : await read($, goal);
1232
+ const turnCount = await read($, turns);
1233
+ const now = await $.clock.now();
1234
+ const brand = l === null ? WARN : TEAL_BRIGHT;
1235
+ // The head is the intro's header; a pane too narrow for it shows the glyph alone.
1236
+ const version = l?.launcherVersion ?? "";
1237
+ // One column of margin inside the pane (the root's paddingLeft) comes off the width.
1238
+ const art =
1239
+ e.props.bodyColumns - 1 >= headerColumns(version) ? header(brand, version) : MARK_HEADER;
1240
+
1241
+ const rows: Array<[string, string, string | undefined]> =
1242
+ l === null
1243
+ ? [
1244
+ [
1245
+ "status",
1246
+ "NOT governed — this session's model traffic is not routed through Mnemom",
1247
+ WARN,
1248
+ ],
1249
+ ["why", "it was launched with plain `claude`, not `mnemom agent`", undefined],
1250
+ ["launch", 'mnemom agent <session> [--goal "…"]', TEAL],
1251
+ ["docs", "https://docs.mnemom.ai", undefined],
1252
+ ]
1253
+ : [
1254
+ ["status", `governed · ${l.cell}`, TEAL],
1255
+ ["gateway", l.gatewayHost, undefined],
1256
+ ["agent", l.agent, TEAL],
1257
+ ["conversation", l.conversationId || "(unknown)", undefined],
1258
+ ...goalRows(l, g, now),
1259
+ [
1260
+ "trust rating",
1261
+ trustDetail(r, now),
1262
+ r !== null && r.state === "rated" ? gradeColor(r.grade) : undefined,
1263
+ ],
1264
+ ["turns", String(turnCount), undefined],
1265
+ [
1266
+ "session",
1267
+ `${l.session || "(unnamed)"} · ${l.shape}${l.launcherVersion ? ` · mnemom ${l.launcherVersion}` : ""}`,
1268
+ undefined,
1269
+ ],
1270
+ [
1271
+ "public page",
1272
+ `https://www.mnemom.ai/agents/${encodeURIComponent(l.agent)}/reputation`,
1273
+ undefined,
1274
+ ],
1275
+ ];
1276
+
1277
+ return (
1278
+ <Box flexDirection="column" paddingTop={1} paddingLeft={1}>
1279
+ {art.map((row, i) => (
1280
+ <Box flexDirection="row" key={`header-${i}`}>
1281
+ {row.map((s) => (
1282
+ <Text
1283
+ color={s.fg}
1284
+ backgroundColor={s.bg}
1285
+ dimColor={s.dim}
1286
+ bold={s.bold}
1287
+ inverse={s.inverse}
1288
+ >
1289
+ {s.t}
1290
+ </Text>
1291
+ ))}
1292
+ </Box>
1293
+ ))}
1294
+ <Text> </Text>
1295
+ {rows.map(([label, value, color]) => (
1296
+ <Box flexDirection="row">
1297
+ <Text dimColor>{label.padEnd(14)}</Text>
1298
+ <Text color={color} wrap="truncate-end">
1299
+ {value}
1300
+ </Text>
1301
+ </Box>
1302
+ ))}
1303
+ <Text> </Text>
1304
+ <Box flexDirection="row" gap={3}>
1305
+ {l !== null && (
1306
+ <Button
1307
+ key="refresh"
1308
+ label="↻ refresh"
1309
+ plain
1310
+ dimColor
1311
+ hotkey="r"
1312
+ onPress={() => {
1313
+ reputationInFlight = refreshReputation($, l).catch(() => undefined);
1314
+ if (l.goalMode !== "off") goalInFlight = refreshGoal($, l).catch(() => undefined);
1315
+ }}
1316
+ />
1317
+ )}
1318
+ <Text dimColor>/mnemom goal · /mnemom doctor · /mnemom verify</Text>
1319
+ <Button
1320
+ key="close"
1321
+ label="✕ close"
1322
+ plain
1323
+ dimColor
1324
+ hotkey="q"
1325
+ role="dismiss"
1326
+ onPress={() => closeStatusPane($)}
1327
+ />
1328
+ </Box>
1329
+ </Box>
1330
+ );
1331
+ });
1332
+
1333
+ // ── the Mnemom sign on the spinner and the turn line (governed sessions only) ──
1334
+ on("ui.render", { component: "Spinner" }, async ($, e, next) => {
1335
+ if (!options.spinnerMark || (await read($, launch)) === null) return next(e);
1336
+ return next({ ...e, props: { ...e.props, suffix: `${e.props.suffix} ${SIGN}` } });
1337
+ });
1338
+
1339
+ on("ui.render", { component: "TurnDuration" }, async ($, e, next) => {
1340
+ const own = await next(e);
1341
+ if (!options.spinnerMark || (await read($, launch)) === null) return own;
1342
+ const { Box, Text } = $.ui.resolve(e);
1343
+ return (
1344
+ <Box flexDirection="row">
1345
+ {own}
1346
+ <Text dimColor> · governed by Mnemom</Text>
1347
+ </Box>
1348
+ );
1349
+ });
1350
+
1351
+ // ── tell the model what it is running as ──
1352
+ // Two carriers for one text. `prompt.context` adds a block to the conversation's first user
1353
+ // message, which a user-installed plugin may do. `prompt.compose` appends a system-prompt section,
1354
+ // which the built-in security plugin bypasses for the user tier: it takes effect only where an
1355
+ // administrator installs this plugin as a managed one.
1356
+ /** The goal as the launch stated it (static: the live verdict is the person's to read, not the model's). */
1357
+ const goalNote = (l: MnemomLaunch): string[] => {
1358
+ if (l.goalMode === "sealed" && l.contract !== null) {
1359
+ const c = l.contract;
1360
+ const out = [
1361
+ `- sealed goal (the gateway judges every turn against it): ${oneLine(c.statement)}`,
1362
+ ];
1363
+ if (c.requirements.length > 0)
1364
+ out.push(` requirements: ${c.requirements.map(oneLine).join("; ")}`);
1365
+ if (c.allowedPaths.length > 0) out.push(` allowed paths: ${c.allowedPaths.join(", ")}`);
1366
+ if (c.forbiddenPaths.length > 0)
1367
+ out.push(` forbidden paths: ${c.forbiddenPaths.join(", ")}`);
1368
+ const guard = guardrailDetail(l, null).text;
1369
+ if (guard !== "none set") out.push(` guardrails: ${guard}`);
1370
+ return out;
1371
+ }
1372
+ if (l.goalMode === "implicit") {
1373
+ const guard = guardrailDetail(l, null).text;
1374
+ return [
1375
+ "- implicit goal: the gateway infers the goal from the person's messages and judges each turn",
1376
+ ` against it; /mnemom goal shows the current inference.${guard !== "none set" ? ` guardrails: ${guard}.` : ""}`,
1377
+ ];
1378
+ }
1379
+ return [];
1380
+ };
1381
+
1382
+ const governanceText = (l: MnemomLaunch): string => {
1383
+ const key = `${l.agent}|${l.conversationId}|${l.cell}|${l.goalMode}|${l.contractSha}`;
1384
+ if (composeCache === null || composeCache.key !== key) {
1385
+ composeCache = {
1386
+ key,
1387
+ text: [
1388
+ "This Claude Code session runs as a Mnemom-governed agent. Mnemom (https://www.mnemom.ai) is the",
1389
+ "trust plane for the agentic internet: every model call in this session is routed through the",
1390
+ `Mnemom gateway (${l.cell}), recorded as integrity checkpoints and reflected in the agent's public`,
1391
+ "Trust Rating.",
1392
+ "",
1393
+ `- governed identity: ${l.agent}; conversation: ${l.conversationId || "(unknown)"}; ${goalWord(l)}.`,
1394
+ ...goalNote(l),
1395
+ "- The person can run /mnemom (status), /mnemom doctor and /mnemom verify at any time; the",
1396
+ " `about-mnemom` skill explains Mnemom, Trust Ratings and the gateway when they ask.",
1397
+ "- Never change or unset ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN or ANTHROPIC_CUSTOM_HEADERS in",
1398
+ " this session: that drops governance for the session, and the Mnemom plugin denies it.",
1399
+ ].join("\n"),
1400
+ };
1401
+ }
1402
+ return composeCache.text;
1403
+ };
1404
+
1405
+ on("prompt.context", async ($, e, next) => {
1406
+ const composed = await next(e);
1407
+ const l = await read($, launch);
1408
+ if (l === null) return composed;
1409
+ return {
1410
+ ...composed,
1411
+ blocks: [...composed.blocks, { name: "mnemomGovernance", text: governanceText(l) }],
1412
+ };
1413
+ });
1414
+
1415
+ on("prompt.compose", async ($, e, next) => {
1416
+ const composed = await next(e);
1417
+ const l = await read($, launch);
1418
+ if (l === null) return composed;
1419
+ return {
1420
+ sections: [
1421
+ ...composed.sections,
1422
+ {
1423
+ id: `${PLUGIN}:governance`,
1424
+ text: `# Mnemom governance\n\n${governanceText(l)}`,
1425
+ scope: "session",
1426
+ },
1427
+ ],
1428
+ };
1429
+ });
1430
+
1431
+ // ── commit attribution: who governed the work ──
1432
+ on("attribution.text", { kind: "commit" }, async ($, e, next) => {
1433
+ const composed = await next(e);
1434
+ const l = await read($, launch);
1435
+ if (!options.commitTrailer || l === null) return composed;
1436
+ const trailers = [`Mnemom-Agent: ${l.agent}`];
1437
+ if (l.conversationId) trailers.push(`Mnemom-Conversation: ${l.conversationId}`);
1438
+ const base = composed.text.trimEnd();
1439
+ return { text: base ? `${base}\n${trailers.join("\n")}` : trailers.join("\n") };
1440
+ });
1441
+
1442
+ // ── the guard ──
1443
+ on("tool.call", async ($, e, next) => {
1444
+ const l = await read($, launch);
1445
+ if (l === null) {
1446
+ if (options.requireGovernance) {
1447
+ return {
1448
+ deny: `${$.plugin.name}: this session is not routed through the Mnemom gateway (requireGovernance is on). Launch it with \`mnemom agent <session>\`, or turn the option off in /config.`,
1449
+ };
1450
+ }
1451
+ return next(e);
1452
+ }
1453
+ if (options.guardReroute && e.tool === "Bash" && REROUTE_RE.test(e.command)) {
1454
+ return {
1455
+ deny: `${$.plugin.name}: that command would change or unset the Mnemom gateway routing (ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_CUSTOM_HEADERS) for this governed session. Leave the routing in place; run an un-governed command in another terminal if you must.`,
1456
+ };
1457
+ }
1458
+ return next(e);
1459
+ });
1460
+
1461
+ // ── turns: count them, refresh the Trust Rating now and then ──
1462
+ on("turn.complete", async ($, e, next) => {
1463
+ const count = await update($, turns, (n) => n + 1);
1464
+ const l = await read($, launch);
1465
+ if (l !== null && count % REPUTATION_REFRESH_TURNS === 0)
1466
+ reputationInFlight = refreshReputation($, l).catch(() => undefined);
1467
+ // The turn's verdict (and, implicit, any goal revision) lands at the gateway as the response
1468
+ // closes; read it a moment later, beside the next prompt, never in its way.
1469
+ if (l !== null && l.goalMode !== "off") {
1470
+ $.clock.after(GOAL_REFRESH_DELAY_MS, () => {
1471
+ goalInFlight = refreshGoal($, l).catch(() => undefined);
1472
+ });
1473
+ }
1474
+ return next(e);
1475
+ });
1476
+ };