@bli-cockpit/memory-mcp 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -96,9 +96,19 @@ every machine:
96
96
  - **Print nothing, or one whole block.** No "no memories found" line — the model
97
97
  would reason about it, and an empty record and an unreachable store would look
98
98
  the same to it. Zero hits is data and prints nothing.
99
- - **One stderr receipt per run**, on every branch:
100
- `[bli-memory hook] prompt {"status":"empty","reason":"no_hits","hits":0,"elapsed_ms":140}`.
101
- Counts and reason labels only — never a prompt, a memory, a path or a token.
99
+ - **One stderr receipt per run**, on every branch, one of three shapes
100
+ (BLI-3664, `hooks/log-line.ts`):
101
+ `[bli-memory] prompt ok {"hits":0,"elapsed_ms":140,"deadline_ms":3000}`,
102
+ `[bli-memory] prompt skipped {"reason":"no_prompt"}`, or
103
+ `[bli-memory] prompt failed {"reason":"dashboard_unreachable","elapsed_ms":2201,"deadline_ms":3000}`.
104
+ `reason` on `skipped`/`failed` is always one of a closed set — `timeout`,
105
+ `http_<status>`, `unpaired`, `dashboard_unreachable`, `bad_response`,
106
+ `no_prompt`, plus three Stop-only local skips (`reentrant`, `no_transcript`,
107
+ `redaction_failed`) — never a stack trace, never Tower's own error text,
108
+ never a prompt, a memory, a path or a token. A `failed` outcome also gets a
109
+ one-line `systemMessage` (the same JSON-on-stdout shape the Supermemory
110
+ plugin's own banner uses), because Claude Code does not surface a hook's
111
+ stderr anywhere a person reads it.
102
112
 
103
113
  `hook stop` sends the last user/assistant exchange (at most 12 KB, tail-read so
104
114
  a 40 MB transcript costs nothing) through the collector's own redactor —
package/dist/door.d.ts CHANGED
@@ -31,6 +31,13 @@ export interface DoorResponse {
31
31
  body: Record<string, unknown>;
32
32
  /** Set when the request never produced a JSON answer at all. */
33
33
  transportError: string | null;
34
+ /**
35
+ * The CLOSED classification of `transportError`, `null` when there was none.
36
+ * `doorReason` reads this — never the free-text message and never
37
+ * `body.error` — so a hook's stderr line can never carry provider-controlled
38
+ * or exception-message text (BLI-3664).
39
+ */
40
+ transportKind: "timeout" | "network" | "malformed" | null;
34
41
  }
35
42
  export interface DoorRequest {
36
43
  session: MemorySession;
@@ -42,5 +49,18 @@ export interface DoorRequest {
42
49
  timeoutMs?: number;
43
50
  }
44
51
  export declare function postMemoryDoor(request: DoorRequest): Promise<DoorResponse>;
45
- /** The reason label for a failed door call: the refusal's own, or `transport`. */
46
- export declare function doorReason(response: DoorResponse): string;
52
+ /**
53
+ * The CLOSED reason label a hook's stderr line may print for a failed door
54
+ * call (BLI-3664). Deliberately never `response.body["error"]` or a raw
55
+ * exception message — both are provider/dashboard-controlled free text, and a
56
+ * hook's log line is metadata-only. Four members, closed:
57
+ *
58
+ * - `timeout` — the request itself was aborted on our own budget.
59
+ * - `dashboard_unreachable` — a transport failure that was NOT a timeout
60
+ * (DNS, TLS, connection refused).
61
+ * - `bad_response` — Tower answered with a status but a body that was not
62
+ * readable JSON.
63
+ * - `http_<status>` — Tower gave a normal HTTP answer that was not 2xx.
64
+ */
65
+ export type DoorFailureReason = "timeout" | "dashboard_unreachable" | "bad_response" | `http_${number}`;
66
+ export declare function doorReason(response: DoorResponse): DoorFailureReason;
package/dist/door.js CHANGED
@@ -49,11 +49,13 @@ export async function postMemoryDoor(request) {
49
49
  });
50
50
  }
