@bli-cockpit/memory-mcp 0.1.0 → 0.1.1
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 +77 -6
- package/dist/container-tag.d.ts +15 -0
- package/dist/container-tag.js +5 -3
- package/dist/door.d.ts +46 -0
- package/dist/door.js +99 -0
- package/dist/hooks/contract.d.ts +122 -0
- package/dist/hooks/contract.js +72 -0
- package/dist/hooks/prompt.d.ts +20 -0
- package/dist/hooks/prompt.js +111 -0
- package/dist/hooks/redact.d.ts +28 -0
- package/dist/hooks/redact.js +36 -0
- package/dist/hooks/render.d.ts +21 -0
- package/dist/hooks/render.js +68 -0
- package/dist/hooks/run-context.d.ts +20 -0
- package/dist/hooks/run-context.js +8 -0
- package/dist/hooks/run.d.ts +56 -0
- package/dist/hooks/run.js +140 -0
- package/dist/hooks/session-start.d.ts +17 -0
- package/dist/hooks/session-start.js +63 -0
- package/dist/hooks/stdin.d.ts +39 -0
- package/dist/hooks/stdin.js +95 -0
- package/dist/hooks/stop.d.ts +27 -0
- package/dist/hooks/stop.js +97 -0
- package/dist/hooks/transcript.d.ts +51 -0
- package/dist/hooks/transcript.js +169 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +74 -6
- package/dist/print-config.d.ts +62 -7
- package/dist/print-config.js +81 -11
- package/dist/server.d.ts +9 -4
- package/dist/server.js +20 -39
- package/package.json +6 -2
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
|
-
|
|
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 #
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
package/dist/container-tag.d.ts
CHANGED
|
@@ -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;
|
package/dist/container-tag.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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;
|