@bli-cockpit/memory-mcp 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,23 +18,94 @@ keeps working. `update_memory` is new — the vendor had no update tool at all,
18
18
  and correction there meant forget-then-save, which loses the link between the
19
19
  two.
20
20
 
21
+ Plus three Claude Code hooks, on the same bin — see below.
22
+
21
23
  ## Install
22
24
 
25
+ **Nobody installs this by hand.** It ships as a dependency of
26
+ `@bli-cockpit/cli`, and `cockpit memory install` (which `cockpit do-everything`
27
+ and the daily sync tick both run) registers it with both hosts. A standalone
28
+ install is for development:
29
+
23
30
  ```bash
24
31
  npm i -g @bli-cockpit/memory-mcp
25
32
  ```
26
33
 
27
- Then register it. The server prints the exact block each host wants, so nothing
28
- has to be retyped:
34
+ The server prints the exact block each host wants, so nothing has to be retyped:
29
35
 
30
36
  ```bash
31
- bli-memory-mcp --print-config # both
32
- bli-memory-mcp --print-config=claude # ~/.claude.json (or a plugin's .mcp.json)
37
+ bli-memory-mcp --print-config # both, with headings
38
+ bli-memory-mcp --print-config=claude # the install contract (see below)
33
39
  bli-memory-mcp --print-config=codex # ~/.codex/config.toml
34
40
  ```
35
41
 
36
- `cockpit memory install` (a later slice of BLI-3580) consumes those two blocks
37
- verbatim rather than keeping its own copy of them.
42
+ `--print-config --claude` prints the **install contract**,
43
+ `bli-memory-install-config.v1` the MCP server entry *and* the three hooks with
44
+ their timeouts, because this bin is the only thing that knows which hook
45
+ subcommands it implements:
46
+
47
+ ```json
48
+ {
49
+ "schema": "bli-memory-install-config.v1",
50
+ "server_id": "bli-memory",
51
+ "mcp_server": { "command": "bli-memory-mcp", "args": [], "env": {} },
52
+ "hooks": [
53
+ { "event": "SessionStart", "command": "bli-memory-mcp hook session-start", "timeout_seconds": 30 },
54
+ { "event": "UserPromptSubmit", "command": "bli-memory-mcp hook prompt", "timeout_seconds": 5 },
55
+ { "event": "Stop", "command": "bli-memory-mcp hook stop", "timeout_seconds": 30 }
56
+ ],
57
+ "permissions_allow": ["mcp__bli-memory__search_memory"]
58
+ }
59
+ ```
60
+
61
+ `command` is the bare bin name; `cockpit memory install` replaces it with the
62
+ absolute path it resolved on that machine, in every one of those strings. It is
63
+ never `npx -y @bli-cockpit/memory-mcp`, which would re-resolve the package from
64
+ the registry on every agent launch.
65
+
66
+ By hand: `mcp_server` goes under `mcpServers.bli-memory` in `~/.claude.json`,
67
+ each hook under `hooks.<event>` in `~/.claude/settings.json`, and the bin path
68
+ has to be absolute or on your PATH.
69
+
70
+ ## The three hooks
71
+
72
+ ```bash
73
+ bli-memory-mcp hook session-start # SessionStart
74
+ bli-memory-mcp hook prompt # UserPromptSubmit
75
+ bli-memory-mcp hook stop # Stop
76
+ ```
77
+
78
+ Each reads Claude Code's JSON payload on stdin and:
79
+
80
+ | Hook | Reads | Calls | Prints |
81
+ |---|---|---|---|
82
+ | `session-start` | `cwd` | `POST /api/memory/v4/profile` | `<bli-memory-context>` with User Profile + Recent Context, `◪` per line |
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 |
85
+
86
+ Four rules they all keep, because a hook runs on every turn of every session on
87
+ every machine:
88
+
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.
93
+ - **Always exit 0.** Claude Code reads exit 2 as "block this prompt" and any
94
+ other non-zero as an error it shows the person. An unpaired machine, an
95
+ outage, a malformed payload: all quiet in the transcript.
96
+ - **Print nothing, or one whole block.** No "no memories found" line — the model
97
+ would reason about it, and an empty record and an unreachable store would look
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.
102
+
103
+ `hook stop` sends the last user/assistant exchange (at most 12 KB, tail-read so
104
+ a 40 MB transcript costs nothing) through the collector's own redactor —
105
+ `redactSecretLikeContent` from `@bli-cockpit/telemetry-core`, the same function
106
+ that masks every uploaded transcript — and lets the librarian's
107
+ extract/reconcile decide what is worth keeping. A re-entrant Stop
108
+ (`stop_hook_active: true`) saves nothing.
38
109
 