51
51
  catch (error) {
52
+ const timedOut = isTimeoutError(error);
52
53
  return {
53
54
  ok: false,
54
55
  status: 0,
55
56
  body: {},
56
- transportError: describeTransportError(error, request.timeoutMs),
57
+ transportError: describeTransportError(error, request.timeoutMs, timedOut),
58
+ transportKind: timedOut ? "timeout" : "network",
57
59
  };
58
60
  }
59
61
  finally {
@@ -71,29 +73,41 @@ export async function postMemoryDoor(request) {
71
73
  status: response.status,
72
74
  body: {},
73
75
  transportError: `Tower answered ${response.status} with a body this server could not read as JSON.`,
76
+ transportKind: "malformed",
74
77
  };
75
78
  }
76
- return { ok: response.ok, status: response.status, body: parsed, transportError: null };
79
+ return {
80
+ ok: response.ok,
81
+ status: response.status,
82
+ body: parsed,
83
+ transportError: null,
84
+ transportKind: null,
85
+ };
86
+ }
87
+ function isTimeoutError(error) {
88
+ const name = error?.name;
89
+ return name === "AbortError" || name === "TimeoutError";
77
90
  }
78
91
  /**
79
92
  * An abort reads as a timeout with its budget named, because "The operation was
80
- * aborted" tells an operator nothing about which limit they hit.
93
+ * aborted" tells an operator nothing about which limit they hit. This message
94
+ * is for a human reading `server.ts`'s MCP tool error text, never for a hook's
95
+ * stderr line — `doorReason` below never reads it.
81
96
  */
