@bli-cockpit/memory-mcp 0.1.4 → 0.1.6

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
@@ -81,15 +81,18 @@ Each reads Claude Code's JSON payload on stdin and:
81
81
  |---|---|---|---|
82
82
  | `session-start` | `cwd` | `POST /api/memory/v4/profile` | `<bli-memory-context>` with User Profile + Recent Context, `◪` per line |
83
83
  | `prompt` | `prompt` | `POST /api/memory/search` (top 5, ≥ 0.55 similarity) | `<bli-memory-recall>`, `◪` per line |
84
- | `stop` | `transcript_path` | `POST /api/memory/save` in `extract` mode | nothing, ever |
84
+ | `stop` | `transcript_path` | `POST /api/memory/save` in `extract` mode — the door answers **202 queued** and distils after the response (BLI-3730) | nothing, ever |
85
85
 
86
86
  Four rules they all keep, because a hook runs on every turn of every session on
87
87
  every machine:
88
88
 
89
89
  - **Never block.** Each has a deadline below the timeout the installer
90
- registers — 8 s / 3 s / 15 s against 30 / 5 / 30 — including a hard deadline
91
- on the stdin read. On any deadline it prints nothing and gives the host its
92
- process back.
90
+ registers — 8 s / **2 s** / 15 s against 30 / 5 / 30 — including a hard
91
+ deadline on the stdin read. On any deadline it prints nothing and gives the
92
+ host its process back. The prompt hook's budget is sized to the PERSON
93
+ waiting with a prompt already typed, not to the host's 5 s ceiling
94
+ (BLI-3730): nobody notices a recall that gives up under its timeout,
95
+ everybody notices a turn that stalls before the model starts.
93
96
  - **Always exit 0.** Claude Code reads exit 2 as "block this prompt" and any
94
97
  other non-zero as an error it shows the person. An unpaired machine, an
95
98
  outage, a malformed payload: all quiet in the transcript.
@@ -98,17 +101,26 @@ every machine:
98
101
  the same to it. Zero hits is data and prints nothing.
99
102
  - **One stderr receipt per run**, on every branch, one of three shapes
100
103
  (BLI-3664, `hooks/log-line.ts`):
101
- `[bli-memory] prompt ok {"hits":0,"elapsed_ms":140,"deadline_ms":3000}`,
104
+ `[bli-memory] prompt ok {"hits":0,"elapsed_ms":140,"deadline_ms":2000}`,
102
105
  `[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`,
