@bli-cockpit/memory-mcp 0.1.5 → 0.1.7

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,9 +101,9 @@ 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}`.
106
+ `[bli-memory] prompt failed {"reason":"dashboard_unreachable","elapsed_ms":1502,"deadline_ms":2000}`.
104
107
  `reason` on `skipped`/`failed` is always one of a closed set — the four
105
108
  network reasons `timeout`, `http_<status>`, `dashboard_unreachable`,
106
109
  `bad_response` (Tower answered with a body that was not readable JSON);
@@ -112,10 +115,12 @@ every machine:
112
115
  `redaction_failed`) — never a stack trace, never Tower's own error text,
113
116
  never a prompt, a memory, a path or a token. A reason `toLogReason` has not
114
117
  been taught becomes `unknown_internal:<name>`, never a silent
115
- `bad_response`. A `failed` outcome also gets a one-line `systemMessage` (the
118
+ `bad_response`. A `failed` **stop** also gets a one-line `systemMessage` (the
116
119
  same JSON-on-stdout shape the Supermemory plugin's own banner uses),
117
120
  because Claude Code does not surface a hook's stderr anywhere a person
118
- reads it.
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.
119
124
 
120
125
  `hook stop` sends the last user/assistant exchange (at most 12 KB, tail-read so
121
126
  a 40 MB transcript costs nothing) through the collector's own redactor —
@@ -124,6 +129,15 @@ that masks every uploaded transcript — and lets the librarian's
124
129
  extract/reconcile decide what is worth keeping. A re-entrant Stop
125
130
  (`stop_hook_active: true`) saves nothing.
126
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
+
127
141
  ## Auth
128
142
 
129
143
  None to set up. The server reads the collector's existing paired session at
@@ -101,6 +101,14 @@ export interface HookOutcome {
101
101
  * HTTP 200.
102
102
  */
103
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;
104
112
  }
105
113
  /** The container this hook's memories live in, and how it was derived. */
106
114
  export interface HookContainer {
@@ -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. */
@@ -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`,
@@ -81,13 +82,14 @@ export declare function toLogReason(reason: string): HookLogReason;
81
82
  */
82
83
  export declare function buildLogLine(event: HookEvent, outcome: HookOutcome, elapsedMs: number, deadlineMs: number): string;
83
84
  /**
84
- * One short sentence for a `failed` outcome only, in the JSON envelope the
85
- * 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
86
87
  * Supermemory mechanism cited in this file's header). `null` on every other
87
88
  * branch: `ok`/`empty` already say their piece through the injected block,
88
- * 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).
89
91
  */
90
- export declare function buildSystemMessage(outcome: HookOutcome): string | null;
92
+ export declare function buildSystemMessage(event: HookEvent, outcome: HookOutcome): string | null;
91
93
  /** Renders one JSON block to stdout — the only shape a hook may emit there
92
94
  * besides plain injected context. Used by `run.ts` on `failed` only, so the
93
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`,
@@ -138,21 +139,35 @@ export function buildLogLine(event, outcome, elapsedMs, deadlineMs) {
138
139
  // nothing AND conflicted with nothing is a materially different
139
140
  // fact from one that conflicted on every candidate.
140
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 }),
141
147
  elapsed_ms: elapsedMs,
142
148
  deadline_ms: deadlineMs,
143
149
  };
144
150
  return `${HOOK_LOG_TAG} ${event} ${word} ${JSON.stringify(fields)}\n`;
145
151
  }
146
152
  /**
147
- * One short sentence for a `failed` outcome only, in the JSON envelope the
148
- * 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
149
155
  * Supermemory mechanism cited in this file's header). `null` on every other
150
156
  * branch: `ok`/`empty` already say their piece through the injected block,
151
- * 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).
152
159
  */
