@bli-cockpit/memory-mcp 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -6
- package/dist/container-tag.d.ts +15 -0
- package/dist/container-tag.js +5 -3
- package/dist/door.d.ts +46 -0
- package/dist/door.js +99 -0
- package/dist/hooks/contract.d.ts +122 -0
- package/dist/hooks/contract.js +72 -0
- package/dist/hooks/prompt.d.ts +20 -0
- package/dist/hooks/prompt.js +111 -0
- package/dist/hooks/redact.d.ts +28 -0
- package/dist/hooks/redact.js +36 -0
- package/dist/hooks/render.d.ts +21 -0
- package/dist/hooks/render.js +68 -0
- package/dist/hooks/run-context.d.ts +20 -0
- package/dist/hooks/run-context.js +8 -0
- package/dist/hooks/run.d.ts +56 -0
- package/dist/hooks/run.js +140 -0
- package/dist/hooks/session-start.d.ts +17 -0
- package/dist/hooks/session-start.js +63 -0
- package/dist/hooks/stdin.d.ts +39 -0
- package/dist/hooks/stdin.js +95 -0
- package/dist/hooks/stop.d.ts +27 -0
- package/dist/hooks/stop.js +97 -0
- package/dist/hooks/transcript.d.ts +51 -0
- package/dist/hooks/transcript.js +169 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +74 -6
- package/dist/print-config.d.ts +62 -7
- package/dist/print-config.js +81 -11
- package/dist/server.d.ts +9 -4
- package/dist/server.js +32 -40
- package/package.json +6 -2
|
@@ -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
|
|
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
|
|
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 {
|
|
31
|
-
import {
|
|
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
|
-
|
|
46
|
-
|
|
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);
|
package/dist/print-config.d.ts
CHANGED
|
@@ -2,18 +2,32 @@
|
|
|
2
2
|
* `bli-memory-mcp --print-config` — the exact block each host wants
|
|
3
3
|
* (BLI-3580).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
14
|
-
*
|
|
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;
|
package/dist/print-config.js
CHANGED
|
@@ -2,32 +2,100 @@
|
|
|
2
2
|
* `bli-memory-mcp --print-config` — the exact block each host wants
|
|
3
3
|
* (BLI-3580).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
14
|
-
*
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 —
|
|
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",
|