39
110
  ## Auth
40
111
 
@@ -61,6 +132,14 @@ memories it has today. The **remote** is the identity, so every worktree of one
61
132
  repo shares a space. A folder that is not a git repository still gets its own
62
133
  space (`dir_<name>__<sha16>`), because a folder is a legitimate workspace.
63
134
 
135
+ One repository can still have TWO tags on the shelf — the vendor's, kept
136
+ verbatim when its memories were imported, and the one derived above. Tower
137
+ reconciles them server-side (`agent_memory_container_aliases`, BLI-3620):
138
+ `search_memory` is answered from every tag that names the same space, and
139
+ `save_memory` lands in the canonical one. That is why a saved memory can report
140
+ a space you did not pass — the tool names where the row actually went, not what
141
+ it asked for.
142
+
64
143
  ## The stdio contract
65
144
 
66
145
  **stdout carries the JSON-RPC stream and nothing else.** Every operational
@@ -34,6 +34,20 @@ export interface ContainerTagOptions {
34
34
  /** Injected in tests. Returns stdout, or null when git said no. */
35
35
  git?: (args: string[], cwd: string) => string | null;
36
36
  realpath?: (value: string) => string;
37
+ /**
38
+ * Ceiling on ONE `git` call, in ms. Default 5 s, which is right for the MCP
39
+ * server (it resolves the tag once, at startup, before any client is
40
+ * waiting).
41
+ *
42
+ * The hooks pass a much smaller number, and the reason is that `spawnSync`
43
+ * BLOCKS the event loop: a git that stalls on a network filesystem or a held
44
+ * index lock would sit here past the hook's own deadline, and the deadline
45
+ * timer cannot fire while the loop is blocked. That is the same class of hang
46
+ * this whole slice exists to remove, so the budget has to reach the syscall
47
+ * that can actually stall. Two calls happen at most, so the worst case is
48
+ * twice this.
49
+ */
50
+ gitTimeoutMs?: number;
37
51
  }