153
- export function buildSystemMessage(outcome) {
160
+ export function buildSystemMessage(event, outcome) {
154
161
  if (outcome.status !== "failed")
155
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;
156
171
  return `BLI Memory: ${failureSentence(toLogReason(outcome.reason))}`;
157
172
  }
158
173
  function failureSentence(reason) {
@@ -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,6 +82,25 @@ export async function runStopHook(context, options = {}) {
75
82
  if (!response.ok) {
76
83
  return { status: "failed", reason: doorReason(response), stdout: "", saved: 0 };
77
84
  }
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
+ }
78
104
  const decisionsRaw = response.body["decisions"];
79
105
  const decisions = Array.isArray(decisionsRaw)
80
106
  ? decisionsRaw.filter((decision) => Boolean(decision) && typeof decision === "object")
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The last exchange out of a Claude Code transcript (BLI-3580).
2
+ * The last exchange out of an agent host's transcript (BLI-3580; Codex added
3
+ * by BLI-3729).
3
4
  *
4
5
  * The Stop hook is handed `transcript_path`: a JSONL file, one record per line,
5
6
  * appended to for the whole session. What the save door wants is the LAST
@@ -19,6 +20,31 @@
19
20
  * A record shape that is not recognised is skipped, never guessed at. The
20
21
  * reader returns null rather than half an exchange: half a turn saved as a
21
22
  * memory is a memory that says something the person did not.
23
+ *
24
+ * ## Two hosts, two record shapes (BLI-3729)
25
+ *
26
+ * `cockpit memory install` now registers the same three hooks with Codex, and
27
+ * Codex hands its Stop hook a `transcript_path` too — pointing at its own
28
+ * rollout JSONL, whose records are shaped differently:
29
+ *
30
+ * Claude Code { "message": { "role": "user", "content": [{ "type": "text", … }] } }
31
+ * Codex { "type": "response_item",
32
+ * "payload": { "type": "message", "role": "user",
33
+ * "content": [{ "type": "input_text", "text": … }] } }
34
+ *
35
+ * Both are read here, by the same backwards walk, because the alternative was a
36
+ * Codex Stop hook that fired on every turn and reported `transcript_no_exchange`
37
+ * every time — a hook that does nothing and says it did nothing. The Codex
38
+ * shape was captured from a real rollout file on 2026-09-05 rather than read
39
+ * off a doc, and the fixture in `hooks.test.ts` keeps the record structure
40
+ * verbatim with only the `text` values replaced (the real ones are somebody's
41
+ * conversation).
42
+ *
43
+ * `assistant` content blocks are `output_text` and `user` blocks are
44
+ * `input_text`; both are accepted alongside Claude's `text`. A `reasoning`,
45
+ * `custom_tool_call` or `event_msg` record contributes nothing, which is the
46
+ * same rule Claude's tool results already follow — a memory made of tool output
47
+ * is the transcript dump this repo refuses.
22
48
  */
23
49
  /** How much of the file's end is read. Big enough for a long final turn. */
24
50
  export declare const TAIL_BYTES: number;
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The last exchange out of a Claude Code transcript (BLI-3580).
2
+ * The last exchange out of an agent host's transcript (BLI-3580; Codex added
3
+ * by BLI-3729).
3
4
  *
4
5
  * The Stop hook is handed `transcript_path`: a JSONL file, one record per line,
5
6
  * appended to for the whole session. What the save door wants is the LAST
@@ -19,6 +20,31 @@
19
20
  * A record shape that is not recognised is skipped, never guessed at. The
20
21
  * reader returns null rather than half an exchange: half a turn saved as a
21
22
  * memory is a memory that says something the person did not.