82
- function describeTransportError(error, timeoutMs) {
83
- const name = error?.name;
84
- if (name === "AbortError" || name === "TimeoutError") {
97
+ function describeTransportError(error, timeoutMs, timedOut) {
98
+ if (timedOut) {
85
99
  return timeoutMs
86
100
  ? `Tower did not answer within ${timeoutMs} ms; the request was abandoned.`
87
101
  : "The request was aborted before Tower answered.";
88
102
  }
89
103
  return error instanceof Error ? (error.message.split("\n")[0] ?? "unknown") : String(error);
90
104
  }
91
- /** The reason label for a failed door call: the refusal's own, or `transport`. */
92
105
  export function doorReason(response) {
93
- if (response.transportError)
94
- return "transport_failed";
95
- const label = response.body["error"];
96
- if (typeof label === "string" && label.trim())
97
- return label.trim();
106
+ if (response.transportKind === "timeout")
107
+ return "timeout";
108
+ if (response.transportKind === "network")
109
+ return "dashboard_unreachable";
110
+ if (response.transportKind === "malformed")
111
+ return "bad_response";
98
112
  return `http_${response.status}`;
99
113
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The one line every hook run prints to stderr, and the one sentence Claude
3
+ * Code shows a person on `failed` (BLI-3664).
4
+ *
5
+ * Before this file, `run.ts` printed `{status, reason, ...}` where `reason`
6
+ * was whatever the hook body happened to produce — including, for a door
7
+ * failure, `doorReason(response)`'s old behaviour of echoing
8
+ * `response.body["error"]` straight from Tower. Across 580 real transcripts
9
+ * that line was never actually read (`docs/reports/supermemory-reachability-
10
+ * 2026-09-05.md` §6): stderr from a Claude Code hook is not captured in the
11
+ * transcript at all, so a BLI Memory outage and a quiet turn with nothing to
12
+ * recall were indistinguishable to anyone who was not tailing this process's
13
+ * stderr live. The fix is two mechanisms, not one:
14
+ *
15
+ * 1. **stderr, on every branch, one line, one shape.** `buildLogLine` below.
16
+ * This is what an operator tailing a launchd-style log — or a future
17
+ * collector upload of hook logs — can grep.
18
+ * 2. **`systemMessage`, on `failed` only.** Claude Code does not surface a
19
+ * hook's stderr as a visible line; the Supermemory plugin's own banner
20
+ * (`~/.claude/plugins/.../recall-directive.js`) is a JSON object on
21
+ * STDOUT carrying a `systemMessage` field (`hooks/stdin.js:writeOutput`,
22
+ * `console.log(JSON.stringify(data))`). `buildSystemMessage` produces the
23
+ * same shape so a real failure — not a quiet turn — is the one case a
24
+ * person actually sees something.
25
+ *
26
+ * **The reason vocabulary is closed and lives HERE, in one module.** Six
27
+ * members answer the ticket's own question (can a timeout, a 5xx, a dead
28
+ * dashboard and "nothing to say" be told apart?): `timeout`, `http_<status>`,
29
+ * `unpaired`, `dashboard_unreachable`, `bad_response`, `no_prompt`. Three more
30
+ * cover skip states that are local to one hook and never touch the network —
31
+ * `reentrant` (Stop, re-entrant), `no_transcript` (Stop, nothing to read yet)
32
+ * and `redaction_failed` (Stop, the masking pass itself failed) — kept as
33
+ * their own fixed literals rather than folded into a network bucket, because
34
+ * calling a re-entrant Stop hook `bad_response` would tell an operator a
35
+ * request failed when nothing was even attempted. Every one of the nine is a
36
+ * compile-time literal from OUR OWN code; none of them is `response.body`
37
+ * text, an `error.message`, or a stack trace (`door.ts`'s `doorReason` already
38
+ * enforces that at the source for the network four). `toLogReason` maps every
39
+ * other internal reason string this package can produce onto one of the nine,
40
+ * and anything it does not recognise — a future reason nobody wired in here —
41
+ * falls back to `bad_response` rather than printing unbounded text.
42
+ */
43
+ import type { HookEvent, HookOutcome, HookStatus } from "./contract.js";
44
+ import type { DoorFailureReason } from "../door.js";
45
+ export declare const HOOK_LOG_TAG = "[bli-memory]";
46
+ /** The closed set. Nine literals plus the parameterised `http_<status>` family. */
47
+ export type HookLogReason = DoorFailureReason | "unpaired" | "no_prompt" | "reentrant" | "no_transcript" | "redaction_failed";
48
+ /** The word printed after the event name. `empty` (ran fine, nothing to say) prints as `ok`. */
49
+ export type HookLogWord = "ok" | "skipped" | "failed";
50
+ export declare function logWordFor(status: HookStatus): HookLogWord;
51
+ /**
52
+ * Normalises any reason string this package can produce into the closed set.
53
+ * A door failure already arrives normalised (`door.ts`'s `doorReason`), so
54
+ * this is mostly a lookup table for the handful of purely local reasons —
55
+ * plus a safe, named default for anything this table has not been taught.
56
+ */
57
+ export declare function toLogReason(reason: string): HookLogReason;
58
+ /**
59
+ * The one stderr line for one hook run. Exact shape per status:
60
+ *
61
+ * [bli-memory] <hook> ok {hits?, saved?, chars?, masked?, elapsed_ms, deadline_ms}
62
+ * [bli-memory] <hook> skipped {reason}
63
+ * [bli-memory] <hook> failed {reason, elapsed_ms, deadline_ms}
64
+ *
65
+ * Metadata only — counts and a closed reason label, never a memory's text,
66
+ * never a path, never the device token.
67
+ */
68
+ export declare function buildLogLine(event: HookEvent, outcome: HookOutcome, elapsedMs: number, deadlineMs: number): string;
69
+ /**
70
+ * One short sentence for a `failed` outcome only, in the JSON envelope the
71
+ * host actually renders (`{"systemMessage": "…"}` on stdout — see the
72
+ * Supermemory mechanism cited in this file's header). `null` on every other
73
+ * branch: `ok`/`empty` already say their piece through the injected block,
74
+ * and `skipped` is a deliberate no-op nobody needs interrupted for.
75
+ */
76
+ export declare function buildSystemMessage(outcome: HookOutcome): string | null;
77
+ /** Renders one JSON block to stdout — the only shape a hook may emit there
78
+ * besides plain injected context. Used by `run.ts` on `failed` only, so the
79
+ * "one block or nothing" stdout contract for every other branch is untouched. */
80
+ export declare function renderSystemMessageBlock(message: string): string;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The one line every hook run prints to stderr, and the one sentence Claude
3
+ * Code shows a person on `failed` (BLI-3664).
4
+ *
5
+ * Before this file, `run.ts` printed `{status, reason, ...}` where `reason`
6
+ * was whatever the hook body happened to produce — including, for a door
7
+ * failure, `doorReason(response)`'s old behaviour of echoing
8
+ * `response.body["error"]` straight from Tower. Across 580 real transcripts
9
+ * that line was never actually read (`docs/reports/supermemory-reachability-
10
+ * 2026-09-05.md` §6): stderr from a Claude Code hook is not captured in the
11
+ * transcript at all, so a BLI Memory outage and a quiet turn with nothing to
12
+ * recall were indistinguishable to anyone who was not tailing this process's
13
+ * stderr live. The fix is two mechanisms, not one:
14
+ *
15
+ * 1. **stderr, on every branch, one line, one shape.** `buildLogLine` below.
16
+ * This is what an operator tailing a launchd-style log — or a future
17
+ * collector upload of hook logs — can grep.
18
+ * 2. **`systemMessage`, on `failed` only.** Claude Code does not surface a
19
+ * hook's stderr as a visible line; the Supermemory plugin's own banner
20
+ * (`~/.claude/plugins/.../recall-directive.js`) is a JSON object on
21
+ * STDOUT carrying a `systemMessage` field (`hooks/stdin.js:writeOutput`,
22
+ * `console.log(JSON.stringify(data))`). `buildSystemMessage` produces the
23
+ * same shape so a real failure — not a quiet turn — is the one case a
24
+ * person actually sees something.
25
+ *
26
+ * **The reason vocabulary is closed and lives HERE, in one module.** Six
27
+ * members answer the ticket's own question (can a timeout, a 5xx, a dead
28
+ * dashboard and "nothing to say" be told apart?): `timeout`, `http_<status>`,
29
+ * `unpaired`, `dashboard_unreachable`, `bad_response`, `no_prompt`. Three more
30
+ * cover skip states that are local to one hook and never touch the network —
31
+ * `reentrant` (Stop, re-entrant), `no_transcript` (Stop, nothing to read yet)
32
+ * and `redaction_failed` (Stop, the masking pass itself failed) — kept as
33
+ * their own fixed literals rather than folded into a network bucket, because
34
+ * calling a re-entrant Stop hook `bad_response` would tell an operator a
35
+ * request failed when nothing was even attempted. Every one of the nine is a
36
+ * compile-time literal from OUR OWN code; none of them is `response.body`
37
+ * text, an `error.message`, or a stack trace (`door.ts`'s `doorReason` already
38
+ * enforces that at the source for the network four). `toLogReason` maps every
39
+ * other internal reason string this package can produce onto one of the nine,
40
+ * and anything it does not recognise — a future reason nobody wired in here —
41
+ * falls back to `bad_response` rather than printing unbounded text.
42
+ */
43
+ export const HOOK_LOG_TAG = "[bli-memory]";
44
+ export function logWordFor(status) {
45
+ if (status === "skipped")
46
+ return "skipped";
47
+ if (status === "failed")
48
+ return "failed";
49
+ return "ok"; // "ok" and "empty" both read as "ok" — empty is data, not a failure.
50
+ }
51
+ /**
52
+ * Normalises any reason string this package can produce into the closed set.
53
+ * A door failure already arrives normalised (`door.ts`'s `doorReason`), so
54
+ * this is mostly a lookup table for the handful of purely local reasons —
55
+ * plus a safe, named default for anything this table has not been taught.
56
+ */
57
+ export function toLogReason(reason) {
58
+ switch (reason) {
59
+ // Already-closed door failures (door.ts), including any `http_<status>`.
60
+ case "timeout":
61
+ case "dashboard_unreachable":
62
+ case "bad_response":
63
+ return reason;
64
+ // Pairing / credential (session.ts's `SessionFailure`).
65
+ case "session_file_missing":
66
+ case "session_file_unreadable":
67
+ case "session_file_invalid":
68
+ case "session_missing_token":
69
+ return "unpaired";
70
+ // The host's own payload could not be used (stdin.ts's `StdinFailure`).
71
+ case "no_stdin":
72
+ case "stdin_empty":
73
+ case "stdin_timeout":
74
+ case "stdin_too_large":
75
+ case "stdin_unreadable":
76
+ case "stdin_malformed":
77
+ return "bad_response";
78
+ // The prompt hook's own empty-prompt skip.
79
+ case "no_prompt":
80
+ return "no_prompt";
81
+ // Stop-only local skips (stop.ts / transcript.ts / redact.ts).
82
+ case "reentrant":
83
+ return "reentrant";
84
+ case "no_transcript_path":
85
+ case "transcript_unreadable":
86
+ case "transcript_no_exchange":
87
+ return "no_transcript";
88
+ case "redaction_failed":
89
+ return "redaction_failed";
90
+ // The run-level deadline race (run.ts).
91
+ case "deadline_exceeded":
92
+ return "timeout";
93
+ default:
94
+ // A door's `http_<status>` template literal, or an unexpected
95
+ // `hook_threw:<Name>` — the request happened but produced something
96
+ // this hook could not act on. Never the raw string past this point.
97
+ if (reason.startsWith("http_"))
98
+ return reason;
99
+ return "bad_response";
100
+ }
101
+ }
102
+ /**
103
+ * The one stderr line for one hook run. Exact shape per status:
104
+ *
105
+ * [bli-memory] <hook> ok {hits?, saved?, chars?, masked?, elapsed_ms, deadline_ms}
106
+ * [bli-memory] <hook> skipped {reason}
107
+ * [bli-memory] <hook> failed {reason, elapsed_ms, deadline_ms}
108
+ *
109
+ * Metadata only — counts and a closed reason label, never a memory's text,
110
+ * never a path, never the device token.
111
+ */
112
+ export function buildLogLine(event, outcome, elapsedMs, deadlineMs) {
113
+ const word = logWordFor(outcome.status);
114
+ const fields = word === "skipped"
115
+ ? { reason: toLogReason(outcome.reason) }
116
+ : word === "failed"
117
+ ? { reason: toLogReason(outcome.reason), elapsed_ms: elapsedMs, deadline_ms: deadlineMs }
118
+ : {
119
+ ...(outcome.hits === undefined ? {} : { hits: outcome.hits }),
120
+ ...(outcome.saved === undefined ? {} : { saved: outcome.saved }),
121
+ ...(outcome.chars === undefined ? {} : { chars: outcome.chars }),
122
+ ...(outcome.masked === undefined ? {} : { masked: outcome.masked }),
123
+ elapsed_ms: elapsedMs,
124
+ deadline_ms: deadlineMs,
125
+ };
126
+ return `${HOOK_LOG_TAG} ${event} ${word} ${JSON.stringify(fields)}\n`;
127
+ }
128
+ /**
129
+ * One short sentence for a `failed` outcome only, in the JSON envelope the
130
+ * host actually renders (`{"systemMessage": "…"}` on stdout — see the
131
+ * Supermemory mechanism cited in this file's header). `null` on every other
132
+ * branch: `ok`/`empty` already say their piece through the injected block,
133
+ * and `skipped` is a deliberate no-op nobody needs interrupted for.
134
+ */
135
+ export function buildSystemMessage(outcome) {
136
+ if (outcome.status !== "failed")
137
+ return null;
138
+ return `BLI Memory: ${failureSentence(toLogReason(outcome.reason))}`;
139
+ }
140
+ function failureSentence(reason) {
141
+ if (reason.startsWith("http_")) {
142
+ return `Tower refused the request (${reason.slice("http_".length)}).`;
143
+ }
144
+ switch (reason) {
145
+ case "timeout":
146
+ return "Tower did not answer in time.";
147
+ case "dashboard_unreachable":
148
+ return "Tower is unreachable from this machine.";
149
+ case "bad_response":
150
+ return "Tower's response could not be understood.";
151
+ case "unpaired":
152
+ return "this machine is not paired with Tower — run `cockpit login`.";
153
+ case "redaction_failed":
154
+ return "the turn could not be safely redacted before saving.";
155
+ case "no_transcript":
156
+ case "reentrant":
157
+ case "no_prompt":
158
+ // Skip-only reasons; `buildSystemMessage` never reaches these because
159
+ // none of them is produced on a `failed` outcome. Named rather than
160
+ // asserted unreachable, so a future change that DID route one through
161
+ // `failed` still gets a sentence instead of a crash.
162
+ return "the recall did not run.";
163
+ default:
164
+ // Unreached: the `http_` family returned above and every other member
165
+ // is handled. A safe sentence, never a crash, if that ever changes.
166
+ return "something went wrong.";
167
+ }
168
+ }
169
+ /** Renders one JSON block to stdout — the only shape a hook may emit there
170
+ * besides plain injected context. Used by `run.ts` on `failed` only, so the
171
+ * "one block or nothing" stdout contract for every other branch is untouched. */
172
+ export function renderSystemMessageBlock(message) {
173
+ return JSON.stringify({ systemMessage: message });
174
+ }
@@ -14,14 +14,22 @@
14
14
  * this prompt" and any other non-zero as an error it shows the person. A
15
15
  * memory recall may never do either.
16
16
  * 3. **stdout discipline.** Nothing is written until the hook has finished and
17
- * produced a whole block. A partial injection is worse than none.
18
- * 4. **One stderr receipt per run, on every branch.** `[bli-memory hook]
19
- * <event> {status, hits|saved, elapsed_ms, reason}` metadata only, never a
20
- * prompt, a memory, a path or a token. A hook that only logged failures
21
- * could not answer "did recall work at all today?", which is the question
22
- * an operator actually asks.
17
+ * produced a whole block. A partial injection is worse than none — with
18
+ * ONE exception, added in BLI-3664: a `failed` outcome also gets a single
19
+ * `{"systemMessage": "…"}` JSON block, because that is the one shape
20
+ * Claude Code actually renders to a person (see `log-line.ts`'s header).
21
+ * Every other status's stdout is exactly as before.
22
+ * 4. **One stderr receipt per run, on every branch.** `[bli-memory] <event>
23
+ * ok|skipped|failed {…}` (`log-line.ts:buildLogLine`) — metadata only,
24
+ * never a prompt, a memory, a path or a token, and a `reason` drawn from a
25
+ * closed set. A hook that only logged failures could not answer "did
26
+ * recall work at all today?", which is the question an operator actually
27
+ * asks — and across 580 real transcripts this line was the ONLY place
28
+ * that question could ever have been answered, because Claude Code does
29
+ * not surface a hook's stderr anywhere a person or a transcript reads
30
+ * (`docs/reports/supermemory-reachability-2026-09-05.md` §6, BLI-3664).
23
31
  *
24
- * Why an unpaired machine is `skipped no_session` and not an error: an intern
32
+ * Why an unpaired machine is `skipped unpaired` and not an error: an intern
25
33
  * who has not run `cockpit login` yet has three registered hooks and no
26
34
  * credential, and that state must be quiet in the transcript and loud in the
27
35
  * log. Same for a machine whose token was revoked — the door answers 401, the
@@ -30,7 +38,7 @@
30
38
  import type { FetchImpl } from "../door.js";
31
39
  import { type HookEvent, type HookOutcome, type HookPayload } from "./contract.js";
32
40
  import type { StopHookOptions } from "./stop.js";
33
- export declare const HOOK_LOG_TAG = "[bli-memory hook]";
41
+ export { HOOK_LOG_TAG } from "./log-line.js";
34
42
  export interface RunHookDeps {
35
43
  env?: NodeJS.ProcessEnv;
36
44
  cwd?: string;
package/dist/hooks/run.js CHANGED
@@ -14,14 +14,22 @@
14
14
  * this prompt" and any other non-zero as an error it shows the person. A
15
15
  * memory recall may never do either.
16
16
  * 3. **stdout discipline.** Nothing is written until the hook has finished and
17
- * produced a whole block. A partial injection is worse than none.
18
- * 4. **One stderr receipt per run, on every branch.** `[bli-memory hook]
19
- * <event> {status, hits|saved, elapsed_ms, reason}` metadata only, never a
20
- * prompt, a memory, a path or a token. A hook that only logged failures
21
- * could not answer "did recall work at all today?", which is the question
22
- * an operator actually asks.
17
+ * produced a whole block. A partial injection is worse than none — with
18
+ * ONE exception, added in BLI-3664: a `failed` outcome also gets a single
19
+ * `{"systemMessage": "…"}` JSON block, because that is the one shape
20
+ * Claude Code actually renders to a person (see `log-line.ts`'s header).
21
+ * Every other status's stdout is exactly as before.
22
+ * 4. **One stderr receipt per run, on every branch.** `[bli-memory] <event>
23
+ * ok|skipped|failed {…}` (`log-line.ts:buildLogLine`) — metadata only,
24
+ * never a prompt, a memory, a path or a token, and a `reason` drawn from a
25
+ * closed set. A hook that only logged failures could not answer "did
26
+ * recall work at all today?", which is the question an operator actually
27
+ * asks — and across 580 real transcripts this line was the ONLY place
28
+ * that question could ever have been answered, because Claude Code does
29
+ * not surface a hook's stderr anywhere a person or a transcript reads
30
+ * (`docs/reports/supermemory-reachability-2026-09-05.md` §6, BLI-3664).
23
31
  *
24
- * Why an unpaired machine is `skipped no_session` and not an error: an intern
32
+ * Why an unpaired machine is `skipped unpaired` and not an error: an intern
25
33
  * who has not run `cockpit login` yet has three registered hooks and no
26
34
  * credential, and that state must be quiet in the transcript and loud in the
27
35
  * log. Same for a machine whose token was revoked — the door answers 401, the
@@ -31,8 +39,9 @@ import path from "node:path";
31
39
  import { resolveContainerTag } from "../container-tag.js";
32
40
  import { loadMemorySession } from "../session.js";
33
41
  import { HOOK_BUDGETS, } from "./contract.js";
42
+ import { buildLogLine, buildSystemMessage, renderSystemMessageBlock } from "./log-line.js";
34
43
  import { readHookPayload } from "./stdin.js";
35
- export const HOOK_LOG_TAG = "[bli-memory hook]";
44
+ export { HOOK_LOG_TAG } from "./log-line.js";
36
45
  /**
37
46
  * Runs one hook and returns its outcome. The caller (`index.ts`) exits 0
38
47
  * regardless — the return value is for tests and for the receipt.
@@ -44,19 +53,22 @@ export async function runHook(event, deps = {}) {
44
53
  const stdout = deps.stdout ?? process.stdout;
45
54
  const stderr = deps.stderr ?? process.stderr;
46
55
  const outcome = await raceDeadline(() => resolveOutcome(event, deps), budget.totalMs);
56
+ const elapsedMs = now() - started;
47
57
  // stdout is written once, here, and only for a complete block.
48
58
  if (outcome.stdout.length > 0) {
49
59
  stdout.write(`${outcome.stdout}\n`);
50
60
  }
51
- stderr.write(`${HOOK_LOG_TAG} ${event} ${JSON.stringify({
52
- status: outcome.status,
53
- reason: outcome.reason,
54
- ...(outcome.hits === undefined ? {} : { hits: outcome.hits }),
55
- ...(outcome.saved === undefined ? {} : { saved: outcome.saved }),
56
- ...(outcome.chars === undefined ? {} : { chars: outcome.chars }),
57
- ...(outcome.masked === undefined ? {} : { masked: outcome.masked }),
58
- elapsed_ms: now() - started,
59
- })}\n`);
61
+ else {
62
+ // The one exception to "one block or nothing": a `failed` outcome still
63
+ // prints nothing to the model (no additionalContext), but DOES get a
64
+ // `systemMessage` block, which is a status banner, not a memory. Every
65
+ // other status's stdout is unchanged from before BLI-3664.
66
+ const systemMessage = buildSystemMessage(outcome);
67
+ if (systemMessage) {
68
+ stdout.write(`${renderSystemMessageBlock(systemMessage)}\n`);
69
+ }
70
+ }
71
+ stderr.write(buildLogLine(event, outcome, elapsedMs, budget.totalMs));
60
72
  return outcome;
61
73
  }
62
74
  async function resolveOutcome(event, deps) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/memory-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "description": "BLI Memory — an MCP server for the memory layer BLI owns (save, search, update, forget).",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "start": "node dist/index.js"
29
29
  },
30
30
  "dependencies": {
31
- "@bli-cockpit/telemetry-core": "0.1.27",
31
+ "@bli-cockpit/telemetry-core": "0.1.28",
32
32
  "@modelcontextprotocol/sdk": "^1.29.0",
33
33
  "zod": "^4.3.6"
34
34
  },