38
52
  export interface ContainerTagResult {
39
53
  containerTag: string;
@@ -47,3 +61,4 @@ export declare function resolveContainerTag(options?: ContainerTagOptions): Cont
47
61
  * `https://github.com/org/repo` are one identity, which is the point.
48
62
  */
49
63
  export declare function normalizeRemote(remote: string): string;
64
+ export declare const DEFAULT_GIT_TIMEOUT_MS = 5000;
@@ -35,7 +35,8 @@ export const CONTAINER_TAG_MAX_NAME = 72;
35
35
  export function resolveContainerTag(options = {}) {
36
36
  const env = options.env ?? process.env;
37
37
  const cwd = options.cwd ?? process.cwd();
38
- const git = options.git ?? runGit;
38
+ const gitTimeoutMs = options.gitTimeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
39
+ const git = options.git ?? ((args, dir) => runGit(args, dir, gitTimeoutMs));
39
40
  const realpath = options.realpath ?? ((value) => fs.realpathSync(value));
40
41
  const override = (env.BLI_MEMORY_CONTAINER_TAG ?? env.SUPERMEMORY_REPO_TAG ?? "").trim();
41
42
  if (override.length > 0)
@@ -93,13 +94,14 @@ function safeRealpath(value, realpath) {
93
94
  return value;
94
95
  }
95
96
  }
96
- function runGit(args, cwd) {
97
+ export const DEFAULT_GIT_TIMEOUT_MS = 5_000;
98
+ function runGit(args, cwd, timeoutMs) {
97
99
  const result = spawnSync("git", args, {
98
100
  cwd,
99
101
  encoding: "utf8",
100
102
  shell: false,
101
103
  windowsHide: true,
102
- timeout: 5_000,
104
+ timeout: timeoutMs,
103
105
  });
104
106
  if (result.status !== 0 || typeof result.stdout !== "string")
105
107
  return null;
package/dist/door.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * One way to knock on a Tower memory door (BLI-3580).
3
+ *
4
+ * The MCP server and the three Claude Code hooks both POST to
5
+ * `/api/memory/*` with this machine's collector device token. That was one
6
+ * implementation living privately inside `server.ts` until the hooks needed the
7
+ * same thing with a deadline on it; two copies of an auth header and an
8
+ * error-shape reader is how one of them ends up sending the token the wrong way
9
+ * a release later, so it lives here once.
10
+ *
11
+ * Two contracts this module keeps:
12
+ *
13
+ * - **A transport failure is not an empty answer.** `transportError` is set
14
+ * ONLY when no JSON answer was produced at all — DNS, TLS, a timeout, a body
15
+ * that is not JSON. A 404 with a reason in it is a refusal, not an outage,
16
+ * and the caller must be able to tell those apart (`server.ts` turns one
17
+ * into `isError`; a hook prints nothing and logs the reason).
18
+ * - **The token never travels into a message.** It goes in one header and is
19
+ * never logged, echoed, or included in an error string.
20
+ *
21
+ * `timeoutMs` exists for the hooks. Claude Code kills a hook that outlives its
22
+ * configured timeout, and a hook killed mid-`fetch` reports nothing at all — so
23
+ * every hook budgets its own request below its own deadline and aborts rather
24
+ * than being killed.
25
+ */
26
+ import type { MemorySession } from "./session.js";
27
+ export type FetchImpl = typeof fetch;
28
+ export interface DoorResponse {
29
+ ok: boolean;
30
+ status: number;
31
+ body: Record<string, unknown>;
32
+ /** Set when the request never produced a JSON answer at all. */
33
+ transportError: string | null;
34
+ }
35
+ export interface DoorRequest {
36
+ session: MemorySession;
37
+ fetchImpl: FetchImpl;
38
+ /** A leading-slash path, e.g. `/api/memory/search`. */
39
+ path: string;
40
+ body: Record<string, unknown>;
41
+ /** Abort the request after this many ms. Omitted means no deadline. */
42
+ timeoutMs?: number;
43
+ }
44
+ 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;
package/dist/door.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * One way to knock on a Tower memory door (BLI-3580).
3
+ *
4
+ * The MCP server and the three Claude Code hooks both POST to
5
+ * `/api/memory/*` with this machine's collector device token. That was one
6
+ * implementation living privately inside `server.ts` until the hooks needed the
7
+ * same thing with a deadline on it; two copies of an auth header and an
8
+ * error-shape reader is how one of them ends up sending the token the wrong way
9
+ * a release later, so it lives here once.
10
+ *
11
+ * Two contracts this module keeps:
12
+ *
13
+ * - **A transport failure is not an empty answer.** `transportError` is set
14
+ * ONLY when no JSON answer was produced at all — DNS, TLS, a timeout, a body
15
+ * that is not JSON. A 404 with a reason in it is a refusal, not an outage,
16
+ * and the caller must be able to tell those apart (`server.ts` turns one
17
+ * into `isError`; a hook prints nothing and logs the reason).
18
+ * - **The token never travels into a message.** It goes in one header and is
19
+ * never logged, echoed, or included in an error string.
20
+ *
21
+ * `timeoutMs` exists for the hooks. Claude Code kills a hook that outlives its
22
+ * configured timeout, and a hook killed mid-`fetch` reports nothing at all — so
23
+ * every hook budgets its own request below its own deadline and aborts rather
24
+ * than being killed.
25
+ */
26
+ export async function postMemoryDoor(request) {
27
+ const url = `${request.session.dashboardUrl}${request.path}`;
28
+ const controller = typeof request.timeoutMs === "number" && request.timeoutMs > 0
29
+ ? new AbortController()
30
+ : null;
31
+ const timer = controller && typeof request.timeoutMs === "number"
32
+ ? setTimeout(() => controller.abort(), request.timeoutMs)
33
+ : null;
34
+ // Never keep the process alive for a deadline timer: a hook that has already
35
+ // printed its answer must exit, not linger until the abort fires.
36
+ timer?.unref?.();
37
+ let response;
38
+ try {
39
+ response = await request.fetchImpl(url, {
40
+ method: "POST",
41
+ headers: {
42
+ // The device token. Never logged, never echoed into a message.
43
+ authorization: `Bearer ${request.session.deviceToken}`,
44
+ "content-type": "application/json",
45
+ accept: "application/json",
46
+ },
47
+ body: JSON.stringify(request.body),
48
+ ...(controller ? { signal: controller.signal } : {}),
49
+ });
50
+ }
51
+ catch (error) {
52
+ return {
53
+ ok: false,
54
+ status: 0,
55
+ body: {},
56
+ transportError: describeTransportError(error, request.timeoutMs),
57
+ };
58
+ }
59
+ finally {
60
+ if (timer)
61
+ clearTimeout(timer);
62
+ }
63
+ let parsed = {};
64
+ try {
65
+ const text = await response.text();
66
+ parsed = text ? JSON.parse(text) : {};
67
+ }
68
+ catch {
69
+ return {
70
+ ok: false,
71
+ status: response.status,
72
+ body: {},
73
+ transportError: `Tower answered ${response.status} with a body this server could not read as JSON.`,
74
+ };
75
+ }
76
+ return { ok: response.ok, status: response.status, body: parsed, transportError: null };
77
+ }
78
+ /**
79
+ * 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.
81
+ */
82
+ function describeTransportError(error, timeoutMs) {
83
+ const name = error?.name;
84
+ if (name === "AbortError" || name === "TimeoutError") {
85
+ return timeoutMs
86
+ ? `Tower did not answer within ${timeoutMs} ms; the request was abandoned.`
87
+ : "The request was aborted before Tower answered.";
88
+ }
89
+ return error instanceof Error ? (error.message.split("\n")[0] ?? "unknown") : String(error);
90
+ }
91
+ /** The reason label for a failed door call: the refusal's own, or `transport`. */
92
+ 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();
98
+ return `http_${response.status}`;
99
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The three Claude Code hooks — the vocabulary they all share (BLI-3580).
3
+ *
4
+ * `cockpit memory install` registers three hook commands on every machine
5
+ * (`memory-install-contract.ts`):
6
+ *
7
+ * SessionStart bli-memory-mcp hook session-start timeout 30 s
8
+ * UserPromptSubmit bli-memory-mcp hook prompt timeout 5 s
9
+ * Stop bli-memory-mcp hook stop timeout 30 s
10
+ *
11
+ * **A hook that hangs is worse than a hook that does nothing.** Claude Code
12
+ * runs `UserPromptSubmit` on every prompt a person types and waits for it, so a
13
+ * hook that blocks on stdin, or on a dashboard that is not answering, costs
14
+ * every person on the fleet its timeout on every turn. That is the failure this
15
+ * file's budgets exist to make impossible: each event has a TOTAL deadline
16
+ * strictly below the installer's timeout for it, a stdin read deadline inside
17
+ * that, and a request deadline inside that. On any deadline the hook prints
18
+ * nothing and exits 0.
19
+ *
20
+ * **Exit 0, always.** Claude Code treats exit 2 as "block this" and any other
21
+ * non-zero as an error it shows the person. Neither is ever the right answer
22
+ * for a memory recall: an unpaired machine, an outage or a malformed payload
23
+ * must be invisible in the transcript and visible in stderr. `hook.ts` is the
24
+ * one place that decides the exit code, and it only ever decides 0.
25
+ *
26
+ * **Print nothing, or print exactly one block.** stdout of a SessionStart or
27
+ * UserPromptSubmit hook is injected into the model's context verbatim, so an
28
+ * empty result prints NOTHING rather than a "no memories found" line the model
29
+ * would then reason about.
30
+ */
31
+ /** The three subcommands, exactly as the installer writes them. */
32
+ export declare const HOOK_EVENTS: readonly ["session-start", "prompt", "stop"];
33
+ export type HookEvent = (typeof HOOK_EVENTS)[number];
34
+ export interface HookBudget {
35
+ /** The whole run. Below the installer's timeout for this event, always. */
36
+ totalMs: number;
37
+ /** Waiting for Claude Code's payload on stdin. */
38
+ stdinMs: number;
39
+ /** One door call. */
40
+ requestMs: number;
41
+ /**
42
+ * One `git` call while deriving the container tag. It has its own budget
43
+ * because `resolveContainerTag` uses `spawnSync`, which BLOCKS the event
44
+ * loop — the total deadline below cannot fire while git is stalling, so this
45
+ * is the only bound that reaches it. At most two calls happen.
46
+ */
47
+ gitMs: number;
48
+ }
49
+ /**
50
+ * Deadlines, in ms, against the installer's timeouts of 30 s / 5 s / 30 s.
51
+ *
52
+ * Each total leaves the host real headroom rather than racing it: a hook killed
53
+ * by its host reports NOTHING — no stderr line, no reason — and that is exactly
54
+ * the kind of silence this repo's logging rule exists to prevent. Better to
55
+ * abandon the recall ourselves and say why.
56
+ */
57
+ export declare const HOOK_BUDGETS: Record<HookEvent, HookBudget>;
58
+ /**
59
+ * Claude Code's hook payload, as documented, with every field optional because
60
+ * a hook must survive a host that adds, renames or omits one.
61
+ *
62
+ * SessionStart { session_id, transcript_path, cwd, hook_event_name, source }
63
+ * UserPromptSubmit … + { prompt }
64
+ * Stop … + { stop_hook_active }
65
+ */
66
+ export interface HookPayload {
67
+ session_id?: string;
68
+ transcript_path?: string;
69
+ cwd?: string;
70
+ hook_event_name?: string;
71
+ source?: string;
72
+ prompt?: string;
73
+ stop_hook_active?: boolean;
74
+ }
75
+ export type HookStatus =
76
+ /** Something was printed. */
77
+ "ok"
78
+ /** Ran fine, had nothing to print. Zero hits is this, not a failure. */
79
+ | "empty"
80
+ /** Deliberately did not run: no payload, no session, re-entrant Stop. */
81
+ | "skipped"
82
+ /** Tried and could not: an outage, a refusal, a deadline. */
83
+ | "failed";
84
+ export interface HookOutcome {
85
+ status: HookStatus;
86
+ /** Named on every branch, success included. Never a bare "failed". */
87
+ reason: string;
88
+ /** Printed to stdout, or empty. Never partial. */
89
+ stdout: string;
90
+ /** Counts for the receipt line. Metadata only — never any text. */
91
+ hits?: number;
92
+ saved?: number;
93
+ chars?: number;
94
+ /** How many secret-like spans were masked before a save. A count, never a value. */
95
+ masked?: number;
96
+ }
97
+ /** The container this hook's memories live in, and how it was derived. */
98
+ export interface HookContainer {
99
+ containerTag: string;
100
+ basis: string;
101
+ }
102
+ /** The wrappers Claude Code injects verbatim. Kept in one place, tested. */
103
+ export declare const CONTEXT_BLOCK: {
104
+ readonly open: "<bli-memory-context>";
105
+ readonly close: "</bli-memory-context>";
106
+ };
107
+ export declare const RECALL_BLOCK: {
108
+ readonly open: "<bli-memory-recall>";
109
+ readonly close: "</bli-memory-recall>";
110
+ };
111
+ /** The vendor plugin's bullet, kept byte for byte so the injection reads the same. */
112
+ export declare const BULLET = "\u25EA";
113
+ /** The per-prompt recall shape the `/v4/profile` shim documents: top 5, ≥ 0.55. */
114
+ export declare const RECALL_MIN_SIMILARITY = 0.55;
115
+ export declare const RECALL_LIMIT = 5;
116
+ /** One recalled line, capped. The vendor truncated at 300 characters. */
117
+ export declare const RECALL_LINE_CHARS = 300;
118
+ /** The search door refuses a query over 1000 characters; a long prompt is trimmed, not dropped. */
119
+ export declare const MAX_QUERY_CHARS = 1000;
120
+ /** At most the final 12 KB of the last exchange travels to `save`. */
121
+ export declare const STOP_CHUNK_CHARS = 12000;
122
+ export declare function hookEventFromArgv(argv: readonly string[]): HookEvent | null;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The three Claude Code hooks — the vocabulary they all share (BLI-3580).
3
+ *
4
+ * `cockpit memory install` registers three hook commands on every machine
5
+ * (`memory-install-contract.ts`):
6
+ *
7
+ * SessionStart bli-memory-mcp hook session-start timeout 30 s
8
+ * UserPromptSubmit bli-memory-mcp hook prompt timeout 5 s
9
+ * Stop bli-memory-mcp hook stop timeout 30 s
10
+ *
11
+ * **A hook that hangs is worse than a hook that does nothing.** Claude Code
12
+ * runs `UserPromptSubmit` on every prompt a person types and waits for it, so a
13
+ * hook that blocks on stdin, or on a dashboard that is not answering, costs
14
+ * every person on the fleet its timeout on every turn. That is the failure this
15
+ * file's budgets exist to make impossible: each event has a TOTAL deadline
16
+ * strictly below the installer's timeout for it, a stdin read deadline inside
17
+ * that, and a request deadline inside that. On any deadline the hook prints
18
+ * nothing and exits 0.
19
+ *
20
+ * **Exit 0, always.** Claude Code treats exit 2 as "block this" and any other
21
+ * non-zero as an error it shows the person. Neither is ever the right answer
22
+ * for a memory recall: an unpaired machine, an outage or a malformed payload
23
+ * must be invisible in the transcript and visible in stderr. `hook.ts` is the
24
+ * one place that decides the exit code, and it only ever decides 0.
25
+ *
26
+ * **Print nothing, or print exactly one block.** stdout of a SessionStart or
27
+ * UserPromptSubmit hook is injected into the model's context verbatim, so an
28
+ * empty result prints NOTHING rather than a "no memories found" line the model
29
+ * would then reason about.
30
+ */
31
+ /** The three subcommands, exactly as the installer writes them. */
32
+ export const HOOK_EVENTS = ["session-start", "prompt", "stop"];
33
+ /**
34
+ * Deadlines, in ms, against the installer's timeouts of 30 s / 5 s / 30 s.
35
+ *
36
+ * Each total leaves the host real headroom rather than racing it: a hook killed
37
+ * by its host reports NOTHING — no stderr line, no reason — and that is exactly
38
+ * the kind of silence this repo's logging rule exists to prevent. Better to
39
+ * abandon the recall ourselves and say why.
40
+ */
41
+ export const HOOK_BUDGETS = {
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.
47
+ stop: { totalMs: 15_000, stdinMs: 3_000, requestMs: 13_000, gitMs: 2_500 },
48
+ };
49
+ /** The wrappers Claude Code injects verbatim. Kept in one place, tested. */
50
+ export const CONTEXT_BLOCK = {
51
+ open: "<bli-memory-context>",
52
+ close: "</bli-memory-context>",
53
+ };
54
+ export const RECALL_BLOCK = {
55
+ open: "<bli-memory-recall>",
56
+ close: "</bli-memory-recall>",
57
+ };
58
+ /** The vendor plugin's bullet, kept byte for byte so the injection reads the same. */
59
+ export const BULLET = "◪";
60
+ /** The per-prompt recall shape the `/v4/profile` shim documents: top 5, ≥ 0.55. */
61
+ export const RECALL_MIN_SIMILARITY = 0.55;
62
+ export const RECALL_LIMIT = 5;
63
+ /** One recalled line, capped. The vendor truncated at 300 characters. */
64
+ export const RECALL_LINE_CHARS = 300;
65
+ /** The search door refuses a query over 1000 characters; a long prompt is trimmed, not dropped. */
66
+ export const MAX_QUERY_CHARS = 1_000;
67
+ /** At most the final 12 KB of the last exchange travels to `save`. */
68
+ export const STOP_CHUNK_CHARS = 12_000;
69
+ export function hookEventFromArgv(argv) {
70
+ const value = (argv[0] ?? "").trim();
71
+ return HOOK_EVENTS.includes(value) ? value : null;
72
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `bli-memory-mcp hook prompt` (BLI-3580).
3
+ *
4
+ * UserPromptSubmit fires on EVERY prompt a person types, and Claude Code waits
5
+ * for it. This is the hook whose budget matters most: 3 s total against the
6
+ * installer's 5 s timeout, one search, print or print nothing.
7
+ *
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.
11
+ *
12
+ * **Zero hits is data.** It prints nothing and exits 0 with `no_hits`. A search
13
+ * that could NOT run is a different line with a different reason, because an
14
+ * agent (or a person reading stderr) that cannot tell those apart will read an
15
+ * outage as an empty shelf — the wire contract the search door states in its
16
+ * own header.
17
+ */
18
+ import { type HookOutcome } from "./contract.js";
19
+ import type { HookRunContext } from "./run-context.js";
20
+ export declare function runPromptHook(context: HookRunContext): Promise<HookOutcome>;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `bli-memory-mcp hook prompt` (BLI-3580).
3
+ *
4
+ * UserPromptSubmit fires on EVERY prompt a person types, and Claude Code waits
5
+ * for it. This is the hook whose budget matters most: 3 s total against the
6
+ * installer's 5 s timeout, one search, print or print nothing.
7
+ *
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.
11
+ *
12
+ * **Zero hits is data.** It prints nothing and exits 0 with `no_hits`. A search
13
+ * that could NOT run is a different line with a different reason, because an
14
+ * agent (or a person reading stderr) that cannot tell those apart will read an
15
+ * outage as an empty shelf — the wire contract the search door states in its
16
+ * own header.
17
+ */
18
+ import { doorReason, postMemoryDoor } from "../door.js";
19
+ import { HOOK_BUDGETS, MAX_QUERY_CHARS, RECALL_LIMIT, RECALL_MIN_SIMILARITY, } from "./contract.js";
20
+ import { renderRecall } from "./render.js";
21
+ export async function runPromptHook(context) {
22
+ const budget = HOOK_BUDGETS.prompt;
23
+ const prompt = (context.payload.prompt ?? "").trim();
24
+ if (prompt.length === 0) {
25
+ return { status: "skipped", reason: "no_prompt", stdout: "" };
26
+ }
27
+ const response = await postMemoryDoor({
28
+ session: context.session,
29
+ fetchImpl: context.fetchImpl,
30
+ path: "/api/memory/search",
31
+ body: {
32
+ // The door refuses a query over 1000 characters. A long prompt is
33
+ // trimmed rather than dropped: its opening is what names the subject.
34
+ query: prompt.slice(0, MAX_QUERY_CHARS),
35
+ // A list of one, because the door takes a list — a second space (a
36
+ // person's, an org's) unions in later with no change on this wire.
37
+ containerTags: [context.container.containerTag],
38
+ limit: RECALL_LIMIT,
39
+ // The recency channel is a session-start concern; a per-turn recall that
40
+ // always carries the same newest rows trains the model to ignore it.
41
+ includeProfile: false,
42
+ },
43
+ timeoutMs: budget.requestMs,
44
+ });
45
+ if (!response.ok) {
46
+ return { status: "failed", reason: doorReason(response), stdout: "", hits: 0 };
47
+ }
48
+ const memories = readHits(response.body["results"]);
49
+ const degraded = typeof response.body["degraded"] === "string" && response.body["degraded"]
50
+ ? String(response.body["degraded"])
51
+ : null;
52
+ const stdout = renderRecall(memories);
53
+ if (stdout.length === 0) {
54
+ // Zero hits and a degraded search are different facts, and an operator
55
+ // reading the log needs to tell them apart: the first says the record is
56
+ // silent, the second says one channel did not run.
57
+ return {
58
+ status: "empty",
59
+ reason: degraded ? `no_hits:${degraded}` : "no_hits",
60
+ stdout: "",
61
+ hits: 0,
62
+ };
63
+ }
64
+ return {
65
+ status: "ok",
66
+ reason: degraded ? `recall_injected:${degraded}` : "recall_injected",
67
+ stdout,
68
+ hits: memories.length,
69
+ chars: stdout.length,
70
+ };
71
+ }
72
+ /**
73
+ * The vendor's floor, applied to the vendor's channel only.
74
+ *
75
+ * `similarity` on our door is **cosine similarity, zero when a row came only
76
+ * 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
80
+ * nothing forever while the door reports `degraded` and nobody reads it.
81
+ *
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`.
85
+ */
86
+ function readHits(value) {
87
+ if (!Array.isArray(value))
88
+ return [];
89
+ const kept = [];
90
+ for (const row of value) {
91
+ if (!row || typeof row !== "object")
92
+ continue;
93
+ const record = row;
94
+ const memory = record["memory"];
95
+ if (typeof memory !== "string" || memory.trim().length === 0)
96
+ continue;
97
+ const similarity = Number(record["similarity"] ?? 0);
98
+ const channels = record["channels"];
99
+ const scoredSemantically = Array.isArray(channels)
100
+ ? channels.includes("semantic")
101
+ : // An older door that does not report channels: a non-zero similarity is
102
+ // the only evidence the semantic channel scored this row.
103
+ Number.isFinite(similarity) && similarity > 0;
104
+ if (scoredSemantically && !(similarity >= RECALL_MIN_SIMILARITY))
105
+ continue;
106
+ kept.push(memory);
107
+ if (kept.length >= RECALL_LIMIT)
108
+ break;
109
+ }
110
+ return kept;
111
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Mask the secret, keep the memory (BLI-3580).
3
+ *
4
+ * The Stop hook sends a slice of a real conversation to `save`, and a
5
+ * conversation is exactly where a pasted API key lives. This is the SAME
6
+ * redaction the collector applies to every transcript it uploads — literally
7
+ * the same function, `redactSecretLikeContent` from `@bli-cockpit/telemetry-core`
8
+ * — rather than a second pattern list that drifts from it. That package is
9
+ * published, tiny (zod only) and already a dependency of the public CLI, so
10
+ * this package can depend on it without dragging the collector along.
11
+ *
12
+ * The collector's doctrine holds here too (`raw-evidence-sanitize.ts`,
13
+ * BLI-2581): **masking never drops the payload.** A match replaces the matched
14
+ * span with `[REDACTED:<rule>]` and everything else travels. A crash inside the
15
+ * redactor is caught and the hook saves nothing rather than saving unmasked
16
+ * text — the one place this file is stricter than the collector, because a
17
+ * memory is stored forever and read by a model, while a transcript upload is
18
+ * read by a pipeline that tracks its own gaps.
19
+ */
20
+ export interface RedactionOutcome {
21
+ ok: boolean;
22
+ text: string;
23
+ /** How many secret-like spans were masked. A count, never the values. */
24
+ masked: number;
25
+ /** Set only when the redactor itself failed; then `ok` is false. */
26
+ reason?: string;
27
+ }
28
+ export declare function maskSecretsForMemory(text: string): RedactionOutcome;