23
+ *
24
+ * ## Two hosts, two record shapes (BLI-3729)
25
+ *
26
+ * `cockpit memory install` now registers the same three hooks with Codex, and
27
+ * Codex hands its Stop hook a `transcript_path` too — pointing at its own
28
+ * rollout JSONL, whose records are shaped differently:
29
+ *
30
+ * Claude Code { "message": { "role": "user", "content": [{ "type": "text", … }] } }
31
+ * Codex { "type": "response_item",
32
+ * "payload": { "type": "message", "role": "user",
33
+ * "content": [{ "type": "input_text", "text": … }] } }
34
+ *
35
+ * Both are read here, by the same backwards walk, because the alternative was a
36
+ * Codex Stop hook that fired on every turn and reported `transcript_no_exchange`
37
+ * every time — a hook that does nothing and says it did nothing. The Codex
38
+ * shape was captured from a real rollout file on 2026-09-05 rather than read
39
+ * off a doc, and the fixture in `hooks.test.ts` keeps the record structure
40
+ * verbatim with only the `text` values replaced (the real ones are somebody's
41
+ * conversation).
42
+ *
43
+ * `assistant` content blocks are `output_text` and `user` blocks are
44
+ * `input_text`; both are accepted alongside Claude's `text`. A `reasoning`,
45
+ * `custom_tool_call` or `event_msg` record contributes nothing, which is the
46
+ * same rule Claude's tool results already follow — a memory made of tool output
47
+ * is the transcript dump this repo refuses.
22
48
  */
23
49
  import fs from "node:fs";
24
50
  import { STOP_CHUNK_CHARS } from "./contract.js";
@@ -123,8 +149,8 @@ function parseJsonlTail(raw) {
123
149
  function roleOf(record) {
124
150
  if (!record)
125
151
  return "";
126
- const message = record["message"];
127
- if (message && typeof message === "object" && !Array.isArray(message)) {
152
+ const message = messageOf(record);
153
+ if (message) {
128
154
  const role = message["role"];
129
155
  if (typeof role === "string")
130
156
  return role;
@@ -133,18 +159,32 @@ function roleOf(record) {
133
159
  return typeof type === "string" ? type : "";
134
160
  }
135
161
  /**
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.
162
+ * The record's message envelope, whichever host wrote it: Claude Code's
163
+ * `message`, Codex's `payload`, or the record itself when it carries `role`
164
+ * directly. Returns null for a record with no envelope at all, which is how a
165
+ * Codex `reasoning` or `event_msg` line drops out without a special case.
166
+ */
167
+ function messageOf(record) {
168
+ for (const key of ["message", "payload"]) {
169
+ const value = record[key];
170
+ if (value && typeof value === "object" && !Array.isArray(value)) {
171
+ return value;
172
+ }
173
+ }
174
+ return null;
175
+ }
176
+ /** The content block types that carry a person's or the model's own words. */
177
+ const TEXT_BLOCK_TYPES = new Set(["text", "input_text", "output_text"]);
178
+ /**
179
+ * The text of a record, whichever of the shapes it uses: a string `content`, an
180
+ * array of blocks whose `type` is one of `TEXT_BLOCK_TYPES`, or a bare `text`.
181
+ * A tool result or an image block contributes nothing, on purpose — a memory
182
+ * made of tool output is the transcript dump this repo refuses.
140
183
  */
141
184
  function textOf(record) {
142
185
  if (!record)
143
186
  return "";
144
- const message = record["message"];
145
- const source = message && typeof message === "object" && !Array.isArray(message)
146
- ? message
147
- : record;
187
+ const source = messageOf(record) ?? record;
148
188
  const content = source["content"] ?? source["text"];
149
189
  if (typeof content === "string")
150
190
  return content.trim();
@@ -159,7 +199,9 @@ function textOf(record) {
159
199
  if (!block || typeof block !== "object")
160
200
  continue;
161
201
  const record_ = block;
162
- if (record_["type"] !== "text")
202
+ if (typeof record_["type"] !== "string")
203
+ continue;
204
+ if (!TEXT_BLOCK_TYPES.has(record_["type"]))
163
205
  continue;
164
206
  const text = record_["text"];
165
207
  if (typeof text === "string")
package/dist/server.js CHANGED
@@ -229,6 +229,12 @@ export function createServer(deps) {
229
229
  containerTag,
230
230
  customId: args.customId,
231
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 } : {}),
232
238
  sourceRef: { kind: "mcp", device_id: deps.session.deviceId },
233
239
  });
234
240
  if (!response.ok)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/memory-mcp",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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.28",
31
+ "@bli-cockpit/telemetry-core": "0.1.29",
32
32
  "@modelcontextprotocol/sdk": "^1.29.0",
33
33
  "zod": "^4.3.6"
34
34
  },