@bli-cockpit/memory-mcp 0.1.3 → 0.1.5
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 +20 -3
- package/dist/door.d.ts +22 -2
- package/dist/door.js +26 -12
- package/dist/hooks/contract.d.ts +30 -1
- package/dist/hooks/contract.js +7 -1
- package/dist/hooks/log-line.d.ts +94 -0
- package/dist/hooks/log-line.js +216 -0
- package/dist/hooks/prompt.d.ts +7 -2
- package/dist/hooks/prompt.js +35 -13
- package/dist/hooks/render.d.ts +2 -1
- package/dist/hooks/render.js +34 -2
- package/dist/hooks/run.d.ts +16 -8
- package/dist/hooks/run.js +29 -17
- package/dist/hooks/stop.js +29 -8
- package/dist/server.d.ts +1 -1
- package/dist/server.js +12 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -96,9 +96,26 @@ every machine:
|
|
|
96
96
|
- **Print nothing, or one whole block.** No "no memories found" line — the model
|
|
97
97
|
would reason about it, and an empty record and an unreachable store would look
|
|
98
98
|
the same to it. Zero hits is data and prints nothing.
|
|
99
|
-
- **One stderr receipt per run**, on every branch
|
|
100
|
-
`
|
|
101
|
-
|
|
99
|
+
- **One stderr receipt per run**, on every branch, one of three shapes
|
|
100
|
+
(BLI-3664, `hooks/log-line.ts`):
|
|
101
|
+
`[bli-memory] prompt ok {"hits":0,"elapsed_ms":140,"deadline_ms":3000}`,
|
|
102
|
+
`[bli-memory] prompt skipped {"reason":"no_prompt"}`, or
|
|
103
|
+
`[bli-memory] prompt failed {"reason":"dashboard_unreachable","elapsed_ms":2201,"deadline_ms":3000}`.
|
|
104
|
+
`reason` on `skipped`/`failed` is always one of a closed set — the four
|
|
105
|
+
network reasons `timeout`, `http_<status>`, `dashboard_unreachable`,
|
|
106
|
+
`bad_response` (Tower answered with a body that was not readable JSON);
|
|
107
|
+
the six `StdinFailure` reasons `no_stdin`, `stdin_empty`, `stdin_timeout`,
|
|
108
|
+
`stdin_too_large`, `stdin_unreadable`, `stdin_malformed` — each its own
|
|
109
|
+
literal, never folded into `bad_response`, because a malformed payload from
|
|
110
|
+
the host never touched the network (BLI-3700); `unpaired`, `no_prompt`,
|
|
111
|
+
plus three Stop-only local skips (`reentrant`, `no_transcript`,
|
|
112
|
+
`redaction_failed`) — never a stack trace, never Tower's own error text,
|
|
113
|
+
never a prompt, a memory, a path or a token. A reason `toLogReason` has not
|
|
114
|
+
been taught becomes `unknown_internal:<name>`, never a silent
|
|
115
|
+
`bad_response`. A `failed` outcome also gets a one-line `systemMessage` (the
|
|
116
|
+
same JSON-on-stdout shape the Supermemory plugin's own banner uses),
|
|
117
|
+
because Claude Code does not surface a hook's stderr anywhere a person
|
|
118
|
+
reads it.
|
|
102
119
|
|
|
103
120
|
`hook stop` sends the last user/assistant exchange (at most 12 KB, tail-read so
|
|
104
121
|
a 40 MB transcript costs nothing) through the collector's own redactor —
|
package/dist/door.d.ts
CHANGED
|
@@ -31,6 +31,13 @@ export interface DoorResponse {
|
|
|
31
31
|
body: Record<string, unknown>;
|
|
32
32
|
/** Set when the request never produced a JSON answer at all. */
|
|
33
33
|
transportError: string | null;
|
|
34
|
+
/**
|
|
35
|
+
* The CLOSED classification of `transportError`, `null` when there was none.
|
|
36
|
+
* `doorReason` reads this — never the free-text message and never
|
|
37
|
+
* `body.error` — so a hook's stderr line can never carry provider-controlled
|
|
38
|
+
* or exception-message text (BLI-3664).
|
|
39
|
+
*/
|
|
40
|
+
transportKind: "timeout" | "network" | "malformed" | null;
|
|
34
41
|
}
|
|
35
42
|
export interface DoorRequest {
|
|
36
43
|
session: MemorySession;
|
|
@@ -42,5 +49,18 @@ export interface DoorRequest {
|
|
|
42
49
|
timeoutMs?: number;
|
|
43
50
|
}
|
|
44
51
|
export declare function postMemoryDoor(request: DoorRequest): Promise<DoorResponse>;
|
|
45
|
-
/**
|
|
46
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The CLOSED reason label a hook's stderr line may print for a failed door
|
|
54
|
+
* call (BLI-3664). Deliberately never `response.body["error"]` or a raw
|
|
55
|
+
* exception message — both are provider/dashboard-controlled free text, and a
|
|
56
|
+
* hook's log line is metadata-only. Four members, closed:
|
|
57
|
+
*
|
|
58
|
+
* - `timeout` — the request itself was aborted on our own budget.
|
|
59
|
+
* - `dashboard_unreachable` — a transport failure that was NOT a timeout
|
|
60
|
+
* (DNS, TLS, connection refused).
|
|
61
|
+
* - `bad_response` — Tower answered with a status but a body that was not
|
|
62
|
+
* readable JSON.
|
|
63
|
+
* - `http_<status>` — Tower gave a normal HTTP answer that was not 2xx.
|
|
64
|
+
*/
|
|
65
|
+
export type DoorFailureReason = "timeout" | "dashboard_unreachable" | "bad_response" | `http_${number}`;
|
|
66
|
+
export declare function doorReason(response: DoorResponse): DoorFailureReason;
|
package/dist/door.js
CHANGED
|
@@ -49,11 +49,13 @@ export async function postMemoryDoor(request) {
|
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
51
|
catch (error) {
|
|
52
|
+
const timedOut = isTimeoutError(error);
|
|
52
53
|
return {
|
|
53
54
|
ok: false,
|
|
54
55
|
status: 0,
|
|
55
56
|
body: {},
|
|
56
|
-
transportError: describeTransportError(error, request.timeoutMs),
|
|
57
|
+
transportError: describeTransportError(error, request.timeoutMs, timedOut),
|
|
58
|
+
transportKind: timedOut ? "timeout" : "network",
|
|
57
59
|
};
|
|
58
60
|
}
|
|
59
61
|
finally {
|
|
@@ -71,29 +73,41 @@ export async function postMemoryDoor(request) {
|
|
|
71
73
|
status: response.status,
|
|
72
74
|
body: {},
|
|
73
75
|
transportError: `Tower answered ${response.status} with a body this server could not read as JSON.`,
|
|
76
|
+
transportKind: "malformed",
|
|
74
77
|
};
|
|
75
78
|
}
|
|
76
|
-
return {
|
|
79
|
+
return {
|
|
80
|
+
ok: response.ok,
|
|
81
|
+
status: response.status,
|
|
82
|
+
body: parsed,
|
|
83
|
+
transportError: null,
|
|
84
|
+
transportKind: null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function isTimeoutError(error) {
|
|
88
|
+
const name = error?.name;
|
|
89
|
+
return name === "AbortError" || name === "TimeoutError";
|
|
77
90
|
}
|
|
78
91
|
/**
|
|
79
92
|
* An abort reads as a timeout with its budget named, because "The operation was
|
|
80
|
-
* aborted" tells an operator nothing about which limit they hit.
|
|
93
|
+
* aborted" tells an operator nothing about which limit they hit. This message
|
|
94
|
+
* is for a human reading `server.ts`'s MCP tool error text, never for a hook's
|
|
95
|
+
* stderr line — `doorReason` below never reads it.
|
|
81
96
|
*/
|
|
82
|
-
function describeTransportError(error, timeoutMs) {
|
|
83
|
-
|
|
84
|
-
if (name === "AbortError" || name === "TimeoutError") {
|
|
97
|
+
function describeTransportError(error, timeoutMs, timedOut) {
|
|
98
|
+
if (timedOut) {
|
|
85
99
|
return timeoutMs
|
|
86
100
|
? `Tower did not answer within ${timeoutMs} ms; the request was abandoned.`
|
|
87
101
|
: "The request was aborted before Tower answered.";
|
|
88
102
|
}
|
|
89
103
|
return error instanceof Error ? (error.message.split("\n")[0] ?? "unknown") : String(error);
|
|
90
104
|
}
|
|
91
|
-
/** The reason label for a failed door call: the refusal's own, or `transport`. */
|
|
92
105
|
export function doorReason(response) {
|
|
93
|
-
if (response.
|
|
94
|
-
return "
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
106
|
+
if (response.transportKind === "timeout")
|
|
107
|
+
return "timeout";
|
|
108
|
+
if (response.transportKind === "network")
|
|
109
|
+
return "dashboard_unreachable";
|
|
110
|
+
if (response.transportKind === "malformed")
|
|
111
|
+
return "bad_response";
|
|
98
112
|
return `http_${response.status}`;
|
|
99
113
|
}
|
package/dist/hooks/contract.d.ts
CHANGED
|
@@ -93,6 +93,14 @@ export interface HookOutcome {
|
|
|
93
93
|
chars?: number;
|
|
94
94
|
/** How many secret-like spans were masked before a save. A count, never a value. */
|
|
95
95
|
masked?: number;
|
|
96
|
+
/**
|
|
97
|
+
* BLI-3718: how many extracted facts collided with another live memory's
|
|
98
|
+
* custom_id rather than being written. Counted SEPARATELY from `saved` so a
|
|
99
|
+
* session where every candidate conflicted reads as `custom_id_conflict`,
|
|
100
|
+
* never as `nothing_worth_keeping` — the two used to be indistinguishable at
|
|
101
|
+
* HTTP 200.
|
|
102
|
+
*/
|
|
103
|
+
conflicts?: number;
|
|
96
104
|
}
|
|
97
105
|
/** The container this hook's memories live in, and how it was derived. */
|
|
98
106
|
export interface HookContainer {
|
|
@@ -110,11 +118,32 @@ export declare const RECALL_BLOCK: {
|
|
|
110
118
|
};
|
|
111
119
|
/** The vendor plugin's bullet, kept byte for byte so the injection reads the same. */
|
|
112
120
|
export declare const BULLET = "\u25EA";
|
|
113
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* The vendor's own per-prompt recall floor. Kept only as the FALLBACK for a
|
|
123
|
+
* door old enough not to send its own `semanticFloor` on the search response
|
|
124
|
+
* (BLI-3697 tick 8) — the live floor is read from that response, never
|
|
125
|
+
* hardcoded here, because three tickets already moved the door's real
|
|
126
|
+
* number (0.55 -> 0.30 -> 0.65) while this constant sat still.
|
|
127
|
+
*/
|
|
114
128
|
export declare const RECALL_MIN_SIMILARITY = 0.55;
|
|
115
129
|
export declare const RECALL_LIMIT = 5;
|
|
116
130
|
/** One recalled line, capped. The vendor truncated at 300 characters. */
|
|
117
131
|
export declare const RECALL_LINE_CHARS = 300;
|
|
132
|
+
/**
|
|
133
|
+
* One recalled memory, its text and when it was written (BLI-3697 tick 8).
|
|
134
|
+
* A fact that changes over time — a version number, a decision, a status —
|
|
135
|
+
* read back with no date is a trap: the QA loop found a `min_cli_version`
|
|
136
|
+
* row from 2026-08-18 answering "what is the CLI floor" at rank 1, true in
|
|
137
|
+
* August and wrong by the time it was read. `renderRecall` prefixes every
|
|
138
|
+
* line with its date rather than trying to decide which rows count as
|
|
139
|
+
* "stale" — that judgment belongs to whoever reads the recall, and they
|
|
140
|
+
* cannot make it at all if the date is missing.
|
|
141
|
+
*/
|
|
142
|
+
export interface RecalledHit {
|
|
143
|
+
text: string;
|
|
144
|
+
/** ISO timestamp, or `null` when the door did not send one. */
|
|
145
|
+
createdAt: string | null;
|
|
146
|
+
}
|
|
118
147
|
/** The search door refuses a query over 1000 characters; a long prompt is trimmed, not dropped. */
|
|
119
148
|
export declare const MAX_QUERY_CHARS = 1000;
|
|
120
149
|
/** At most the final 12 KB of the last exchange travels to `save`. */
|
package/dist/hooks/contract.js
CHANGED
|
@@ -57,7 +57,13 @@ export const RECALL_BLOCK = {
|
|
|
57
57
|
};
|
|
58
58
|
/** The vendor plugin's bullet, kept byte for byte so the injection reads the same. */
|
|
59
59
|
export const BULLET = "◪";
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* The vendor's own per-prompt recall floor. Kept only as the FALLBACK for a
|
|
62
|
+
* door old enough not to send its own `semanticFloor` on the search response
|
|
63
|
+
* (BLI-3697 tick 8) — the live floor is read from that response, never
|
|
64
|
+
* hardcoded here, because three tickets already moved the door's real
|
|
65
|
+
* number (0.55 -> 0.30 -> 0.65) while this constant sat still.
|
|
66
|
+
*/
|
|
61
67
|
export const RECALL_MIN_SIMILARITY = 0.55;
|
|
62
68
|
export const RECALL_LIMIT = 5;
|
|
63
69
|
/** One recalled line, capped. The vendor truncated at 300 characters. */
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one line every hook run prints to stderr, and the one sentence Claude
|
|
3
|
+
* Code shows a person on `failed` (BLI-3664).
|
|
4
|
+
*
|
|
5
|
+
* Before this file, `run.ts` printed `{status, reason, ...}` where `reason`
|
|
6
|
+
* was whatever the hook body happened to produce — including, for a door
|
|
7
|
+
* failure, `doorReason(response)`'s old behaviour of echoing
|
|
8
|
+
* `response.body["error"]` straight from Tower. Across 580 real transcripts
|
|
9
|
+
* that line was never actually read (`docs/reports/supermemory-reachability-
|
|
10
|
+
* 2026-09-05.md` §6): stderr from a Claude Code hook is not captured in the
|
|
11
|
+
* transcript at all, so a BLI Memory outage and a quiet turn with nothing to
|
|
12
|
+
* recall were indistinguishable to anyone who was not tailing this process's
|
|
13
|
+
* stderr live. The fix is two mechanisms, not one:
|
|
14
|
+
*
|
|
15
|
+
* 1. **stderr, on every branch, one line, one shape.** `buildLogLine` below.
|
|
16
|
+
* This is what an operator tailing a launchd-style log — or a future
|
|
17
|
+
* collector upload of hook logs — can grep.
|
|
18
|
+
* 2. **`systemMessage`, on `failed` only.** Claude Code does not surface a
|
|
19
|
+
* hook's stderr as a visible line; the Supermemory plugin's own banner
|
|
20
|
+
* (`~/.claude/plugins/.../recall-directive.js`) is a JSON object on
|
|
21
|
+
* STDOUT carrying a `systemMessage` field (`hooks/stdin.js:writeOutput`,
|
|
22
|
+
* `console.log(JSON.stringify(data))`). `buildSystemMessage` produces the
|
|
23
|
+
* same shape so a real failure — not a quiet turn — is the one case a
|
|
24
|
+
* person actually sees something.
|
|
25
|
+
*
|
|
26
|
+
* **The reason vocabulary is closed and lives HERE, in one module.** Four
|
|
27
|
+
* members answer the network half of the ticket's own question (can a
|
|
28
|
+
* timeout, a 5xx and a dead dashboard be told apart?): `timeout`,
|
|
29
|
+
* `http_<status>`, `dashboard_unreachable`, `bad_response` — the last of
|
|
30
|
+
* those meaning specifically "Tower answered with a status but a body that
|
|
31
|
+
* was not readable JSON" (`door.ts`'s `doorReason`). Six more are the
|
|
32
|
+
* **caller's own stdin**, one per `stdin.ts` `StdinFailure` member —
|
|
33
|
+
* `no_stdin`, `stdin_empty`, `stdin_timeout`, `stdin_too_large`,
|
|
34
|
+
* `stdin_unreadable`, `stdin_malformed` — kept as their OWN literals rather
|
|
35
|
+
* than folded into `bad_response`, because a malformed payload from the host
|
|
36
|
+
* never touched the network at all (BLI-3700, QA tick 6 NEW-9: a plain-text
|
|
37
|
+
* stdin payload printed `bad_response` and read as a Tower outage for four
|
|
38
|
+
* runs that never made a request). `unpaired` and `no_prompt` round those
|
|
39
|
+
* out, and three more cover skip states local to one hook that never touch
|
|
40
|
+
* the network either — `reentrant` (Stop, re-entrant), `no_transcript`
|
|
41
|
+
* (Stop, nothing to read yet) and `redaction_failed` (Stop, the masking pass
|
|
42
|
+
* itself failed). Every member is a compile-time literal from OUR OWN code;
|
|
43
|
+
* none of them is `response.body` text, an `error.message`, or a stack trace
|
|
44
|
+
* (`door.ts`'s `doorReason` already enforces that at the source for the
|
|
45
|
+
* network four). `toLogReason` maps every other internal reason string this
|
|
46
|
+
* package can produce onto one of these, and anything it does not recognise —
|
|
47
|
+
* a future reason nobody wired in here — becomes `unknown_internal:<name>`,
|
|
48
|
+
* never a silent `bad_response`: a reason this table has not been taught must
|
|
49
|
+
* say so, not point at the wrong system.
|
|
50
|
+
*/
|
|
51
|
+
import type { HookEvent, HookOutcome, HookStatus } from "./contract.js";
|
|
52
|
+
import type { DoorFailureReason } from "../door.js";
|
|
53
|
+
import type { StdinFailure } from "./stdin.js";
|
|
54
|
+
export declare const HOOK_LOG_TAG = "[bli-memory]";
|
|
55
|
+
/**
|
|
56
|
+
* The closed set: the door's four network members, the six `StdinFailure`
|
|
57
|
+
* members (BLI-3700 — never folded into a door reason), five more fixed
|
|
58
|
+
* local literals, and the `unknown_internal:<name>` escape hatch for a
|
|
59
|
+
* reason `toLogReason` has not been taught.
|
|
60
|
+
*/
|
|
61
|
+
export type HookLogReason = DoorFailureReason | StdinFailure | "unpaired" | "no_prompt" | "reentrant" | "no_transcript" | "redaction_failed" | `unknown_internal:${string}`;
|
|
62
|
+
/** The word printed after the event name. `empty` (ran fine, nothing to say) prints as `ok`. */
|
|
63
|
+
export type HookLogWord = "ok" | "skipped" | "failed";
|
|
64
|
+
export declare function logWordFor(status: HookStatus): HookLogWord;
|
|
65
|
+
/**
|
|
66
|
+
* Normalises any reason string this package can produce into the closed set.
|
|
67
|
+
* A door failure already arrives normalised (`door.ts`'s `doorReason`), so
|
|
68
|
+
* this is mostly a lookup table for the handful of purely local reasons —
|
|
69
|
+
* plus a safe, named default for anything this table has not been taught.
|
|
70
|
+
*/
|
|
71
|
+
export declare function toLogReason(reason: string): HookLogReason;
|
|
72
|
+
/**
|
|
73
|
+
* The one stderr line for one hook run. Exact shape per status:
|
|
74
|
+
*
|
|
75
|
+
* [bli-memory] <hook> ok {hits?, saved?, chars?, masked?, elapsed_ms, deadline_ms}
|
|
76
|
+
* [bli-memory] <hook> skipped {reason}
|
|
77
|
+
* [bli-memory] <hook> failed {reason, elapsed_ms, deadline_ms}
|
|
78
|
+
*
|
|
79
|
+
* Metadata only — counts and a closed reason label, never a memory's text,
|
|
80
|
+
* never a path, never the device token.
|
|
81
|
+
*/
|
|
82
|
+
export declare function buildLogLine(event: HookEvent, outcome: HookOutcome, elapsedMs: number, deadlineMs: number): string;
|
|
83
|
+
/**
|
|
84
|
+
* One short sentence for a `failed` outcome only, in the JSON envelope the
|
|
85
|
+
* host actually renders (`{"systemMessage": "…"}` on stdout — see the
|
|
86
|
+
* Supermemory mechanism cited in this file's header). `null` on every other
|
|
87
|
+
* branch: `ok`/`empty` already say their piece through the injected block,
|
|
88
|
+
* and `skipped` is a deliberate no-op nobody needs interrupted for.
|
|
89
|
+
*/
|
|
90
|
+
export declare function buildSystemMessage(outcome: HookOutcome): string | null;
|
|
91
|
+
/** Renders one JSON block to stdout — the only shape a hook may emit there
|
|
92
|
+
* besides plain injected context. Used by `run.ts` on `failed` only, so the
|
|
93
|
+
* "one block or nothing" stdout contract for every other branch is untouched. */
|
|
94
|
+
export declare function renderSystemMessageBlock(message: string): string;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one line every hook run prints to stderr, and the one sentence Claude
|
|
3
|
+
* Code shows a person on `failed` (BLI-3664).
|
|
4
|
+
*
|
|
5
|
+
* Before this file, `run.ts` printed `{status, reason, ...}` where `reason`
|
|
6
|
+
* was whatever the hook body happened to produce — including, for a door
|
|
7
|
+
* failure, `doorReason(response)`'s old behaviour of echoing
|
|
8
|
+
* `response.body["error"]` straight from Tower. Across 580 real transcripts
|
|
9
|
+
* that line was never actually read (`docs/reports/supermemory-reachability-
|
|
10
|
+
* 2026-09-05.md` §6): stderr from a Claude Code hook is not captured in the
|
|
11
|
+
* transcript at all, so a BLI Memory outage and a quiet turn with nothing to
|
|
12
|
+
* recall were indistinguishable to anyone who was not tailing this process's
|
|
13
|
+
* stderr live. The fix is two mechanisms, not one:
|
|
14
|
+
*
|
|
15
|
+
* 1. **stderr, on every branch, one line, one shape.** `buildLogLine` below.
|
|
16
|
+
* This is what an operator tailing a launchd-style log — or a future
|
|
17
|
+
* collector upload of hook logs — can grep.
|
|
18
|
+
* 2. **`systemMessage`, on `failed` only.** Claude Code does not surface a
|
|
19
|
+
* hook's stderr as a visible line; the Supermemory plugin's own banner
|
|
20
|
+
* (`~/.claude/plugins/.../recall-directive.js`) is a JSON object on
|
|
21
|
+
* STDOUT carrying a `systemMessage` field (`hooks/stdin.js:writeOutput`,
|
|
22
|
+
* `console.log(JSON.stringify(data))`). `buildSystemMessage` produces the
|
|
23
|
+
* same shape so a real failure — not a quiet turn — is the one case a
|
|
24
|
+
* person actually sees something.
|
|
25
|
+
*
|
|
26
|
+
* **The reason vocabulary is closed and lives HERE, in one module.** Four
|
|
27
|
+
* members answer the network half of the ticket's own question (can a
|
|
28
|
+
* timeout, a 5xx and a dead dashboard be told apart?): `timeout`,
|
|
29
|
+
* `http_<status>`, `dashboard_unreachable`, `bad_response` — the last of
|
|
30
|
+
* those meaning specifically "Tower answered with a status but a body that
|
|
31
|
+
* was not readable JSON" (`door.ts`'s `doorReason`). Six more are the
|
|
32
|
+
* **caller's own stdin**, one per `stdin.ts` `StdinFailure` member —
|
|
33
|
+
* `no_stdin`, `stdin_empty`, `stdin_timeout`, `stdin_too_large`,
|
|
34
|
+
* `stdin_unreadable`, `stdin_malformed` — kept as their OWN literals rather
|
|
35
|
+
* than folded into `bad_response`, because a malformed payload from the host
|
|
36
|
+
* never touched the network at all (BLI-3700, QA tick 6 NEW-9: a plain-text
|
|
37
|
+
* stdin payload printed `bad_response` and read as a Tower outage for four
|
|
38
|
+
* runs that never made a request). `unpaired` and `no_prompt` round those
|
|
39
|
+
* out, and three more cover skip states local to one hook that never touch
|
|
40
|
+
* the network either — `reentrant` (Stop, re-entrant), `no_transcript`
|
|
41
|
+
* (Stop, nothing to read yet) and `redaction_failed` (Stop, the masking pass
|
|
42
|
+
* itself failed). Every member is a compile-time literal from OUR OWN code;
|
|
43
|
+
* none of them is `response.body` text, an `error.message`, or a stack trace
|
|
44
|
+
* (`door.ts`'s `doorReason` already enforces that at the source for the
|
|
45
|
+
* network four). `toLogReason` maps every other internal reason string this
|
|
46
|
+
* package can produce onto one of these, and anything it does not recognise —
|
|
47
|
+
* a future reason nobody wired in here — becomes `unknown_internal:<name>`,
|
|
48
|
+
* never a silent `bad_response`: a reason this table has not been taught must
|
|
49
|
+
* say so, not point at the wrong system.
|
|
50
|
+
*/
|
|
51
|
+
export const HOOK_LOG_TAG = "[bli-memory]";
|
|
52
|
+
export function logWordFor(status) {
|
|
53
|
+
if (status === "skipped")
|
|
54
|
+
return "skipped";
|
|
55
|
+
if (status === "failed")
|
|
56
|
+
return "failed";
|
|
57
|
+
return "ok"; // "ok" and "empty" both read as "ok" — empty is data, not a failure.
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Normalises any reason string this package can produce into the closed set.
|
|
61
|
+
* A door failure already arrives normalised (`door.ts`'s `doorReason`), so
|
|
62
|
+
* this is mostly a lookup table for the handful of purely local reasons —
|
|
63
|
+
* plus a safe, named default for anything this table has not been taught.
|
|
64
|
+
*/
|
|
65
|
+
export function toLogReason(reason) {
|
|
66
|
+
switch (reason) {
|
|
67
|
+
// Already-closed door failures (door.ts), including any `http_<status>`.
|
|
68
|
+
case "timeout":
|
|
69
|
+
case "dashboard_unreachable":
|
|
70
|
+
case "bad_response":
|
|
71
|
+
return reason;
|
|
72
|
+
// Pairing / credential (session.ts's `SessionFailure`).
|
|
73
|
+
case "session_file_missing":
|
|
74
|
+
case "session_file_unreadable":
|
|
75
|
+
case "session_file_invalid":
|
|
76
|
+
case "session_missing_token":
|
|
77
|
+
return "unpaired";
|
|
78
|
+
// The host's own payload could not be used (stdin.ts's `StdinFailure`).
|
|
79
|
+
// Each keeps its own literal — never folded into `bad_response`, which
|
|
80
|
+
// names a Tower reply, not a caller-side stdin defect (BLI-3700).
|
|
81
|
+
case "no_stdin":
|
|
82
|
+
case "stdin_empty":
|
|
83
|
+
case "stdin_timeout":
|
|
84
|
+
case "stdin_too_large":
|
|
85
|
+
case "stdin_unreadable":
|
|
86
|
+
case "stdin_malformed":
|
|
87
|
+
return reason;
|
|
88
|
+
// The prompt hook's own empty-prompt skip.
|
|
89
|
+
case "no_prompt":
|
|
90
|
+
return "no_prompt";
|
|
91
|
+
// Stop-only local skips (stop.ts / transcript.ts / redact.ts).
|
|
92
|
+
case "reentrant":
|
|
93
|
+
return "reentrant";
|
|
94
|
+
case "no_transcript_path":
|
|
95
|
+
case "transcript_unreadable":
|
|
96
|
+
case "transcript_no_exchange":
|
|
97
|
+
return "no_transcript";
|
|
98
|
+
case "redaction_failed":
|
|
99
|
+
return "redaction_failed";
|
|
100
|
+
// The run-level deadline race (run.ts).
|
|
101
|
+
case "deadline_exceeded":
|
|
102
|
+
return "timeout";
|
|
103
|
+
default:
|
|
104
|
+
// A door's `http_<status>` template literal passes through as-is; an
|
|
105
|
+
// unexpected reason nobody wired into this table — `hook_threw:<Name>`
|
|
106
|
+
// or a future internal reason — becomes `unknown_internal:<name>`
|
|
107
|
+
// rather than a silent `bad_response`, which would misname a caller-side
|
|
108
|
+
// or run-level defect as a Tower reply (BLI-3700). Never the raw
|
|
109
|
+
// string is dropped, and never is it printed unbounded: the name is
|
|
110
|
+
// one of OUR OWN reason literals, never `response.body` text.
|
|
111
|
+
if (reason.startsWith("http_"))
|
|
112
|
+
return reason;
|
|
113
|
+
return `unknown_internal:${reason}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The one stderr line for one hook run. Exact shape per status:
|
|
118
|
+
*
|
|
119
|
+
* [bli-memory] <hook> ok {hits?, saved?, chars?, masked?, elapsed_ms, deadline_ms}
|
|
120
|
+
* [bli-memory] <hook> skipped {reason}
|
|
121
|
+
* [bli-memory] <hook> failed {reason, elapsed_ms, deadline_ms}
|
|
122
|
+
*
|
|
123
|
+
* Metadata only — counts and a closed reason label, never a memory's text,
|
|
124
|
+
* never a path, never the device token.
|
|
125
|
+
*/
|
|
126
|
+
export function buildLogLine(event, outcome, elapsedMs, deadlineMs) {
|
|
127
|
+
const word = logWordFor(outcome.status);
|
|
128
|
+
const fields = word === "skipped"
|
|
129
|
+
? { reason: toLogReason(outcome.reason) }
|
|
130
|
+
: word === "failed"
|
|
131
|
+
? { reason: toLogReason(outcome.reason), elapsed_ms: elapsedMs, deadline_ms: deadlineMs }
|
|
132
|
+
: {
|
|
133
|
+
...(outcome.hits === undefined ? {} : { hits: outcome.hits }),
|
|
134
|
+
...(outcome.saved === undefined ? {} : { saved: outcome.saved }),
|
|
135
|
+
...(outcome.chars === undefined ? {} : { chars: outcome.chars }),
|
|
136
|
+
...(outcome.masked === undefined ? {} : { masked: outcome.masked }),
|
|
137
|
+
// BLI-3718: printed whenever set, including 0 — a save that wrote
|
|
138
|
+
// nothing AND conflicted with nothing is a materially different
|
|
139
|
+
// fact from one that conflicted on every candidate.
|
|
140
|
+
...(outcome.conflicts === undefined ? {} : { conflicts: outcome.conflicts }),
|
|
141
|
+
elapsed_ms: elapsedMs,
|
|
142
|
+
deadline_ms: deadlineMs,
|
|
143
|
+
};
|
|
144
|
+
return `${HOOK_LOG_TAG} ${event} ${word} ${JSON.stringify(fields)}\n`;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* One short sentence for a `failed` outcome only, in the JSON envelope the
|
|
148
|
+
* host actually renders (`{"systemMessage": "…"}` on stdout — see the
|
|
149
|
+
* Supermemory mechanism cited in this file's header). `null` on every other
|
|
150
|
+
* branch: `ok`/`empty` already say their piece through the injected block,
|
|
151
|
+
* and `skipped` is a deliberate no-op nobody needs interrupted for.
|
|
152
|
+
*/
|
|
153
|
+
export function buildSystemMessage(outcome) {
|
|
154
|
+
if (outcome.status !== "failed")
|
|
155
|
+
return null;
|
|
156
|
+
return `BLI Memory: ${failureSentence(toLogReason(outcome.reason))}`;
|
|
157
|
+
}
|
|
158
|
+
function failureSentence(reason) {
|
|
159
|
+
if (reason.startsWith("http_")) {
|
|
160
|
+
return `Tower refused the request (${reason.slice("http_".length)}).`;
|
|
161
|
+
}
|
|
162
|
+
if (reason.startsWith("unknown_internal:")) {
|
|
163
|
+
// A reason this table has not been taught. Named, never a crash, and
|
|
164
|
+
// never claims to know which system was at fault (BLI-3700).
|
|
165
|
+
return "something went wrong that this hook does not yet name.";
|
|
166
|
+
}
|
|
167
|
+
switch (reason) {
|
|
168
|
+
case "timeout":
|
|
169
|
+
return "Tower did not answer in time.";
|
|
170
|
+
case "dashboard_unreachable":
|
|
171
|
+
return "Tower is unreachable from this machine.";
|
|
172
|
+
case "bad_response":
|
|
173
|
+
return "Tower's response could not be understood.";
|
|
174
|
+
case "unpaired":
|
|
175
|
+
return "this machine is not paired with Tower — run `cockpit login`.";
|
|
176
|
+
case "redaction_failed":
|
|
177
|
+
return "the turn could not be safely redacted before saving.";
|
|
178
|
+
// The six `StdinFailure` members (BLI-3700). Skip-only today —
|
|
179
|
+
// `readHookPayload` always returns these as a `skipped` outcome, so
|
|
180
|
+
// `buildSystemMessage` never reaches them in practice — but each still
|
|
181
|
+
// gets its own sentence, named rather than asserted unreachable, so a
|
|
182
|
+
// future change that DID route one through `failed` gets the right
|
|
183
|
+
// words instead of the wrong system's name.
|
|
184
|
+
case "no_stdin":
|
|
185
|
+
return "no payload arrived on stdin (not run from a host that pipes one).";
|
|
186
|
+
case "stdin_empty":
|
|
187
|
+
return "the payload on stdin was empty.";
|
|
188
|
+
case "stdin_timeout":
|
|
189
|
+
return "stdin never closed within this hook's own deadline.";
|
|
190
|
+
case "stdin_too_large":
|
|
191
|
+
return "the payload on stdin exceeded the size ceiling.";
|
|
192
|
+
case "stdin_unreadable":
|
|
193
|
+
return "the payload on stdin could not be read.";
|
|
194
|
+
case "stdin_malformed":
|
|
195
|
+
return "the payload on stdin was not readable JSON.";
|
|
196
|
+
case "no_transcript":
|
|
197
|
+
case "reentrant":
|
|
198
|
+
case "no_prompt":
|
|
199
|
+
// Skip-only reasons; `buildSystemMessage` never reaches these because
|
|
200
|
+
// none of them is produced on a `failed` outcome. Named rather than
|
|
201
|
+
// asserted unreachable, so a future change that DID route one through
|
|
202
|
+
// `failed` still gets a sentence instead of a crash.
|
|
203
|
+
return "the recall did not run.";
|
|
204
|
+
default:
|
|
205
|
+
// Unreached: the `http_`/`unknown_internal:` families returned above
|
|
206
|
+
// and every other member is handled. A safe sentence, never a crash,
|
|
207
|
+
// if that ever changes.
|
|
208
|
+
return "something went wrong.";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Renders one JSON block to stdout — the only shape a hook may emit there
|
|
212
|
+
* besides plain injected context. Used by `run.ts` on `failed` only, so the
|
|
213
|
+
* "one block or nothing" stdout contract for every other branch is untouched. */
|
|
214
|
+
export function renderSystemMessageBlock(message) {
|
|
215
|
+
return JSON.stringify({ systemMessage: message });
|
|
216
|
+
}
|
package/dist/hooks/prompt.d.ts
CHANGED
|
@@ -6,8 +6,13 @@
|
|
|
6
6
|
* installer's 5 s timeout, one search, print or print nothing.
|
|
7
7
|
*
|
|
8
8
|
* The recall shape is the vendor's, kept so the injection reads the same: the
|
|
9
|
-
* prompt itself is the query, hits below
|
|
10
|
-
*
|
|
9
|
+
* prompt itself is the query, hits below the door's OWN floor are dropped
|
|
10
|
+
* (BLI-3697 tick 8: this hook used to keep a hardcoded 0.55 of its own,
|
|
11
|
+
* which drifted out of step when the door's floor moved across three
|
|
12
|
+
* tickets — the door's `semanticFloor` in the response is read instead now,
|
|
13
|
+
* `RECALL_MIN_SIMILARITY` surviving only as the fallback for an older
|
|
14
|
+
* deployment that does not send the field yet), at most five survive, each
|
|
15
|
+
* capped at 300 characters.
|
|
11
16
|
*
|
|
12
17
|
* **Zero hits is data.** It prints nothing and exits 0 with `no_hits`. A search
|
|
13
18
|
* that could NOT run is a different line with a different reason, because an
|
package/dist/hooks/prompt.js
CHANGED
|
@@ -6,8 +6,13 @@
|
|
|
6
6
|
* installer's 5 s timeout, one search, print or print nothing.
|
|
7
7
|
*
|
|
8
8
|
* The recall shape is the vendor's, kept so the injection reads the same: the
|
|
9
|
-
* prompt itself is the query, hits below
|
|
10
|
-
*
|
|
9
|
+
* prompt itself is the query, hits below the door's OWN floor are dropped
|
|
10
|
+
* (BLI-3697 tick 8: this hook used to keep a hardcoded 0.55 of its own,
|
|
11
|
+
* which drifted out of step when the door's floor moved across three
|
|
12
|
+
* tickets — the door's `semanticFloor` in the response is read instead now,
|
|
13
|
+
* `RECALL_MIN_SIMILARITY` surviving only as the fallback for an older
|
|
14
|
+
* deployment that does not send the field yet), at most five survive, each
|
|
15
|
+
* capped at 300 characters.
|
|
11
16
|
*
|
|
12
17
|
* **Zero hits is data.** It prints nothing and exits 0 with `no_hits`. A search
|
|
13
18
|
* that could NOT run is a different line with a different reason, because an
|
|
@@ -45,7 +50,8 @@ export async function runPromptHook(context) {
|
|
|
45
50
|
if (!response.ok) {
|
|
46
51
|
return { status: "failed", reason: doorReason(response), stdout: "", hits: 0 };
|
|
47
52
|
}
|
|
48
|
-
const
|
|
53
|
+
const floor = doorFloor(response.body["semanticFloor"]);
|
|
54
|
+
const memories = readHits(response.body["results"], floor);
|
|
49
55
|
const degraded = typeof response.body["degraded"] === "string" && response.body["degraded"]
|
|
50
56
|
? String(response.body["degraded"])
|
|
51
57
|
: null;
|
|
@@ -70,20 +76,35 @@ export async function runPromptHook(context) {
|
|
|
70
76
|
};
|
|
71
77
|
}
|
|
72
78
|
/**
|
|
73
|
-
* The
|
|
79
|
+
* The door's own floor for THIS search, or the vendor's fallback.
|
|
80
|
+
*
|
|
81
|
+
* BLI-3697 tick 8: this hook used to re-check every row against its own
|
|
82
|
+
* hardcoded `RECALL_MIN_SIMILARITY` (0.55) regardless of what floor the
|
|
83
|
+
* door had actually searched with — three tickets moved that number (0.55 ->
|
|
84
|
+
* 0.30 -> 0.65) and this hook never noticed, silently applying a SECOND,
|
|
85
|
+
* stale threshold on top of the door's real one. `semanticFloor` on the
|
|
86
|
+
* response is the number the search actually ran with; only a door old
|
|
87
|
+
* enough not to send it falls back to the vendor's constant.
|
|
88
|
+
*/
|
|
89
|
+
function doorFloor(value) {
|
|
90
|
+
return typeof value === "number" && Number.isFinite(value) ? value : RECALL_MIN_SIMILARITY;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The floor governs the SEMANTIC channel only.
|
|
74
94
|
*
|
|
75
95
|
* `similarity` on our door is **cosine similarity, zero when a row came only
|
|
76
96
|
* from the lexical or recency channel** (`agent-memories/search.ts:74`). The
|
|
77
|
-
* vendor filtered
|
|
78
|
-
*
|
|
79
|
-
*
|
|
97
|
+
* vendor filtered against a store where every hit was semantic; doing that
|
|
98
|
+
* here would drop every keyword match, and on a deployment with no embedding
|
|
99
|
+
* credential it would drop EVERY hit — a recall that silently returns
|
|
80
100
|
* nothing forever while the door reports `degraded` and nobody reads it.
|
|
81
101
|
*
|
|
82
|
-
* So
|
|
83
|
-
*
|
|
84
|
-
*
|
|
102
|
+
* So `floor` (the door's own `semanticFloor`, see `doorFloor` above) governs
|
|
103
|
+
* SEMANTIC hits and nothing else: a row the semantic channel scored has to
|
|
104
|
+
* clear it, and a row it did not score at all is judged by the door's own
|
|
105
|
+
* ranking, which already put it in the top `limit`.
|
|
85
106
|
*/
|
|
86
|
-
function readHits(value) {
|
|
107
|
+
function readHits(value, floor) {
|
|
87
108
|
if (!Array.isArray(value))
|
|
88
109
|
return [];
|
|
89
110
|
const kept = [];
|
|
@@ -101,9 +122,10 @@ function readHits(value) {
|
|
|
101
122
|
: // An older door that does not report channels: a non-zero similarity is
|
|
102
123
|
// the only evidence the semantic channel scored this row.
|
|
103
124
|
Number.isFinite(similarity) && similarity > 0;
|
|
104
|
-
if (scoredSemantically && !(similarity >=
|
|
125
|
+
if (scoredSemantically && !(similarity >= floor))
|
|
105
126
|
continue;
|
|
106
|
-
|
|
127
|
+
const createdAt = typeof record["createdAt"] === "string" ? record["createdAt"] : null;
|
|
128
|
+
kept.push({ text: memory, createdAt });
|
|
107
129
|
if (kept.length >= RECALL_LIMIT)
|
|
108
130
|
break;
|
|
109
131
|
}
|
package/dist/hooks/render.d.ts
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
* A memory is a recalled fact; a fact that arrives phrased as an order and
|
|
15
15
|
* is obeyed is how a memory store becomes an injection channel.
|
|
16
16
|
*/
|
|
17
|
+
import { type RecalledHit } from "./contract.js";
|
|
17
18
|
export declare function renderSessionContext(profile: {
|
|
18
19
|
static: string[];
|
|
19
20
|
dynamic: string[];
|
|
20
21
|
}): string;
|
|
21
|
-
export declare function renderRecall(
|
|
22
|
+
export declare function renderRecall(hits: readonly RecalledHit[]): string;
|
package/dist/hooks/render.js
CHANGED
|
@@ -37,8 +37,8 @@ export function renderSessionContext(profile) {
|
|
|
37
37
|
lines.push(CONTEXT_BLOCK.close);
|
|
38
38
|
return lines.join("\n");
|
|
39
39
|
}
|
|
40
|
-
export function renderRecall(
|
|
41
|
-
const items =
|
|
40
|
+
export function renderRecall(hits) {
|
|
41
|
+
const items = cleanRecalledHits(hits);
|
|
42
42
|
if (items.length === 0)
|
|
43
43
|
return "";
|
|
44
44
|
return [
|
|
@@ -48,6 +48,38 @@ export function renderRecall(memories) {
|
|
|
48
48
|
RECALL_BLOCK.close,
|
|
49
49
|
].join("\n");
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* One memory, on one line, dated. Folds newlines for the same reason
|
|
53
|
+
* `cleanItems` does; the date prefix is added BEFORE the length cap, so the
|
|
54
|
+
* cap still bounds the whole printed line, never just the text half.
|
|
55
|
+
*
|
|
56
|
+
* Every line gets its date, not only the ones some heuristic calls "stale" —
|
|
57
|
+
* `RecalledHit`'s header in `contract.ts` says why: only the reader can judge
|
|
58
|
+
* staleness, and only if the date is there to judge.
|
|
59
|
+
*/
|
|
60
|
+
function cleanRecalledHits(hits) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const hit of hits) {
|
|
63
|
+
if (typeof hit.text !== "string")
|
|
64
|
+
continue;
|
|
65
|
+
const folded = hit.text.replace(/\s+/gu, " ").trim();
|
|
66
|
+
if (folded.length === 0)
|
|
67
|
+
continue;
|
|
68
|
+
const prefix = dateLabel(hit.createdAt);
|
|
69
|
+
const line = prefix ? `${prefix} ${folded}` : folded;
|
|
70
|
+
out.push(line.length > RECALL_LINE_CHARS ? `${line.slice(0, RECALL_LINE_CHARS - 1)}…` : line);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/** `[YYYY-MM-DD]`, or `""` when the door sent no date or an unparsable one. */
|
|
75
|
+
function dateLabel(createdAt) {
|
|
76
|
+
if (!createdAt)
|
|
77
|
+
return "";
|
|
78
|
+
const date = new Date(createdAt);
|
|
79
|
+
if (Number.isNaN(date.getTime()))
|
|
80
|
+
return "";
|
|
81
|
+
return `[${date.toISOString().slice(0, 10)}]`;
|
|
82
|
+
}
|
|
51
83
|
/**
|
|
52
84
|
* One memory, on one line. Newlines are folded because a multi-line bullet
|
|
53
85
|
* breaks the list the model is reading, and the cap is the vendor's 300.
|
package/dist/hooks/run.d.ts
CHANGED
|
@@ -14,14 +14,22 @@
|
|
|
14
14
|
* this prompt" and any other non-zero as an error it shows the person. A
|
|
15
15
|
* memory recall may never do either.
|
|
16
16
|
* 3. **stdout discipline.** Nothing is written until the hook has finished and
|
|
17
|
-
* produced a whole block. A partial injection is worse than none
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
17
|
+
* produced a whole block. A partial injection is worse than none — with
|
|
18
|
+
* ONE exception, added in BLI-3664: a `failed` outcome also gets a single
|
|
19
|
+
* `{"systemMessage": "…"}` JSON block, because that is the one shape
|
|
20
|
+
* Claude Code actually renders to a person (see `log-line.ts`'s header).
|
|
21
|
+
* Every other status's stdout is exactly as before.
|
|
22
|
+
* 4. **One stderr receipt per run, on every branch.** `[bli-memory] <event>
|
|
23
|
+
* ok|skipped|failed {…}` (`log-line.ts:buildLogLine`) — metadata only,
|
|
24
|
+
* never a prompt, a memory, a path or a token, and a `reason` drawn from a
|
|
25
|
+
* closed set. A hook that only logged failures could not answer "did
|
|
26
|
+
* recall work at all today?", which is the question an operator actually
|
|
27
|
+
* asks — and across 580 real transcripts this line was the ONLY place
|
|
28
|
+
* that question could ever have been answered, because Claude Code does
|
|
29
|
+
* not surface a hook's stderr anywhere a person or a transcript reads
|
|
30
|
+
* (`docs/reports/supermemory-reachability-2026-09-05.md` §6, BLI-3664).
|
|
23
31
|
*
|
|
24
|
-
* Why an unpaired machine is `skipped
|
|
32
|
+
* Why an unpaired machine is `skipped unpaired` and not an error: an intern
|
|
25
33
|
* who has not run `cockpit login` yet has three registered hooks and no
|
|
26
34
|
* credential, and that state must be quiet in the transcript and loud in the
|
|
27
35
|
* log. Same for a machine whose token was revoked — the door answers 401, the
|
|
@@ -30,7 +38,7 @@
|
|
|
30
38
|
import type { FetchImpl } from "../door.js";
|
|
31
39
|
import { type HookEvent, type HookOutcome, type HookPayload } from "./contract.js";
|
|
32
40
|
import type { StopHookOptions } from "./stop.js";
|
|
33
|
-
export
|
|
41
|
+
export { HOOK_LOG_TAG } from "./log-line.js";
|
|
34
42
|
export interface RunHookDeps {
|
|
35
43
|
env?: NodeJS.ProcessEnv;
|
|
36
44
|
cwd?: string;
|
package/dist/hooks/run.js
CHANGED
|
@@ -14,14 +14,22 @@
|
|
|
14
14
|
* this prompt" and any other non-zero as an error it shows the person. A
|
|
15
15
|
* memory recall may never do either.
|
|
16
16
|
* 3. **stdout discipline.** Nothing is written until the hook has finished and
|
|
17
|
-
* produced a whole block. A partial injection is worse than none
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
17
|
+
* produced a whole block. A partial injection is worse than none — with
|
|
18
|
+
* ONE exception, added in BLI-3664: a `failed` outcome also gets a single
|
|
19
|
+
* `{"systemMessage": "…"}` JSON block, because that is the one shape
|
|
20
|
+
* Claude Code actually renders to a person (see `log-line.ts`'s header).
|
|
21
|
+
* Every other status's stdout is exactly as before.
|
|
22
|
+
* 4. **One stderr receipt per run, on every branch.** `[bli-memory] <event>
|
|
23
|
+
* ok|skipped|failed {…}` (`log-line.ts:buildLogLine`) — metadata only,
|
|
24
|
+
* never a prompt, a memory, a path or a token, and a `reason` drawn from a
|
|
25
|
+
* closed set. A hook that only logged failures could not answer "did
|
|
26
|
+
* recall work at all today?", which is the question an operator actually
|
|
27
|
+
* asks — and across 580 real transcripts this line was the ONLY place
|
|
28
|
+
* that question could ever have been answered, because Claude Code does
|
|
29
|
+
* not surface a hook's stderr anywhere a person or a transcript reads
|
|
30
|
+
* (`docs/reports/supermemory-reachability-2026-09-05.md` §6, BLI-3664).
|
|
23
31
|
*
|
|
24
|
-
* Why an unpaired machine is `skipped
|
|
32
|
+
* Why an unpaired machine is `skipped unpaired` and not an error: an intern
|
|
25
33
|
* who has not run `cockpit login` yet has three registered hooks and no
|
|
26
34
|
* credential, and that state must be quiet in the transcript and loud in the
|
|
27
35
|
* log. Same for a machine whose token was revoked — the door answers 401, the
|
|
@@ -31,8 +39,9 @@ import path from "node:path";
|
|
|
31
39
|
import { resolveContainerTag } from "../container-tag.js";
|
|
32
40
|
import { loadMemorySession } from "../session.js";
|
|
33
41
|
import { HOOK_BUDGETS, } from "./contract.js";
|
|
42
|
+
import { buildLogLine, buildSystemMessage, renderSystemMessageBlock } from "./log-line.js";
|
|
34
43
|
import { readHookPayload } from "./stdin.js";
|
|
35
|
-
export
|
|
44
|
+
export { HOOK_LOG_TAG } from "./log-line.js";
|
|
36
45
|
/**
|
|
37
46
|
* Runs one hook and returns its outcome. The caller (`index.ts`) exits 0
|
|
38
47
|
* regardless — the return value is for tests and for the receipt.
|
|
@@ -44,19 +53,22 @@ export async function runHook(event, deps = {}) {
|
|
|
44
53
|
const stdout = deps.stdout ?? process.stdout;
|
|
45
54
|
const stderr = deps.stderr ?? process.stderr;
|
|
46
55
|
const outcome = await raceDeadline(() => resolveOutcome(event, deps), budget.totalMs);
|
|
56
|
+
const elapsedMs = now() - started;
|
|
47
57
|
// stdout is written once, here, and only for a complete block.
|
|
48
58
|
if (outcome.stdout.length > 0) {
|
|
49
59
|
stdout.write(`${outcome.stdout}\n`);
|
|
50
60
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
else {
|
|
62
|
+
// The one exception to "one block or nothing": a `failed` outcome still
|
|
63
|
+
// prints nothing to the model (no additionalContext), but DOES get a
|
|
64
|
+
// `systemMessage` block, which is a status banner, not a memory. Every
|
|
65
|
+
// other status's stdout is unchanged from before BLI-3664.
|
|
66
|
+
const systemMessage = buildSystemMessage(outcome);
|
|
67
|
+
if (systemMessage) {
|
|
68
|
+
stdout.write(`${renderSystemMessageBlock(systemMessage)}\n`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
stderr.write(buildLogLine(event, outcome, elapsedMs, budget.totalMs));
|
|
60
72
|
return outcome;
|
|
61
73
|
}
|
|
62
74
|
async function resolveOutcome(event, deps) {
|
package/dist/hooks/stop.js
CHANGED
|
@@ -75,19 +75,40 @@ export async function runStopHook(context, options = {}) {
|
|
|
75
75
|
if (!response.ok) {
|
|
76
76
|
return { status: "failed", reason: doorReason(response), stdout: "", saved: 0 };
|
|
77
77
|
}
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
?
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
const decisionsRaw = response.body["decisions"];
|
|
79
|
+
const decisions = Array.isArray(decisionsRaw)
|
|
80
|
+
? decisionsRaw.filter((decision) => Boolean(decision) && typeof decision === "object")
|
|
81
|
+
: null;
|
|
82
|
+
const written = decisions
|
|
83
|
+
? decisions.filter((decision) => decision["verb"] !== "skip").length
|
|
83
84
|
: response.body["id"]
|
|
84
85
|
? 1
|
|
85
86
|
: 0;
|
|
87
|
+
// BLI-3718: a candidate that collided with another live memory's custom_id
|
|
88
|
+
// is counted SEPARATELY from `written` — it is neither a save nor "nothing
|
|
89
|
+
// worth keeping", and folding it into the latter is exactly how a session's
|
|
90
|
+
// second-and-later Stop hooks reported `{"skipped":N}` at HTTP 200 while a
|
|
91
|
+
// fact silently never landed.
|
|
92
|
+
const conflicted = decisions
|
|
93
|
+
? decisions.filter((decision) => decision["reason"] === "custom_id_conflict").length
|
|
94
|
+
: 0;
|
|
95
|
+
if (written > 0) {
|
|
96
|
+
return {
|
|
97
|
+
status: "ok",
|
|
98
|
+
reason: String(response.body["outcome"] ?? "saved"),
|
|
99
|
+
stdout: "",
|
|
100
|
+
saved: written,
|
|
101
|
+
conflicts: conflicted,
|
|
102
|
+
chars: masked.text.length,
|
|
103
|
+
masked: masked.masked,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
86
106
|
return {
|
|
87
|
-
status:
|
|
88
|
-
reason:
|
|
107
|
+
status: "empty",
|
|
108
|
+
reason: conflicted > 0 ? "custom_id_conflict" : "nothing_worth_keeping",
|
|
89
109
|
stdout: "",
|
|
90
|
-
saved:
|
|
110
|
+
saved: 0,
|
|
111
|
+
conflicts: conflicted,
|
|
91
112
|
chars: masked.text.length,
|
|
92
113
|
masked: masked.masked,
|
|
93
114
|
};
|
package/dist/server.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ import { z } from "zod";
|
|
|
36
36
|
import { type FetchImpl } from "./door.js";
|
|
37
37
|
import type { MemorySession } from "./session.js";
|
|
38
38
|
export declare const PACKAGE_NAME = "@bli-cockpit/memory-mcp";
|
|
39
|
-
export declare const PACKAGE_VERSION = "0.1.
|
|
39
|
+
export declare const PACKAGE_VERSION = "0.1.5";
|
|
40
40
|
/**
|
|
41
41
|
* The server id an MCP client registers. Defined in `print-config.ts` and
|
|
42
42
|
* re-exported here: `index.ts` needs the name on the hook and print-config
|
package/dist/server.js
CHANGED
|
@@ -37,7 +37,10 @@ import { resolveContainerTag } from "./container-tag.js";
|
|
|
37
37
|
import { postMemoryDoor } from "./door.js";
|
|
38
38
|
import { SERVER_NAME } from "./print-config.js";
|
|
39
39
|
export const PACKAGE_NAME = "@bli-cockpit/memory-mcp";
|
|
40
|
-
|
|
40
|
+
// BLI-3713: this constant had drifted from package.json's real version
|
|
41
|
+
// (0.1.1 vs 0.1.4) — nobody had bumped it alongside a version bump before.
|
|
42
|
+
// Kept in lockstep with package.json's "version" field going forward.
|
|
43
|
+
export const PACKAGE_VERSION = "0.1.5";
|
|
41
44
|
/**
|
|
42
45
|
* The server id an MCP client registers. Defined in `print-config.ts` and
|
|
43
46
|
* re-exported here: `index.ts` needs the name on the hook and print-config
|
|
@@ -241,7 +244,14 @@ export function createServer(deps) {
|
|
|
241
244
|
const embedNote = response.body.embedded === false && response.body.embedSkippedReason
|
|
242
245
|
? ` It was stored without a search vector (${String(response.body.embedSkippedReason)}), so it is findable by keyword and not yet by meaning.`
|
|
243
246
|
: "";
|
|
244
|
-
|
|
247
|
+
// BLI-3713: `mode: "extract"` can fall back to a verbatim save when
|
|
248
|
+
// distillation fails (a bad model answer must never mean the content is
|
|
249
|
+
// dropped) — say so plainly rather than let the caller believe atomic
|
|
250
|
+
// facts were written when the whole note was stored as one row instead.
|
|
251
|
+
const extractNote = response.body.extract === "fell_back_verbatim"
|
|
252
|
+
? ` Saved as written; extraction failed (${String(response.body.extractFailureReason ?? "unknown")}).`
|
|
253
|
+
: "";
|
|
254
|
+
return textResult(`Memory ${outcome} in ${landedIn} (id: ${id}).${embedNote}${extractNote}`, response.body);
|
|
245
255
|
});
|
|
246
256
|
server.registerTool("update_memory", {
|
|
247
257
|
title: "Correct a memory",
|