106
+ `[bli-memory] prompt failed {"reason":"dashboard_unreachable","elapsed_ms":1502,"deadline_ms":2000}`.
107
+ `reason` on `skipped`/`failed` is always one of a closed set — the four
108
+ network reasons `timeout`, `http_<status>`, `dashboard_unreachable`,
109
+ `bad_response` (Tower answered with a body that was not readable JSON);
110
+ the six `StdinFailure` reasons `no_stdin`, `stdin_empty`, `stdin_timeout`,
111
+ `stdin_too_large`, `stdin_unreadable`, `stdin_malformed` — each its own
112
+ literal, never folded into `bad_response`, because a malformed payload from
113
+ the host never touched the network (BLI-3700); `unpaired`, `no_prompt`,
114
+ plus three Stop-only local skips (`reentrant`, `no_transcript`,
107
115
  `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.
116
+ never a prompt, a memory, a path or a token. A reason `toLogReason` has not
117
+ been taught becomes `unknown_internal:<name>`, never a silent
118
+ `bad_response`. A `failed` **stop** also gets a one-line `systemMessage` (the
119
+ same JSON-on-stdout shape the Supermemory plugin's own banner uses),
120
+ because Claude Code does not surface a hook's stderr anywhere a person
121
+ reads it and a failed save means the turn was not kept. A failed RECALL is
122
+ silent (BLI-3730): it costs the person nothing and a banner on every prompt
123
+ during an outage is noise they cannot act on.
112
124
 
113
125
  `hook stop` sends the last user/assistant exchange (at most 12 KB, tail-read so
114
126
  a 40 MB transcript costs nothing) through the collector's own redactor —
@@ -117,6 +129,15 @@ that masks every uploaded transcript — and lets the librarian's
117
129
  extract/reconcile decide what is worth keeping. A re-entrant Stop
118
130
  (`stop_hook_active: true`) saves nothing.
119
131
 
132
+ Since BLI-3730 the door **queues** that work: it writes one durable
133
+ `memory_extract_jobs` row, answers 202, and distils after the response, so the
134
+ hook reports `ok {"chars":…,"masked":…,"queued":true}` and never claims a save
135
+ it cannot see. `saved` is absent on that branch rather than 0 — "accepted,
136
+ pending" and "nothing was worth keeping" must never read as the same line. The
137
+ outcome lands on the job row and in Tower's own
138
+ `[bli-memory] extract job done {added, updated, unchanged, conflicted,
139
+ extract_ms, reconcile_ms}`.
140
+
120
141
  ## Auth
121
142
 
122
143
  None to set up. The server reads the collector's existing paired session at
@@ -93,6 +93,22 @@ export interface HookOutcome {
93
93
  chars?: number;
94
94
  /** How many secret-like spans were masked before a save. A count, never a value. */
95
95
  masked?: number;
96
+ /**
97
+ * BLI-3718: how many extracted facts collided with another live memory's
98
+ * custom_id rather than being written. Counted SEPARATELY from `saved` so a
99
+ * session where every candidate conflicted reads as `custom_id_conflict`,
100
+ * never as `nothing_worth_keeping` — the two used to be indistinguishable at
101
+ * HTTP 200.
102
+ */
103
+ conflicts?: number;
104
+ /**
105
+ * BLI-3730: the door accepted the turn and will distil it after the
106
+ * response (HTTP 202). The hook cannot say what was saved — nothing has been
107
+ * yet — so it says the true thing instead of guessing, and `saved` stays
108
+ * absent rather than being reported as 0, which would read as "nothing was
109
+ * worth keeping".
110
+ */
111
+ queued?: boolean;
96
112
  }
97
113
  /** The container this hook's memories live in, and how it was derived. */
98
114
  export interface HookContainer {
@@ -110,11 +126,32 @@ export declare const RECALL_BLOCK: {
110
126
  };
111
127
  /** The vendor plugin's bullet, kept byte for byte so the injection reads the same. */
112
128
  export declare const BULLET = "\u25EA";
113
- /** The per-prompt recall shape the `/v4/profile` shim documents: top 5, ≥ 0.55. */
129
+ /**
130
+ * The vendor's own per-prompt recall floor. Kept only as the FALLBACK for a
131
+ * door old enough not to send its own `semanticFloor` on the search response
132
+ * (BLI-3697 tick 8) — the live floor is read from that response, never
133
+ * hardcoded here, because three tickets already moved the door's real
134
+ * number (0.55 -> 0.30 -> 0.65) while this constant sat still.
135
+ */
114
136
  export declare const RECALL_MIN_SIMILARITY = 0.55;
115
137
  export declare const RECALL_LIMIT = 5;
116
138
  /** One recalled line, capped. The vendor truncated at 300 characters. */
117
139
  export declare const RECALL_LINE_CHARS = 300;
140
+ /**
141
+ * One recalled memory, its text and when it was written (BLI-3697 tick 8).
142
+ * A fact that changes over time — a version number, a decision, a status —
143
+ * read back with no date is a trap: the QA loop found a `min_cli_version`
144
+ * row from 2026-08-18 answering "what is the CLI floor" at rank 1, true in
145
+ * August and wrong by the time it was read. `renderRecall` prefixes every
146
+ * line with its date rather than trying to decide which rows count as
147
+ * "stale" — that judgment belongs to whoever reads the recall, and they
148
+ * cannot make it at all if the date is missing.
149
+ */
150
+ export interface RecalledHit {
151
+ text: string;
152
+ /** ISO timestamp, or `null` when the door did not send one. */
153
+ createdAt: string | null;
154
+ }
118
155
  /** The search door refuses a query over 1000 characters; a long prompt is trimmed, not dropped. */
119
156
  export declare const MAX_QUERY_CHARS = 1000;
120
157
  /** At most the final 12 KB of the last exchange travels to `save`. */
@@ -40,10 +40,25 @@ export const HOOK_EVENTS = ["session-start", "prompt", "stop"];
40
40
  */
41
41
  export const HOOK_BUDGETS = {
42
42
  "session-start": { totalMs: 8_000, stdinMs: 2_000, requestMs: 6_000, gitMs: 2_500 },
43
- // A person is waiting on this one with their prompt already typed.
44
- prompt: { totalMs: 3_000, stdinMs: 1_000, requestMs: 2_200, gitMs: 700 },
45
- // `extract` mode runs one model call server-side, so this one is allowed to
46
- // be slow it prints nothing either way, so nobody is waiting on it.
43
+ /**
44
+ * A person is waiting on this one with their prompt already typed, so the
45
+ * whole run is capped at TWO SECONDS and the door call at 1,500 ms
46
+ * (BLI-3730). The old 3,000/2,200 pair was chosen against the installer's
47
+ * 5 s host timeout, which is the wrong number to size against: nobody
48
+ * notices a hook that gives up under its host's ceiling, everybody notices
49
+ * a turn that stalls for three seconds before the model starts. A recall
50
+ * that misses this window prints NOTHING and logs `timeout` — the shelf is
51
+ * not worth the wait, and the person never finds out, by design.
52
+ */
53
+ prompt: { totalMs: 2_000, stdinMs: 600, requestMs: 1_500, gitMs: 500 },
54
+ /**
55
+ * `extract` mode is queued server-side now (BLI-3730): the door writes a
56
+ * durable job, answers 202 and does the model work after the response, so
57
+ * this hook is waiting on one INSERT rather than on two model calls and
58
+ * twelve neighbour searches. The generous budget stays anyway — it prints
59
+ * nothing either way, and the one thing worse than a slow Stop hook is a
60
+ * Stop hook that abandons a save the door was about to accept.
61
+ */
47
62
  stop: { totalMs: 15_000, stdinMs: 3_000, requestMs: 13_000, gitMs: 2_500 },
48
63
  };
49
64
  /** The wrappers Claude Code injects verbatim. Kept in one place, tested. */
@@ -57,7 +72,13 @@ export const RECALL_BLOCK = {
57
72
  };
58
73
  /** The vendor plugin's bullet, kept byte for byte so the injection reads the same. */
59
74
  export const BULLET = "◪";
60
- /** The per-prompt recall shape the `/v4/profile` shim documents: top 5, ≥ 0.55. */
75
+ /**
76
+ * The vendor's own per-prompt recall floor. Kept only as the FALLBACK for a
77
+ * door old enough not to send its own `semanticFloor` on the search response
78
+ * (BLI-3697 tick 8) — the live floor is read from that response, never
79
+ * hardcoded here, because three tickets already moved the door's real
80
+ * number (0.55 -> 0.30 -> 0.65) while this constant sat still.
81
+ */
61
82
  export const RECALL_MIN_SIMILARITY = 0.55;
62
83
  export const RECALL_LIMIT = 5;
63
84
  /** One recalled line, capped. The vendor truncated at 300 characters. */
@@ -15,7 +15,8 @@
15
15
  * 1. **stderr, on every branch, one line, one shape.** `buildLogLine` below.
16
16
  * This is what an operator tailing a launchd-style log — or a future
17
17
  * collector upload of hook logs — can grep.
18
- * 2. **`systemMessage`, on `failed` only.** Claude Code does not surface a
18
+ * 2. **`systemMessage`, on a failed STOP only** (narrowed in BLI-3730 see
19
+ * `buildSystemMessage`; a failed recall is silent). Claude Code does not surface a
19
20
  * hook's stderr as a visible line; the Supermemory plugin's own banner
20
21
  * (`~/.claude/plugins/.../recall-directive.js`) is a JSON object on
21
22
  * STDOUT carrying a `systemMessage` field (`hooks/stdin.js:writeOutput`,
@@ -23,28 +24,42 @@
23
24
  * same shape so a real failure — not a quiet turn — is the one case a
24
25
  * person actually sees something.
25
26
  *
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.
27
+ * **The reason vocabulary is closed and lives HERE, in one module.** Four
28
+ * members answer the network half of the ticket's own question (can a
29
+ * timeout, a 5xx and a dead dashboard be told apart?): `timeout`,
30
+ * `http_<status>`, `dashboard_unreachable`, `bad_response` the last of
31
+ * those meaning specifically "Tower answered with a status but a body that
32
+ * was not readable JSON" (`door.ts`'s `doorReason`). Six more are the
33
+ * **caller's own stdin**, one per `stdin.ts` `StdinFailure` member
34
+ * `no_stdin`, `stdin_empty`, `stdin_timeout`, `stdin_too_large`,
35
+ * `stdin_unreadable`, `stdin_malformed` kept as their OWN literals rather
36
+ * than folded into `bad_response`, because a malformed payload from the host
37
+ * never touched the network at all (BLI-3700, QA tick 6 NEW-9: a plain-text
38
+ * stdin payload printed `bad_response` and read as a Tower outage for four
39
+ * runs that never made a request). `unpaired` and `no_prompt` round those
40
+ * out, and three more cover skip states local to one hook that never touch
41
+ * the network either`reentrant` (Stop, re-entrant), `no_transcript`
42
+ * (Stop, nothing to read yet) and `redaction_failed` (Stop, the masking pass
43
+ * itself failed). Every member is a compile-time literal from OUR OWN code;
44
+ * none of them is `response.body` text, an `error.message`, or a stack trace
45
+ * (`door.ts`'s `doorReason` already enforces that at the source for the
46
+ * network four). `toLogReason` maps every other internal reason string this
47
+ * package can produce onto one of these, and anything it does not recognise —
48
+ * a future reason nobody wired in here — becomes `unknown_internal:<name>`,
49
+ * never a silent `bad_response`: a reason this table has not been taught must
50
+ * say so, not point at the wrong system.
42
51
  */
43
52
  import type { HookEvent, HookOutcome, HookStatus } from "./contract.js";
44
53
  import type { DoorFailureReason } from "../door.js";
54
+ import type { StdinFailure } from "./stdin.js";
45
55
  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";
56
+ /**
57
+ * The closed set: the door's four network members, the six `StdinFailure`
58
+ * members (BLI-3700 — never folded into a door reason), five more fixed
59
+ * local literals, and the `unknown_internal:<name>` escape hatch for a
60
+ * reason `toLogReason` has not been taught.
61
+ */
62
+ export type HookLogReason = DoorFailureReason | StdinFailure | "unpaired" | "no_prompt" | "reentrant" | "no_transcript" | "redaction_failed" | `unknown_internal:${string}`;
48
63
  /** The word printed after the event name. `empty` (ran fine, nothing to say) prints as `ok`. */
49
64
  export type HookLogWord = "ok" | "skipped" | "failed";
50
65
  export declare function logWordFor(status: HookStatus): HookLogWord;
@@ -67,13 +82,14 @@ export declare function toLogReason(reason: string): HookLogReason;
67
82
  */
68
83
  export declare function buildLogLine(event: HookEvent, outcome: HookOutcome, elapsedMs: number, deadlineMs: number): string;
69
84
  /**
70
- * One short sentence for a `failed` outcome only, in the JSON envelope the
71
- * host actually renders (`{"systemMessage": "…"}` on stdout — see the
85
+ * One short sentence for a `failed` STOP outcome only, in the JSON envelope
86
+ * the host actually renders (`{"systemMessage": "…"}` on stdout — see the
72
87
  * Supermemory mechanism cited in this file's header). `null` on every other
73
88
  * branch: `ok`/`empty` already say their piece through the injected block,
74
- * and `skipped` is a deliberate no-op nobody needs interrupted for.
89
+ * `skipped` is a deliberate no-op nobody needs interrupted for, and a failed
90
+ * RECALL is not a person's problem (BLI-3730).
75
91
  */
76
- export declare function buildSystemMessage(outcome: HookOutcome): string | null;
92
+ export declare function buildSystemMessage(event: HookEvent, outcome: HookOutcome): string | null;
77
93
  /** Renders one JSON block to stdout — the only shape a hook may emit there
78
94
  * besides plain injected context. Used by `run.ts` on `failed` only, so the
79
95
  * "one block or nothing" stdout contract for every other branch is untouched. */
@@ -15,7 +15,8 @@
15
15
  * 1. **stderr, on every branch, one line, one shape.** `buildLogLine` below.
16
16
  * This is what an operator tailing a launchd-style log — or a future
17
17
  * collector upload of hook logs — can grep.
18
- * 2. **`systemMessage`, on `failed` only.** Claude Code does not surface a
18
+ * 2. **`systemMessage`, on a failed STOP only** (narrowed in BLI-3730 see
19
+ * `buildSystemMessage`; a failed recall is silent). Claude Code does not surface a
19
20
  * hook's stderr as a visible line; the Supermemory plugin's own banner
20
21
  * (`~/.claude/plugins/.../recall-directive.js`) is a JSON object on
21
22
  * STDOUT carrying a `systemMessage` field (`hooks/stdin.js:writeOutput`,
@@ -23,22 +24,30 @@
23
24
  * same shape so a real failure — not a quiet turn — is the one case a
24
25
  * person actually sees something.
25
26
  *
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.
27
+ * **The reason vocabulary is closed and lives HERE, in one module.** Four
28
+ * members answer the network half of the ticket's own question (can a
29
+ * timeout, a 5xx and a dead dashboard be told apart?): `timeout`,
30
+ * `http_<status>`, `dashboard_unreachable`, `bad_response` the last of
31
+ * those meaning specifically "Tower answered with a status but a body that
32
+ * was not readable JSON" (`door.ts`'s `doorReason`). Six more are the
33
+ * **caller's own stdin**, one per `stdin.ts` `StdinFailure` member
34
+ * `no_stdin`, `stdin_empty`, `stdin_timeout`, `stdin_too_large`,
35
+ * `stdin_unreadable`, `stdin_malformed` kept as their OWN literals rather
36
+ * than folded into `bad_response`, because a malformed payload from the host
37
+ * never touched the network at all (BLI-3700, QA tick 6 NEW-9: a plain-text
38
+ * stdin payload printed `bad_response` and read as a Tower outage for four
39
+ * runs that never made a request). `unpaired` and `no_prompt` round those
40
+ * out, and three more cover skip states local to one hook that never touch
41
+ * the network either`reentrant` (Stop, re-entrant), `no_transcript`
42
+ * (Stop, nothing to read yet) and `redaction_failed` (Stop, the masking pass
43
+ * itself failed). Every member is a compile-time literal from OUR OWN code;
44
+ * none of them is `response.body` text, an `error.message`, or a stack trace
45
+ * (`door.ts`'s `doorReason` already enforces that at the source for the
46
+ * network four). `toLogReason` maps every other internal reason string this
47
+ * package can produce onto one of these, and anything it does not recognise —
48
+ * a future reason nobody wired in here — becomes `unknown_internal:<name>`,
49
+ * never a silent `bad_response`: a reason this table has not been taught must
50
+ * say so, not point at the wrong system.
42
51
  */
43
52
  export const HOOK_LOG_TAG = "[bli-memory]";
44
53
  export function logWordFor(status) {
@@ -68,13 +77,15 @@ export function toLogReason(reason) {
68
77
  case "session_missing_token":
69
78
  return "unpaired";
70
79
  // The host's own payload could not be used (stdin.ts's `StdinFailure`).
80
+ // Each keeps its own literal — never folded into `bad_response`, which
81
+ // names a Tower reply, not a caller-side stdin defect (BLI-3700).
71
82
  case "no_stdin":
72
83
  case "stdin_empty":
73
84
  case "stdin_timeout":
74
85
  case "stdin_too_large":
75
86
  case "stdin_unreadable":
76
87
  case "stdin_malformed":
77
- return "bad_response";
88
+ return reason;
78
89
  // The prompt hook's own empty-prompt skip.
79
90
  case "no_prompt":
80
91
  return "no_prompt";
@@ -91,12 +102,16 @@ export function toLogReason(reason) {
91
102
  case "deadline_exceeded":
92
103
  return "timeout";
93
104
  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.
105
+ // A door's `http_<status>` template literal passes through as-is; an
106
+ // unexpected reason nobody wired into this table — `hook_threw:<Name>`
107
+ // or a future internal reason becomes `unknown_internal:<name>`
108
+ // rather than a silent `bad_response`, which would misname a caller-side
109
+ // or run-level defect as a Tower reply (BLI-3700). Never the raw
110
+ // string is dropped, and never is it printed unbounded: the name is
111
+ // one of OUR OWN reason literals, never `response.body` text.
97
112
  if (reason.startsWith("http_"))
98
113
  return reason;
99
- return "bad_response";
114
+ return `unknown_internal:${reason}`;
100
115
  }
101
116
  }
102
117
  /**
@@ -120,27 +135,50 @@ export function buildLogLine(event, outcome, elapsedMs, deadlineMs) {
120
135
  ...(outcome.saved === undefined ? {} : { saved: outcome.saved }),
121
136
  ...(outcome.chars === undefined ? {} : { chars: outcome.chars }),
122
137
  ...(outcome.masked === undefined ? {} : { masked: outcome.masked }),
138
+ // BLI-3718: printed whenever set, including 0 — a save that wrote
139
+ // nothing AND conflicted with nothing is a materially different
140
+ // fact from one that conflicted on every candidate.
141
+ ...(outcome.conflicts === undefined ? {} : { conflicts: outcome.conflicts }),
142
+ // BLI-3730: the door took the turn and will distil it after the
143
+ // response. Printed so `ok {queued:true}` and `ok {saved:0}` —
144
+ // "accepted, pending" and "nothing worth keeping" — can never be
145
+ // read as the same line.
146
+ ...(outcome.queued === undefined ? {} : { queued: outcome.queued }),
123
147
  elapsed_ms: elapsedMs,
124
148
  deadline_ms: deadlineMs,
125
149
  };
126
150
  return `${HOOK_LOG_TAG} ${event} ${word} ${JSON.stringify(fields)}\n`;
127
151
  }
128
152
  /**
129
- * One short sentence for a `failed` outcome only, in the JSON envelope the
130
- * host actually renders (`{"systemMessage": "…"}` on stdout — see the
153
+ * One short sentence for a `failed` STOP outcome only, in the JSON envelope
154
+ * the host actually renders (`{"systemMessage": "…"}` on stdout — see the
131
155
  * Supermemory mechanism cited in this file's header). `null` on every other
132
156
  * branch: `ok`/`empty` already say their piece through the injected block,
133
- * and `skipped` is a deliberate no-op nobody needs interrupted for.
157
+ * `skipped` is a deliberate no-op nobody needs interrupted for, and a failed
158
+ * RECALL is not a person's problem (BLI-3730).
134
159
  */
135
- export function buildSystemMessage(outcome) {
160
+ export function buildSystemMessage(event, outcome) {
136
161
  if (outcome.status !== "failed")
137
162
  return null;
163
+ // BLI-3730: RECALL is silent, always. A `UserPromptSubmit` or `SessionStart`
164
+ // hook that could not reach Tower has cost the person nothing — the turn
165
+ // runs, the model simply has one fewer source — and a banner on every
166
+ // prompt during an outage is noise a person cannot act on and cannot turn
167
+ // off. The Stop hook is different in kind: it is the only hook that WRITES,
168
+ // and a failure there means the turn was not kept. That is worth one line.
169
+ if (event !== "stop")
170
+ return null;
138
171
  return `BLI Memory: ${failureSentence(toLogReason(outcome.reason))}`;
139
172
  }
140
173
  function failureSentence(reason) {
141
174
  if (reason.startsWith("http_")) {
142
175
  return `Tower refused the request (${reason.slice("http_".length)}).`;
143
176
  }
177
+ if (reason.startsWith("unknown_internal:")) {
178
+ // A reason this table has not been taught. Named, never a crash, and
179
+ // never claims to know which system was at fault (BLI-3700).
180
+ return "something went wrong that this hook does not yet name.";
181
+ }
144
182
  switch (reason) {
145
183
  case "timeout":
146
184
  return "Tower did not answer in time.";
@@ -152,6 +190,24 @@ function failureSentence(reason) {
152
190
  return "this machine is not paired with Tower — run `cockpit login`.";
153
191
  case "redaction_failed":
154
192
  return "the turn could not be safely redacted before saving.";
193
+ // The six `StdinFailure` members (BLI-3700). Skip-only today —
194
+ // `readHookPayload` always returns these as a `skipped` outcome, so
195
+ // `buildSystemMessage` never reaches them in practice — but each still
196
+ // gets its own sentence, named rather than asserted unreachable, so a
197
+ // future change that DID route one through `failed` gets the right
198
+ // words instead of the wrong system's name.
199
+ case "no_stdin":
200
+ return "no payload arrived on stdin (not run from a host that pipes one).";
201
+ case "stdin_empty":
202
+ return "the payload on stdin was empty.";
203
+ case "stdin_timeout":
204
+ return "stdin never closed within this hook's own deadline.";
205
+ case "stdin_too_large":
206
+ return "the payload on stdin exceeded the size ceiling.";
207
+ case "stdin_unreadable":
208
+ return "the payload on stdin could not be read.";
209
+ case "stdin_malformed":
210
+ return "the payload on stdin was not readable JSON.";
155
211
  case "no_transcript":
156
212
  case "reentrant":
157
213
  case "no_prompt":
@@ -161,8 +217,9 @@ function failureSentence(reason) {
161
217
  // `failed` still gets a sentence instead of a crash.
162
218
  return "the recall did not run.";
163
219
  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.
220
+ // Unreached: the `http_`/`unknown_internal:` families returned above
221
+ // and every other member is handled. A safe sentence, never a crash,
222
+ // if that ever changes.
166
223
  return "something went wrong.";
167
224
  }
168
225
  }
@@ -6,8 +6,13 @@
6
6
  * installer's 5 s timeout, one search, print or print nothing.
7
7
  *
8
8
  * The recall shape is the vendor's, kept so the injection reads the same: the
9
- * prompt itself is the query, hits below 0.55 similarity are dropped, at most
10
- * five survive, each capped at 300 characters.
9
+ * prompt itself is the query, hits below the door's OWN floor are dropped
10
+ * (BLI-3697 tick 8: this hook used to keep a hardcoded 0.55 of its own,
11
+ * which drifted out of step when the door's floor moved across three
12
+ * tickets — the door's `semanticFloor` in the response is read instead now,
13
+ * `RECALL_MIN_SIMILARITY` surviving only as the fallback for an older
14
+ * deployment that does not send the field yet), at most five survive, each
15
+ * capped at 300 characters.
11
16
  *
12
17
  * **Zero hits is data.** It prints nothing and exits 0 with `no_hits`. A search
13
18
  * that could NOT run is a different line with a different reason, because an
@@ -6,8 +6,13 @@
6
6
  * installer's 5 s timeout, one search, print or print nothing.
7
7
  *
8
8
  * The recall shape is the vendor's, kept so the injection reads the same: the
9
- * prompt itself is the query, hits below 0.55 similarity are dropped, at most
10
- * five survive, each capped at 300 characters.
9
+ * prompt itself is the query, hits below the door's OWN floor are dropped
10
+ * (BLI-3697 tick 8: this hook used to keep a hardcoded 0.55 of its own,
11
+ * which drifted out of step when the door's floor moved across three
12
+ * tickets — the door's `semanticFloor` in the response is read instead now,
13
+ * `RECALL_MIN_SIMILARITY` surviving only as the fallback for an older
14
+ * deployment that does not send the field yet), at most five survive, each
15
+ * capped at 300 characters.
11
16
  *
12
17
  * **Zero hits is data.** It prints nothing and exits 0 with `no_hits`. A search
13
18
  * that could NOT run is a different line with a different reason, because an
@@ -45,7 +50,8 @@ export async function runPromptHook(context) {
45
50
  if (!response.ok) {
46
51
  return { status: "failed", reason: doorReason(response), stdout: "", hits: 0 };
47
52
  }
48
- const memories = readHits(response.body["results"]);
53
+ const floor = doorFloor(response.body["semanticFloor"]);
54
+ const memories = readHits(response.body["results"], floor);
49
55
  const degraded = typeof response.body["degraded"] === "string" && response.body["degraded"]
50
56
  ? String(response.body["degraded"])
51
57
  : null;
@@ -70,20 +76,35 @@ export async function runPromptHook(context) {
70
76
  };
71
77
  }
72
78
  /**
73
- * The vendor's floor, applied to the vendor's channel only.
79
+ * The door's own floor for THIS search, or the vendor's fallback.
80
+ *
81
+ * BLI-3697 tick 8: this hook used to re-check every row against its own
82
+ * hardcoded `RECALL_MIN_SIMILARITY` (0.55) regardless of what floor the
83
+ * door had actually searched with — three tickets moved that number (0.55 ->
84
+ * 0.30 -> 0.65) and this hook never noticed, silently applying a SECOND,
85
+ * stale threshold on top of the door's real one. `semanticFloor` on the
86
+ * response is the number the search actually ran with; only a door old
87
+ * enough not to send it falls back to the vendor's constant.
88
+ */
89
+ function doorFloor(value) {
90
+ return typeof value === "number" && Number.isFinite(value) ? value : RECALL_MIN_SIMILARITY;
91
+ }
92
+ /**
93
+ * The floor governs the SEMANTIC channel only.
74
94
  *
75
95
  * `similarity` on our door is **cosine similarity, zero when a row came only
76
96
  * from the lexical or recency channel** (`agent-memories/search.ts:74`). The
77
- * vendor filtered at 0.55 against a store where every hit was semantic; doing
78
- * that here would drop every keyword match, and on a deployment with no
79
- * embedding credential it would drop EVERY hit — a recall that silently returns
97
+ * vendor filtered against a store where every hit was semantic; doing that
98
+ * here would drop every keyword match, and on a deployment with no embedding
99
+ * credential it would drop EVERY hit — a recall that silently returns
80
100
  * nothing forever while the door reports `degraded` and nobody reads it.
81
101
  *
82
- * So the floor governs SEMANTIC hits and nothing else: a row the semantic
83
- * channel scored has to clear 0.55, and a row it did not score at all is judged
84
- * by the door's own ranking, which already put it in the top `limit`.
102
+ * So `floor` (the door's own `semanticFloor`, see `doorFloor` above) governs
103
+ * SEMANTIC hits and nothing else: a row the semantic channel scored has to
104
+ * clear it, and a row it did not score at all is judged by the door's own
105
+ * ranking, which already put it in the top `limit`.
85
106
  */
86
- function readHits(value) {
107
+ function readHits(value, floor) {
87
108
  if (!Array.isArray(value))
88
109
  return [];
89
110
  const kept = [];
@@ -101,9 +122,10 @@ function readHits(value) {
101
122
  : // An older door that does not report channels: a non-zero similarity is
102
123
  // the only evidence the semantic channel scored this row.
103
124
  Number.isFinite(similarity) && similarity > 0;
104
- if (scoredSemantically && !(similarity >= RECALL_MIN_SIMILARITY))
125
+ if (scoredSemantically && !(similarity >= floor))
105
126
  continue;
106
- kept.push(memory);
127
+ const createdAt = typeof record["createdAt"] === "string" ? record["createdAt"] : null;
128
+ kept.push({ text: memory, createdAt });
107
129
  if (kept.length >= RECALL_LIMIT)
108
130
  break;
109
131
  }
@@ -14,8 +14,9 @@
14
14
  * A memory is a recalled fact; a fact that arrives phrased as an order and
15
15
  * is obeyed is how a memory store becomes an injection channel.
16
16
  */
17
+ import { type RecalledHit } from "./contract.js";
17
18
  export declare function renderSessionContext(profile: {
18
19
  static: string[];
19
20
  dynamic: string[];
20
21
  }): string;
21
- export declare function renderRecall(memories: string[]): string;
22
+ export declare function renderRecall(hits: readonly RecalledHit[]): string;
@@ -37,8 +37,8 @@ export function renderSessionContext(profile) {
37
37
  lines.push(CONTEXT_BLOCK.close);
38
38
  return lines.join("\n");
39
39
  }
40
- export function renderRecall(memories) {
41
- const items = cleanItems(memories);
40
+ export function renderRecall(hits) {
41
+ const items = cleanRecalledHits(hits);
42
42
  if (items.length === 0)
43
43
  return "";
44
44
  return [
@@ -48,6 +48,38 @@ export function renderRecall(memories) {
48
48
  RECALL_BLOCK.close,
49
49
  ].join("\n");
50
50
  }
51
+ /**
52
+ * One memory, on one line, dated. Folds newlines for the same reason
53
+ * `cleanItems` does; the date prefix is added BEFORE the length cap, so the
54
+ * cap still bounds the whole printed line, never just the text half.
55
+ *
56
+ * Every line gets its date, not only the ones some heuristic calls "stale" —
57
+ * `RecalledHit`'s header in `contract.ts` says why: only the reader can judge
58
+ * staleness, and only if the date is there to judge.
59
+ */
60
+ function cleanRecalledHits(hits) {
61
+ const out = [];
62
+ for (const hit of hits) {
63
+ if (typeof hit.text !== "string")
64
+ continue;
65
+ const folded = hit.text.replace(/\s+/gu, " ").trim();
66
+ if (folded.length === 0)
67
+ continue;
68
+ const prefix = dateLabel(hit.createdAt);
69
+ const line = prefix ? `${prefix} ${folded}` : folded;
70
+ out.push(line.length > RECALL_LINE_CHARS ? `${line.slice(0, RECALL_LINE_CHARS - 1)}…` : line);
71
+ }
72
+ return out;
73
+ }
74
+ /** `[YYYY-MM-DD]`, or `""` when the door sent no date or an unparsable one. */
75
+ function dateLabel(createdAt) {
76
+ if (!createdAt)
77
+ return "";
78
+ const date = new Date(createdAt);
79
+ if (Number.isNaN(date.getTime()))
80
+ return "";
81
+ return `[${date.toISOString().slice(0, 10)}]`;
82
+ }
51
83
  /**
52
84
  * One memory, on one line. Newlines are folded because a multi-line bullet
53
85
  * breaks the list the model is reading, and the cap is the vendor's 300.
@@ -15,10 +15,12 @@
15
15
  * memory recall may never do either.
16
16
  * 3. **stdout discipline.** Nothing is written until the hook has finished and
17
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.
18
+ * ONE exception, added in BLI-3664 and narrowed in BLI-3730: a `failed`
19
+ * STOP outcome also gets a single `{"systemMessage": "…"}` JSON block,
20
+ * because that is the one shape Claude Code actually renders to a person
21
+ * (see `log-line.ts`'s header) and a lost SAVE is worth a line. A failed
22
+ * recall is silent — it costs the person nothing and they cannot act on
23
+ * it. Every other status's stdout is exactly as before.
22
24
  * 4. **One stderr receipt per run, on every branch.** `[bli-memory] <event>
23
25
  * ok|skipped|failed {…}` (`log-line.ts:buildLogLine`) — metadata only,
24
26
  * never a prompt, a memory, a path or a token, and a `reason` drawn from a
package/dist/hooks/run.js CHANGED
@@ -15,10 +15,12 @@
15
15
  * memory recall may never do either.
16
16
  * 3. **stdout discipline.** Nothing is written until the hook has finished and
17
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.
18
+ * ONE exception, added in BLI-3664 and narrowed in BLI-3730: a `failed`
19
+ * STOP outcome also gets a single `{"systemMessage": "…"}` JSON block,
20
+ * because that is the one shape Claude Code actually renders to a person
21
+ * (see `log-line.ts`'s header) and a lost SAVE is worth a line. A failed
22
+ * recall is silent — it costs the person nothing and they cannot act on
23
+ * it. Every other status's stdout is exactly as before.
22
24
  * 4. **One stderr receipt per run, on every branch.** `[bli-memory] <event>
23
25
  * ok|skipped|failed {…}` (`log-line.ts:buildLogLine`) — metadata only,
24
26
  * never a prompt, a memory, a path or a token, and a `reason` drawn from a
@@ -63,7 +65,7 @@ export async function runHook(event, deps = {}) {
63
65
  // prints nothing to the model (no additionalContext), but DOES get a
64
66
  // `systemMessage` block, which is a status banner, not a memory. Every
65
67
  // other status's stdout is unchanged from before BLI-3664.
66
- const systemMessage = buildSystemMessage(outcome);
68
+ const systemMessage = buildSystemMessage(event, outcome);
67
69
  if (systemMessage) {
68
70
  stdout.write(`${renderSystemMessageBlock(systemMessage)}\n`);
69
71
  }
@@ -16,6 +16,13 @@
16
16
  * nothing saved, `reentrant`.
17
17
  * - Nothing is printed on stdout, ever. Stop's stdout is shown to the person
18
18
  * in transcript mode, and "I saved a memory" on every turn is noise.
19
+ *
20
+ * BLI-3730: the door answers **202** for `mode: "extract"` — it writes a
21
+ * durable job and distils it after the response — so the normal successful
22
+ * outcome here is `queued`, with no decisions to count. The synchronous
23
+ * branch below is kept for a `wait: true` caller and for a Tower old enough
24
+ * to still answer 200 with decisions; it is not dead code, it is the older
25
+ * half of a wire contract that has to keep working during a rollout.
19
26
  */
20
27
  import { type HookOutcome } from "./contract.js";
21
28
  import type { HookRunContext } from "./run-context.js";
@@ -16,6 +16,13 @@
16
16
  * nothing saved, `reentrant`.
17
17
  * - Nothing is printed on stdout, ever. Stop's stdout is shown to the person
18
18
  * in transcript mode, and "I saved a memory" on every turn is noise.
19
+ *
20
+ * BLI-3730: the door answers **202** for `mode: "extract"` — it writes a
21
+ * durable job and distils it after the response — so the normal successful
22
+ * outcome here is `queued`, with no decisions to count. The synchronous
23
+ * branch below is kept for a `wait: true` caller and for a Tower old enough
24
+ * to still answer 200 with decisions; it is not dead code, it is the older
25
+ * half of a wire contract that has to keep working during a rollout.
19
26
  */
20
27
  import { createHash } from "node:crypto";
21
28
  import { doorReason, postMemoryDoor } from "../door.js";
@@ -75,19 +82,59 @@ export async function runStopHook(context, options = {}) {
75
82
  if (!response.ok) {
76
83
  return { status: "failed", reason: doorReason(response), stdout: "", saved: 0 };
77
84
  }
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
85
+ // BLI-3730: the door queued it. Extraction is one model call (two on the
86
+ // BLI-3713 retry) plus one embed and one neighbour search per fact, and on
87
+ // 2026-09-05 twelve facts outran this hook's whole 15 s deadline — every
88
+ // turn ended with "BLI Memory: Tower did not answer in time" while the work
89
+ // was still in flight. The door writes a durable job row, answers 202 and
90
+ // does the work after the response, so this hook is now waiting on one
91
+ // INSERT. Nothing is claimed saved, because nothing is yet: `queued` is the
92
+ // true thing to report, and `saved` stays absent rather than 0, which would
93
+ // read as "nothing was worth keeping".
94
+ if (response.status === 202 || response.body["queued"] === true) {
95
+ return {
96
+ status: "ok",
97
+ reason: "queued",
98
+ stdout: "",
99
+ queued: true,
100
+ chars: masked.text.length,
101
+ masked: masked.masked,
102
+ };
103
+ }
104
+ const decisionsRaw = response.body["decisions"];
105
+ const decisions = Array.isArray(decisionsRaw)
106
+ ? decisionsRaw.filter((decision) => Boolean(decision) && typeof decision === "object")
107
+ : null;
108
+ const written = decisions
109
+ ? decisions.filter((decision) => decision["verb"] !== "skip").length
83
110
  : response.body["id"]
84
111
  ? 1
85
112
  : 0;
113
+ // BLI-3718: a candidate that collided with another live memory's custom_id
114
+ // is counted SEPARATELY from `written` — it is neither a save nor "nothing
115
+ // worth keeping", and folding it into the latter is exactly how a session's
116
+ // second-and-later Stop hooks reported `{"skipped":N}` at HTTP 200 while a
117
+ // fact silently never landed.
118
+ const conflicted = decisions
119
+ ? decisions.filter((decision) => decision["reason"] === "custom_id_conflict").length
120
+ : 0;
121
+ if (written > 0) {
122
+ return {
123
+ status: "ok",
124
+ reason: String(response.body["outcome"] ?? "saved"),
125
+ stdout: "",
126
+ saved: written,
127
+ conflicts: conflicted,
128
+ chars: masked.text.length,
129
+ masked: masked.masked,
130
+ };
131
+ }
86
132
  return {
87
- status: written > 0 ? "ok" : "empty",
88
- reason: written > 0 ? String(response.body["outcome"] ?? "saved") : "nothing_worth_keeping",
133
+ status: "empty",
134
+ reason: conflicted > 0 ? "custom_id_conflict" : "nothing_worth_keeping",
89
135
  stdout: "",
90
- saved: written,
136
+ saved: 0,
137
+ conflicts: conflicted,
91
138
  chars: masked.text.length,
92
139
  masked: masked.masked,
93
140
  };
package/dist/server.d.ts CHANGED
@@ -36,7 +36,7 @@ import { z } from "zod";
36
36
  import { type FetchImpl } from "./door.js";
37
37
  import type { MemorySession } from "./session.js";
38
38
  export declare const PACKAGE_NAME = "@bli-cockpit/memory-mcp";
39
- export declare const PACKAGE_VERSION = "0.1.1";
39
+ export declare const PACKAGE_VERSION = "0.1.5";
40
40
  /**
41
41
  * The server id an MCP client registers. Defined in `print-config.ts` and
42
42
  * re-exported here: `index.ts` needs the name on the hook and print-config
package/dist/server.js CHANGED
@@ -37,7 +37,10 @@ import { resolveContainerTag } from "./container-tag.js";
37
37
  import { postMemoryDoor } from "./door.js";
38
38
  import { SERVER_NAME } from "./print-config.js";
39
39
  export const PACKAGE_NAME = "@bli-cockpit/memory-mcp";
40
- export const PACKAGE_VERSION = "0.1.1";
40
+ // BLI-3713: this constant had drifted from package.json's real version
41
+ // (0.1.1 vs 0.1.4) — nobody had bumped it alongside a version bump before.
42
+ // Kept in lockstep with package.json's "version" field going forward.
43
+ export const PACKAGE_VERSION = "0.1.5";
41
44
  /**
42
45
  * The server id an MCP client registers. Defined in `print-config.ts` and
43
46
  * re-exported here: `index.ts` needs the name on the hook and print-config
@@ -226,6 +229,12 @@ export function createServer(deps) {
226
229
  containerTag,
227
230
  customId: args.customId,
228
231
  mode: args.extract ? "extract" : "verbatim",
232
+ // BLI-3730: `extract` is queued by default (the Stop hook cannot hold
233
+ // a connection through two model calls). An agent that ASKED for
234
+ // distillation is a different caller: it is waiting for the answer and
235
+ // its host allows far longer than a Stop hook does, so this tool takes
236
+ // the synchronous branch and can still report what was written.
237
+ ...(args.extract ? { wait: true } : {}),
229
238
  sourceRef: { kind: "mcp", device_id: deps.session.deviceId },
230
239
  });
231
240
  if (!response.ok)
@@ -241,7 +250,14 @@ export function createServer(deps) {
241
250
  const embedNote = response.body.embedded === false && response.body.embedSkippedReason
242
251
  ? ` It was stored without a search vector (${String(response.body.embedSkippedReason)}), so it is findable by keyword and not yet by meaning.`
243
252
  : "";
244
- return textResult(`Memory ${outcome} in ${landedIn} (id: ${id}).${embedNote}`, response.body);
253
+ // BLI-3713: `mode: "extract"` can fall back to a verbatim save when
254
+ // distillation fails (a bad model answer must never mean the content is
255
+ // dropped) — say so plainly rather than let the caller believe atomic
256
+ // facts were written when the whole note was stored as one row instead.
257
+ const extractNote = response.body.extract === "fell_back_verbatim"
258
+ ? ` Saved as written; extraction failed (${String(response.body.extractFailureReason ?? "unknown")}).`
259
+ : "";
260
+ return textResult(`Memory ${outcome} in ${landedIn} (id: ${id}).${embedNote}${extractNote}`, response.body);
245
261
  });
246
262
  server.registerTool("update_memory", {
247
263
  title: "Correct a memory",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/memory-mcp",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",