@bli-cockpit/memory-mcp 0.1.0 → 0.1.1

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,97 @@
1
+ /**
2
+ * `bli-memory-mcp hook stop` (BLI-3580).
3
+ *
4
+ * Stop fires when a turn finishes. This hook is the only one that WRITES: it
5
+ * takes the last user/assistant exchange out of the transcript, masks anything
6
+ * secret-like, and hands it to `POST /api/memory/save` in `extract` mode — so
7
+ * the librarian (extract → reconcile) decides what, if anything, in that turn
8
+ * is worth keeping. Saving the exchange verbatim on every Stop would fill a
9
+ * container with conversation, which is the dump this repo refuses.
10
+ *
11
+ * Two guards, both from the host's own contract:
12
+ *
13
+ * - `stop_hook_active: true` means Claude Code is running Stop as a result of
14
+ * a Stop hook's own continuation. Calling save again there is a loop, and
15
+ * the host publishes that flag precisely so a hook can refuse it. Exit 0,
16
+ * nothing saved, `reentrant`.
17
+ * - Nothing is printed on stdout, ever. Stop's stdout is shown to the person
18
+ * in transcript mode, and "I saved a memory" on every turn is noise.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { doorReason, postMemoryDoor } from "../door.js";
22
+ import { HOOK_BUDGETS } from "./contract.js";
23
+ import { maskSecretsForMemory } from "./redact.js";
24
+ import { readLastExchange } from "./transcript.js";
25
+ export async function runStopHook(context, options = {}) {
26
+ const budget = HOOK_BUDGETS.stop;
27
+ if (context.payload.stop_hook_active === true) {
28
+ return { status: "skipped", reason: "reentrant", stdout: "", saved: 0 };
29
+ }
30
+ const transcriptPath = (context.payload.transcript_path ?? "").trim();
31
+ if (transcriptPath.length === 0) {
32
+ return { status: "skipped", reason: "no_transcript_path", stdout: "", saved: 0 };
33
+ }
34
+ const read = readLastExchange({
35
+ path: transcriptPath,
36
+ ...(options.readTail ? { readTail: options.readTail } : {}),
37
+ });
38
+ if (!read.ok) {
39
+ // Not a failure of ours and not a success: a session whose transcript has
40
+ // no completed exchange yet (a slash command, a cancelled turn) is normal.
41
+ return { status: "skipped", reason: read.reason, stdout: "", saved: 0 };
42
+ }
43
+ const masked = maskSecretsForMemory(read.exchange.text);
44
+ if (!masked.ok) {
45
+ return {
46
+ status: "failed",
47
+ reason: masked.reason ?? "redaction_failed",
48
+ stdout: "",
49
+ saved: 0,
50
+ };
51
+ }
52
+ const response = await postMemoryDoor({
53
+ session: context.session,
54
+ fetchImpl: context.fetchImpl,
55
+ path: "/api/memory/save",
56
+ body: {
57
+ containerTag: context.container.containerTag,
58
+ content: masked.text,
59
+ // The librarian decides what is worth keeping, and reconcile skips what
60
+ // the container already knows.
61
+ mode: "extract",
62
+ // One row per session rather than one per turn, matching the vendor
63
+ // plugin's `customId: <session id>`.
64
+ ...(context.payload.session_id ? { customId: context.payload.session_id } : {}),
65
+ sourceRef: {
66
+ kind: "claude_code_session",
67
+ session_id: context.payload.session_id ?? null,
68
+ // The workspace, identified without naming it: a cwd carries a home
69
+ // directory, and a home directory names a person.
70
+ cwd_hash: hashCwd(context.cwd),
71
+ },
72
+ },
73
+ timeoutMs: budget.requestMs,
74
+ });
75
+ if (!response.ok) {
76
+ return { status: "failed", reason: doorReason(response), stdout: "", saved: 0 };
77
+ }
78
+ const decisions = response.body["decisions"];
79
+ const written = Array.isArray(decisions)
80
+ ? decisions.filter((decision) => decision &&
81
+ typeof decision === "object" &&
82
+ decision["verb"] !== "skip").length
83
+ : response.body["id"]
84
+ ? 1
85
+ : 0;
86
+ return {
87
+ status: written > 0 ? "ok" : "empty",
88
+ reason: written > 0 ? String(response.body["outcome"] ?? "saved") : "nothing_worth_keeping",
89
+ stdout: "",
90
+ saved: written,
91
+ chars: masked.text.length,
92
+ masked: masked.masked,
93
+ };
94
+ }
95
+ function hashCwd(cwd) {
96
+ return createHash("sha256").update(cwd, "utf8").digest("hex").slice(0, 16);
97
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The last exchange out of a Claude Code transcript (BLI-3580).
3
+ *
4
+ * The Stop hook is handed `transcript_path`: a JSONL file, one record per line,
5
+ * appended to for the whole session. What the save door wants is the LAST
6
+ * user/assistant exchange — the turn that just finished — and nothing else.
7
+ *
8
+ * Everything here is bounded, in both directions:
9
+ *
10
+ * - Only the **tail** of the file is read (`TAIL_BYTES`). A long session's
11
+ * transcript is tens of megabytes; reading it whole on every Stop would cost
12
+ * the hook its deadline and the machine its memory, to then throw away all
13
+ * but the last two records.
14
+ * - Only the last **12 KB of text** travels (`STOP_CHUNK_CHARS`), and the USER
15
+ * side is kept whole in preference to the assistant's — a person's question
16
+ * is what makes an assistant answer legible, and a truncated question is a
17
+ * memory nobody can place.
18
+ *
19
+ * A record shape that is not recognised is skipped, never guessed at. The
20
+ * reader returns null rather than half an exchange: half a turn saved as a
21
+ * memory is a memory that says something the person did not.
22
+ */
23
+ /** How much of the file's end is read. Big enough for a long final turn. */
24
+ export declare const TAIL_BYTES: number;
25
+ export interface LastExchange {
26
+ user: string;
27
+ assistant: string;
28
+ /** The chunk as it will be saved, already capped. */
29
+ text: string;
30
+ }
31
+ export interface ReadTranscriptOptions {
32
+ path: string;
33
+ tailBytes?: number;
34
+ maxChars?: number;
35
+ /** Injected in tests; reads the last `bytes` of `path`. */
36
+ readTail?: (path: string, bytes: number) => string;
37
+ }
38
+ export type ReadTranscriptResult = {
39
+ ok: true;
40
+ exchange: LastExchange;
41
+ } | {
42
+ ok: false;
43
+ reason: "transcript_unreadable" | "transcript_no_exchange";
44
+ };
45
+ export declare function readLastExchange(options: ReadTranscriptOptions): ReadTranscriptResult;
46
+ /**
47
+ * The user's turn whole, the assistant's trimmed to fit. Both labelled, because
48
+ * the extraction door reads this as a conversation chunk and needs to know who
49
+ * said which half.
50
+ */
51
+ export declare function capExchange(user: string, assistant: string, maxChars: number): string;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The last exchange out of a Claude Code transcript (BLI-3580).
3
+ *
4
+ * The Stop hook is handed `transcript_path`: a JSONL file, one record per line,
5
+ * appended to for the whole session. What the save door wants is the LAST
6
+ * user/assistant exchange — the turn that just finished — and nothing else.
7
+ *
8
+ * Everything here is bounded, in both directions:
9
+ *
10
+ * - Only the **tail** of the file is read (`TAIL_BYTES`). A long session's
11
+ * transcript is tens of megabytes; reading it whole on every Stop would cost
12
+ * the hook its deadline and the machine its memory, to then throw away all
13
+ * but the last two records.
14
+ * - Only the last **12 KB of text** travels (`STOP_CHUNK_CHARS`), and the USER
15
+ * side is kept whole in preference to the assistant's — a person's question
16
+ * is what makes an assistant answer legible, and a truncated question is a
17
+ * memory nobody can place.
18
+ *
19
+ * A record shape that is not recognised is skipped, never guessed at. The
20
+ * reader returns null rather than half an exchange: half a turn saved as a
21
+ * memory is a memory that says something the person did not.
22
+ */
23
+ import fs from "node:fs";
24
+ import { STOP_CHUNK_CHARS } from "./contract.js";
25
+ /** How much of the file's end is read. Big enough for a long final turn. */
26
+ export const TAIL_BYTES = 512 * 1024;
27
+ export function readLastExchange(options) {
28
+ let raw;
29
+ try {
30
+ raw = (options.readTail ?? readFileTail)(options.path, options.tailBytes ?? TAIL_BYTES);
31
+ }
32
+ catch {
33
+ return { ok: false, reason: "transcript_unreadable" };
34
+ }
35
+ const records = parseJsonlTail(raw);
36
+ // Walk backwards: the last assistant message, then the last user message
37
+ // BEFORE it. A user message after the assistant's is the next turn starting,
38
+ // which is not what just finished.
39
+ let assistantIndex = -1;
40
+ for (let index = records.length - 1; index >= 0; index -= 1) {
41
+ if (roleOf(records[index]) === "assistant" && textOf(records[index]).length > 0) {
42
+ assistantIndex = index;
43
+ break;
44
+ }
45
+ }
46
+ if (assistantIndex < 0)
47
+ return { ok: false, reason: "transcript_no_exchange" };
48
+ let userIndex = -1;
49
+ for (let index = assistantIndex - 1; index >= 0; index -= 1) {
50
+ if (roleOf(records[index]) === "user" && textOf(records[index]).length > 0) {
51
+ userIndex = index;
52
+ break;
53
+ }
54
+ }
55
+ if (userIndex < 0)
56
+ return { ok: false, reason: "transcript_no_exchange" };
57
+ const user = textOf(records[userIndex]);
58
+ const assistant = textOf(records[assistantIndex]);
59
+ return {
60
+ ok: true,
61
+ exchange: {
62
+ user,
63
+ assistant,
64
+ text: capExchange(user, assistant, options.maxChars ?? STOP_CHUNK_CHARS),
65
+ },
66
+ };
67
+ }
68
+ /**
69
+ * The user's turn whole, the assistant's trimmed to fit. Both labelled, because
70
+ * the extraction door reads this as a conversation chunk and needs to know who
71
+ * said which half.
72
+ */
73
+ export function capExchange(user, assistant, maxChars) {
74
+ const userBlock = `User: ${user.trim()}`;
75
+ const label = "\n\nAssistant: ";
76
+ const room = maxChars - userBlock.length - label.length;
77
+ if (room <= 0) {
78
+ // A single question longer than the whole budget: keep its END, which is
79
+ // where the actual ask usually is, and say it was cut.
80
+ return `User (truncated): …${user.trim().slice(-Math.max(maxChars - 24, 0))}`;
81
+ }
82
+ const assistantText = assistant.trim();
83
+ const kept = assistantText.length > room ? `${assistantText.slice(0, room - 1)}…` : assistantText;
84
+ return `${userBlock}${label}${kept}`;
85
+ }
86
+ function readFileTail(path, bytes) {
87
+ const handle = fs.openSync(path, "r");
88
+ try {
89
+ const size = fs.fstatSync(handle).size;
90
+ const length = Math.min(size, bytes);
91
+ const start = size - length;
92
+ const buffer = Buffer.alloc(length);
93
+ fs.readSync(handle, buffer, 0, length, start);
94
+ return buffer.toString("utf8");
95
+ }
96
+ finally {
97
+ fs.closeSync(handle);
98
+ }
99
+ }
100
+ /**
101
+ * Every whole line that parses. The FIRST line of a tail read is usually half a
102
+ * record — it is dropped by failing to parse, which is the correct outcome and
103
+ * needs no special case.
104
+ */
105
+ function parseJsonlTail(raw) {
106
+ const records = [];
107
+ for (const line of raw.split("\n")) {
108
+ const trimmed = line.trim();
109
+ if (trimmed.length === 0)
110
+ continue;
111
+ try {
112
+ const parsed = JSON.parse(trimmed);
113
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
114
+ records.push(parsed);
115
+ }
116
+ }
117
+ catch {
118
+ continue;
119
+ }
120
+ }
121
+ return records;
122
+ }
123
+ function roleOf(record) {
124
+ if (!record)
125
+ return "";
126
+ const message = record["message"];
127
+ if (message && typeof message === "object" && !Array.isArray(message)) {
128
+ const role = message["role"];
129
+ if (typeof role === "string")
130
+ return role;
131
+ }
132
+ const type = record["type"];
133
+ return typeof type === "string" ? type : "";
134
+ }
135
+ /**
136
+ * The text of a record, whichever of the three shapes it uses: a string
137
+ * `content`, an array of blocks with `type: "text"`, or a bare `text`. A tool
138
+ * result or an image block contributes nothing, on purpose — a memory made of
139
+ * tool output is the transcript dump this repo refuses.
140
+ */
141
+ function textOf(record) {
142
+ if (!record)
143
+ return "";
144
+ const message = record["message"];
145
+ const source = message && typeof message === "object" && !Array.isArray(message)
146
+ ? message
147
+ : record;
148
+ const content = source["content"] ?? source["text"];
149
+ if (typeof content === "string")
150
+ return content.trim();
151
+ if (!Array.isArray(content))
152
+ return "";
153
+ const parts = [];
154
+ for (const block of content) {
155
+ if (typeof block === "string") {
156
+ parts.push(block);
157
+ continue;
158
+ }
159
+ if (!block || typeof block !== "object")
160
+ continue;
161
+ const record_ = block;
162
+ if (record_["type"] !== "text")
163
+ continue;
164
+ const text = record_["text"];
165
+ if (typeof text === "string")
166
+ parts.push(text);
167
+ }
168
+ return parts.join("\n").trim();
169
+ }
package/dist/index.d.ts CHANGED
@@ -10,9 +10,18 @@
10
10
  * Usage:
11
11
  * bli-memory-mcp run the server over stdio
12
12
  * bli-memory-mcp --print-config both host blocks
13
- * bli-memory-mcp --print-config --claude the Claude Code JSON block
13
+ * bli-memory-mcp --print-config --claude the Claude Code install block
14
14
  * bli-memory-mcp --print-config --codex the Codex TOML block
15
15
  * bli-memory-mcp --print-config=claude the same, as one token
16
+ * bli-memory-mcp hook session-start the SessionStart hook
17
+ * bli-memory-mcp hook prompt the UserPromptSubmit hook
18
+ * bli-memory-mcp hook stop the Stop hook
19
+ *
20
+ * The three `hook` subcommands are what `cockpit memory install` registers with
21
+ * Claude Code. Each reads the host's JSON payload on stdin, prints either one
22
+ * context block or NOTHING, logs one metadata line to stderr, and **always
23
+ * exits 0** — see `hooks/contract.ts` for the budgets and why a hook may never
24
+ * block or fail loudly.
16
25
  *
17
26
  * Both spellings are accepted deliberately. `cockpit memory install`
18
27
  * (BLI-3580's installer slice) spawns this with separate flags; a person
package/dist/index.js CHANGED
@@ -10,9 +10,18 @@
10
10
  * Usage:
11
11
  * bli-memory-mcp run the server over stdio
12
12
  * bli-memory-mcp --print-config both host blocks
13
- * bli-memory-mcp --print-config --claude the Claude Code JSON block
13
+ * bli-memory-mcp --print-config --claude the Claude Code install block
14
14
  * bli-memory-mcp --print-config --codex the Codex TOML block
15
15
  * bli-memory-mcp --print-config=claude the same, as one token
16
+ * bli-memory-mcp hook session-start the SessionStart hook
17
+ * bli-memory-mcp hook prompt the UserPromptSubmit hook
18
+ * bli-memory-mcp hook stop the Stop hook
19
+ *
20
+ * The three `hook` subcommands are what `cockpit memory install` registers with
21
+ * Claude Code. Each reads the host's JSON payload on stdin, prints either one
22
+ * context block or NOTHING, logs one metadata line to stderr, and **always
23
+ * exits 0** — see `hooks/contract.ts` for the budgets and why a hook may never
24
+ * block or fail loudly.
16
25
  *
17
26
  * Both spellings are accepted deliberately. `cockpit memory install`
18
27
  * (BLI-3580's installer slice) spawns this with separate flags; a person
@@ -25,14 +34,30 @@
25
34
  * `BLI_MEMORY_DASHBOARD_URL`, `BLI_MEMORY_CONTAINER_TAG`,
26
35
  * `BLI_MEMORY_ISOLATE_WORKTREES=1`.
27
36
  */
28
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
29
37
  import { resolveContainerTag } from "./container-tag.js";
30
- import { parseFormat, printConfig } from "./print-config.js";
31
- import { createServer, SERVER_NAME } from "./server.js";
38
+ import { HOOK_EVENTS, hookEventFromArgv } from "./hooks/contract.js";
39
+ import { runHook } from "./hooks/run.js";
40
+ import { MEMORY_MCP_BIN, parseFormat, printConfig, SERVER_NAME, } from "./print-config.js";
32
41
  import { loadMemorySession } from "./session.js";
33
42
  const TAG = "[bli-memory-mcp]";
34
43
  async function main() {
35
44
  const argv = process.argv.slice(2);
45
+ // The hook path is checked FIRST and never falls through. Before this
46
+ // existed, `hook prompt` matched nothing here and started the stdio MCP
47
+ // server, which waits on stdin forever — so every registered hook on every
48
+ // machine hung until its host killed it (BLI-3580, observed on CLI 0.2.50).
49
+ if (argv[0] === "hook") {
50
+ const event = hookEventFromArgv(argv.slice(1));
51
+ if (!event) {
52
+ process.stderr.write(`${TAG} unknown hook event — one of ${HOOK_EVENTS.join(", ")}\n`);
53
+ // Still 0: a host reading a stale config must not see a hook error.
54
+ await exitQuietly();
55
+ return;
56
+ }
57
+ await runHook(event);
58
+ await exitQuietly();
59
+ return;
60
+ }
36
61
  const format = parseFormat(argv);
37
62
  if (format === "invalid") {
38
63
  process.stderr.write(`${TAG} unknown --print-config value — use claude, codex, or omit it for both\n`);
@@ -42,8 +67,11 @@ async function main() {
42
67
  // The one thing that goes to stdout besides the protocol: an installer
43
68
  // pipes this straight into a config file.
44
69
  process.stdout.write(`${printConfig(format, {
45
- command: "npx",
46
- args: ["-y", "@bli-cockpit/memory-mcp"],
70
+ // The BIN, never `npx`: an `npx -y` launch would re-resolve the package
71
+ // from the registry on every agent start, and `cockpit memory install`
72
+ // substitutes the absolute path it resolved on this machine.
73
+ command: MEMORY_MCP_BIN,
74
+ args: [],
47
75
  serverName: SERVER_NAME,
48
76
  })}\n`);
49
77
  return;
@@ -58,6 +86,15 @@ async function main() {
58
86
  // host, where the credential came from. Never the token, never a path.
59
87
  process.stderr.write(`${TAG} ready — container ${container.containerTag} (${container.basis}), ` +
60
88
  `dashboard ${session.session.dashboardUrl}, auth ${session.session.source}\n`);
89
+ // The MCP SDK is loaded HERE and nowhere else. A static import at the top of
90
+ // this file made every hook run — one per prompt, on every machine — load the
91
+ // whole SDK and its schemas before it could ask a single question, and it
92
+ // also made `--print-config` fail outright on an install whose SDK was
93
+ // missing. Only the server branch needs it.
94
+ const [{ StdioServerTransport }, { createServer }] = await Promise.all([
95
+ import("@modelcontextprotocol/sdk/server/stdio.js"),
96
+ import("./server.js"),
97
+ ]);
61
98
  const server = createServer({
62
99
  session: session.session,
63
100
  fetchImpl: globalThis.fetch,
@@ -65,6 +102,37 @@ async function main() {
65
102
  });
66
103
  await server.connect(new StdioServerTransport());
67
104
  }
105
+ /**
106
+ * Exit now, without waiting for anything still in flight.
107
+ *
108
+ * A hook that has printed its answer must give the host its process back: an
109
+ * in-flight `fetch` would otherwise hold the event loop until its own abort
110
+ * fires, and the host counts that against the hook's timeout. stdout is drained
111
+ * first — `process.exit` truncates a pending pipe write, and a truncated
112
+ * injection block is worse than no injection.
113
+ */
114
+ async function exitQuietly() {
115
+ process.stdin.pause();
116
+ await drain(process.stdout);
117
+ await drain(process.stderr);
118
+ process.exit(0);
119
+ }
120
+ function drain(stream) {
121
+ return new Promise((resolve) => {
122
+ if (stream.writableLength === 0) {
123
+ resolve();
124
+ return;
125
+ }
126
+ // A 250 ms ceiling: a blocked stdout may never drain, and hanging here
127
+ // would reintroduce exactly the hang this file exists to remove.
128
+ const timer = setTimeout(resolve, 250);
129
+ timer.unref?.();
130
+ stream.write("", () => {
131
+ clearTimeout(timer);
132
+ resolve();
133
+ });
134
+ });
135
+ }
68
136
  main().catch((error) => {
69
137
  process.stderr.write(`${TAG} fatal: ${error instanceof Error ? error.message : String(error)}\n`);
70
138
  process.exit(1);
@@ -2,18 +2,32 @@
2
2
  * `bli-memory-mcp --print-config` — the exact block each host wants
3
3
  * (BLI-3580).
4
4
  *
5
- * A third slice of this ticket builds `cockpit memory install`, which registers
6
- * this server on every machine, for Claude Code and for Codex. That installer
7
- * must not hand-assemble JSON or TOML from a template it maintains separately
8
- * that is how the two drift and how a fleet ends up half-registered. It reads
9
- * THESE strings verbatim, and so does a person doing it by hand.
5
+ * `cockpit memory install` registers this server on every machine, for Claude
6
+ * Code and for Codex. That installer must not hand-assemble JSON or TOML from a
7
+ * template it maintains separately that is how the two drift and how a fleet
8
+ * ends up half-registered. It reads THESE strings verbatim, and so does a
9
+ * person doing it by hand.
10
10
  *
11
11
  * Two hosts, two formats, one server:
12
12
  *
13
- * claude → the `mcpServers` entry for `~/.claude.json` (project or global) or
14
- * a plugin's `.mcp.json`. Claude Code speaks stdio here.
13
+ * claude → the INSTALLER CONTRACT, `bli-memory-install-config.v1`: the MCP
14
+ * server entry plus the three hooks and their timeouts, which is
15
+ * what `parsePrintedMemoryInstallConfig`
16
+ * (`collector/commands/memory-install-contract.ts`) reads. It is
17
+ * deliberately NOT Claude's raw `mcpServers` object: the hooks have
18
+ * to travel with the server entry, and this bin is the only thing
19
+ * that knows which hook subcommands it implements. Printing the raw
20
+ * `mcpServers` shape is what made every machine fall back to the
21
+ * installer's built-in template (`bin_present_template_used`) on
22
+ * 0.1.0.
15
23
  * codex → the `[mcp_servers.bli-memory]` block for `~/.codex/config.toml`.
16
24
  *
25
+ * `command` is the bare bin name in both. The installer substitutes the
26
+ * absolute path it resolved on that machine; a person copying the block by hand
27
+ * gets whatever is on their PATH. It is never `npx -y @bli-cockpit/memory-mcp`,
28
+ * which would re-resolve the package from the registry on every agent launch
29
+ * on every intern's machine.
30
+ *
17
31
  * The stdio contract, stated once because both installers depend on it:
18
32
  * **stdout is the MCP protocol channel and carries nothing else.** Every
19
33
  * operational line — the startup banner, an auth failure, a warning — goes to
@@ -29,6 +43,47 @@ export interface PrintConfigOptions {
29
43
  env?: Record<string, string>;
30
44
  }
31
45
  export type ConfigFormat = "claude" | "codex" | "both";
46
+ /** The published bin name. Both hosts spawn this; the installer path-qualifies it. */
47
+ export declare const MEMORY_MCP_BIN = "bli-memory-mcp";
48
+ /**
49
+ * The server id an MCP client registers, and the `mcp__<server>__<tool>` prefix.
50
+ *
51
+ * It lives HERE rather than in `server.ts` on purpose: `index.ts` needs it for
52
+ * `--print-config` and for the hook receipts, and `server.ts` pulls in the whole
53
+ * MCP SDK. Importing it from there made every hook run load the SDK before it
54
+ * could do anything, on the one hook a person is waiting for. `server.ts`
55
+ * re-exports it, so nothing that already imports it from there had to change.
56
+ */
57
+ export declare const SERVER_NAME = "bli-memory";
58
+ /** The installer contract's schema id, printed so a reader can pin it. */
59
+ export declare const INSTALL_CONFIG_SCHEMA = "bli-memory-install-config.v1";
60
+ /**
61
+ * The three Claude Code hooks, their subcommands and their HOST timeouts, in
62
+ * the order the installer writes them.
63
+ *
64
+ * A host timeout is not the hook's own deadline: each hook budgets itself well
65
+ * below its timeout and abandons the work rather than being killed
66
+ * (`hooks/contract.ts`). These numbers are the outer bound, and they mirror the
67
+ * vendor plugin's own budgets.
68
+ */
69
+ export declare const INSTALL_HOOKS: readonly [{
70
+ readonly event: "SessionStart";
71
+ readonly subcommand: "hook session-start";
72
+ readonly timeout_seconds: 30;
73
+ }, {
74
+ readonly event: "UserPromptSubmit";
75
+ readonly subcommand: "hook prompt";
76
+ readonly timeout_seconds: 5;
77
+ }, {
78
+ readonly event: "Stop";
79
+ readonly subcommand: "hook stop";
80
+ readonly timeout_seconds: 30;
81
+ }];
82
+ /**
83
+ * Auto-approved tools. READ ONLY, deliberately: a memory the agent can
84
+ * silently overwrite is worse than one it has to ask about.
85
+ */
86
+ export declare function autoApproveTools(serverName: string): string[];
32
87
  export declare function claudeConfigBlock(options: PrintConfigOptions): string;
33
88
  export declare function codexConfigBlock(options: PrintConfigOptions): string;
34
89
  export declare function printConfig(format: ConfigFormat, options: PrintConfigOptions): string;
@@ -2,32 +2,100 @@
2
2
  * `bli-memory-mcp --print-config` — the exact block each host wants
3
3
  * (BLI-3580).
4
4
  *
5
- * A third slice of this ticket builds `cockpit memory install`, which registers
6
- * this server on every machine, for Claude Code and for Codex. That installer
7
- * must not hand-assemble JSON or TOML from a template it maintains separately
8
- * that is how the two drift and how a fleet ends up half-registered. It reads
9
- * THESE strings verbatim, and so does a person doing it by hand.
5
+ * `cockpit memory install` registers this server on every machine, for Claude
6
+ * Code and for Codex. That installer must not hand-assemble JSON or TOML from a
7
+ * template it maintains separately that is how the two drift and how a fleet
8
+ * ends up half-registered. It reads THESE strings verbatim, and so does a
9
+ * person doing it by hand.
10
10
  *
11
11
  * Two hosts, two formats, one server:
12
12
  *
13
- * claude → the `mcpServers` entry for `~/.claude.json` (project or global) or
14
- * a plugin's `.mcp.json`. Claude Code speaks stdio here.
13
+ * claude → the INSTALLER CONTRACT, `bli-memory-install-config.v1`: the MCP
14
+ * server entry plus the three hooks and their timeouts, which is
15
+ * what `parsePrintedMemoryInstallConfig`
16
+ * (`collector/commands/memory-install-contract.ts`) reads. It is
17
+ * deliberately NOT Claude's raw `mcpServers` object: the hooks have
18
+ * to travel with the server entry, and this bin is the only thing
19
+ * that knows which hook subcommands it implements. Printing the raw
20
+ * `mcpServers` shape is what made every machine fall back to the
21
+ * installer's built-in template (`bin_present_template_used`) on
22
+ * 0.1.0.
15
23
  * codex → the `[mcp_servers.bli-memory]` block for `~/.codex/config.toml`.
16
24
  *
25
+ * `command` is the bare bin name in both. The installer substitutes the
26
+ * absolute path it resolved on that machine; a person copying the block by hand
27
+ * gets whatever is on their PATH. It is never `npx -y @bli-cockpit/memory-mcp`,
28
+ * which would re-resolve the package from the registry on every agent launch
29
+ * on every intern's machine.
30
+ *
17
31
  * The stdio contract, stated once because both installers depend on it:
18
32
  * **stdout is the MCP protocol channel and carries nothing else.** Every
19
33
  * operational line — the startup banner, an auth failure, a warning — goes to
20
34
  * stderr. A single stray `console.log` here corrupts the JSON-RPC stream and
21
35
  * the host reports a server that "does not work" with no reason attached.
22
36
  */
37
+ /** The published bin name. Both hosts spawn this; the installer path-qualifies it. */
38
+ export const MEMORY_MCP_BIN = "bli-memory-mcp";
39
+ /**
40
+ * The server id an MCP client registers, and the `mcp__<server>__<tool>` prefix.
41
+ *
42
+ * It lives HERE rather than in `server.ts` on purpose: `index.ts` needs it for
43
+ * `--print-config` and for the hook receipts, and `server.ts` pulls in the whole
44
+ * MCP SDK. Importing it from there made every hook run load the SDK before it
45
+ * could do anything, on the one hook a person is waiting for. `server.ts`
46
+ * re-exports it, so nothing that already imports it from there had to change.
47
+ */
48
+ export const SERVER_NAME = "bli-memory";
49
+ /** The installer contract's schema id, printed so a reader can pin it. */
50
+ export const INSTALL_CONFIG_SCHEMA = "bli-memory-install-config.v1";
51
+ /**
52
+ * The three Claude Code hooks, their subcommands and their HOST timeouts, in
53
+ * the order the installer writes them.
54
+ *
55
+ * A host timeout is not the hook's own deadline: each hook budgets itself well
56
+ * below its timeout and abandons the work rather than being killed
57
+ * (`hooks/contract.ts`). These numbers are the outer bound, and they mirror the
58
+ * vendor plugin's own budgets.
59
+ */
60
+ export const INSTALL_HOOKS = [
61
+ { event: "SessionStart", subcommand: "hook session-start", timeout_seconds: 30 },
62
+ { event: "UserPromptSubmit", subcommand: "hook prompt", timeout_seconds: 5 },
63
+ { event: "Stop", subcommand: "hook stop", timeout_seconds: 30 },
64
+ ];
65
+ /**
66
+ * Auto-approved tools. READ ONLY, deliberately: a memory the agent can
67
+ * silently overwrite is worse than one it has to ask about.
68
+ */
69
+ export function autoApproveTools(serverName) {
70
+ return [`mcp__${serverName}__search_memory`];
71
+ }
23
72
  export function claudeConfigBlock(options) {
24
73
  const entry = {
25
74
  command: options.command,
26
75
  args: options.args,
76
+ env: options.env ?? {},
27
77
  };
28
- if (options.env && Object.keys(options.env).length > 0)
29
- entry.env = options.env;
30
- return JSON.stringify({ mcpServers: { [options.serverName]: entry } }, null, 2);
78
+ return JSON.stringify({
79
+ schema: INSTALL_CONFIG_SCHEMA,
80
+ server_id: options.serverName,
81
+ mcp_server: entry,
82
+ hooks: INSTALL_HOOKS.map((hook) => ({
83
+ event: hook.event,
84
+ command: `${quoteCommandPath(options.command)} ${hook.subcommand}`,
85
+ timeout_seconds: hook.timeout_seconds,
86
+ })),
87
+ permissions_allow: autoApproveTools(options.serverName),
88
+ }, null, 2);
89
+ }
90
+ /**
91
+ * A Claude Code hook `command` is a shell string by the platform's design, so a
92
+ * path with a space in it is quoted. The installer refuses a path that could
93
+ * not be quoted safely rather than escaping it cleverly (`isUnsafeBinPath`),
94
+ * and it re-quotes whatever it substitutes — this is for the person who copies
95
+ * the printed block by hand.
96
+ */
97
+ function quoteCommandPath(command) {
98
+ return /\s/u.test(command) ? `"${command}"` : command;
31
99
  }
32
100
  export function codexConfigBlock(options) {
33
101
  const lines = [
@@ -49,7 +117,9 @@ export function printConfig(format, options) {
49
117
  if (format === "codex")
50
118
  return codexConfigBlock(options);
51
119
  return [
52
- "# Claude Code — merge into ~/.claude.json (or a plugin's .mcp.json)",
120
+ "# Claude Code — the install contract `cockpit memory install` consumes.",
121
+ "# By hand: mcp_server goes under `mcpServers.bli-memory` in ~/.claude.json,",
122
+ "# and each hook under `hooks.<event>` in ~/.claude/settings.json.",
53
123
  claudeConfigBlock(options),
54
124
  "",
55
125
  "# Codex — append to ~/.codex/config.toml",