@bli-cockpit/memory-mcp 0.1.9 → 0.1.11

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.
@@ -42,15 +42,49 @@ export const HOOK_BUDGETS = {
42
42
  "session-start": { totalMs: 8_000, stdinMs: 2_000, requestMs: 6_000, gitMs: 2_500 },
43
43
  /**
44
44
  * A person is waiting on this one with their prompt already typed, so the
45
- * whole run is capped at TWO SECONDS and the door call at 1,500 ms
46
- * (BLI-3730). The old 3,000/2,200 pair was chosen against the installer's
45
+ * whole run is capped and the door call is capped inside it (BLI-3730). The
46
+ * pair before that ticket, 3,000/2,200, was sized against the installer's
47
47
  * 5 s host timeout, which is the wrong number to size against: nobody
48
48
  * notices a hook that gives up under its host's ceiling, everybody notices
49
- * a turn that stalls for three seconds before the model starts. A recall
50
- * that misses this window prints NOTHING and logs `timeout` — the shelf is
51
- * not worth the wait, and the person never finds out, by design.
49
+ * a turn that stalls before the model starts. A recall that misses this
50
+ * window prints NOTHING and logs `timeout` — the shelf is not worth the
51
+ * wait, and BLI-3788 is what that cost when nobody counted the misses.
52
+ *
53
+ * **2,500/2,000 is measured, not chosen (BLI-3788).** BLI-3730 set
54
+ * 2,000/1,500 while this door was one queued INSERT; BLI-3777 then put three
55
+ * Tower legs behind the same call and nothing re-measured the budget. QA
56
+ * tick 18 found the block printing on 2 of 6 identical runs, every miss at
57
+ * 1,535-1,543 ms — i.e. the door was losing by tens of milliseconds, not by
58
+ * seconds. Re-measured against production on 2026-09-05 from an ICT machine
59
+ * (`npm run memory:hook-bench --workspace=apps/dashboard`):
60
+ *
61
+ * warm, ten runs back to back printed 10/10 p50 1,072 ms p95 1,142 ms
62
+ * cold, eight runs 90 s apart printed 5/8 p50 1,470 ms 3 × timeout at 1,535 ms
63
+ *
64
+ * A cold call is the one a person actually makes — one prompt every few
65
+ * minutes, a fresh lambda, an empty cache — and it lands just over the old
66
+ * line. So the door was made cheaper where that was free (BLI-3788 took the
67
+ * alias expansion out of the serial preamble and cached it) AND this budget
68
+ * was widened to what the door costs, both said out loud rather than one of
69
+ * them quietly buying the other's result.
70
+ *
71
+ * **The host ceiling was re-checked, not assumed.** Both installers write
72
+ * `timeout_seconds: 5` for `UserPromptSubmit` — Claude Code's
73
+ * `~/.claude/settings.json` and Codex's `~/.codex/hooks.json`, one constant,
74
+ * `MEMORY_HOOK_TIMEOUT_SECONDS` in the collector's
75
+ * `memory-install-contract.ts`. A 2,500 ms total leaves 2.5 s of headroom
76
+ * under both, which is the same margin the 2,000 ms total had against the
77
+ * older 3,000 ms budget it replaced. Nothing here goes near the ceiling; the
78
+ * person's patience is still the binding constraint, and 500 ms is what that
79
+ * patience is being asked for.
80
+ *
81
+ * The remaining tail is COUNTED now rather than silent: every run appends a
82
+ * counts-only line to this machine's hook-stats file (`hook-stats.ts`), the
83
+ * collector carries `hook_timeouts_24h` on the heartbeat's memory receipt,
84
+ * and `cockpit ops --memory` prints it. A budget nobody can see missed is a
85
+ * budget nobody can re-fit.
52
86
  */
53
- prompt: { totalMs: 2_000, stdinMs: 600, requestMs: 1_500, gitMs: 500 },
87
+ prompt: { totalMs: 2_500, stdinMs: 600, requestMs: 2_000, gitMs: 500 },
54
88
  /**
55
89
  * `extract` mode is queued server-side now (BLI-3730): the door writes a
56
90
  * durable job, answers 202 and does the model work after the response, so
@@ -0,0 +1,67 @@
1
+ /**
2
+ * HOW OFTEN DOES A HOOK MISS? — the writing half (BLI-3788).
3
+ *
4
+ * A prompt hook that loses its race prints NOTHING. That is the design, and it
5
+ * is right: a person cannot act on "your recall was 40 ms late" and a
6
+ * half-injected block is worse than none. The cost of that design is that the
7
+ * feature can be broken for two thirds of somebody's turns with the only
8
+ * evidence in a stderr line Claude Code shows nobody (QA tick 18: the block
9
+ * printed on 2 of 6 identical runs, each miss at ~1,535 ms against a 1,500 ms
10
+ * budget, and no operator surface counted one).
11
+ *
12
+ * So every run adds one to a counts-only file on this machine. The collector
13
+ * reads it once a sync tick and carries the last 24 hours on the heartbeat's
14
+ * memory receipt; `cockpit ops --memory` prints it. This module writes; the
15
+ * SHAPE, the path and the windowing rule live in
16
+ * `@bli-cockpit/telemetry-core`'s `memory-hook-stats.ts`, because the reader
17
+ * is a different published package and two copies of a JSON contract kept in
18
+ * step by comment is the BLI-2541 failure.
19
+ *
20
+ * ## Three properties, all defended by tests
21
+ *
22
+ * **Nothing here may fail a hook.** Every path catches everything and returns
23
+ * a reason label. A machine with a read-only home keeps recalling memories and
24
+ * stops counting, and says which on stderr.
25
+ *
26
+ * **It is a FLOOR.** Two hooks finishing in the same millisecond both read,
27
+ * both add one, and one of them wins the rename — a count can be short and can
28
+ * never be invented. A lock would be the wrong trade: a filesystem wait in
29
+ * front of a hook whose whole problem is latency, to protect a number that
30
+ * only needs to be right to within a few percent.
31
+ *
32
+ * **Counts only.** No prompt, no memory, no container tag, no path, no token,
33
+ * no reason string that did not come from the hook's own closed vocabulary.
34
+ */
35
+ import { type MemoryHookCounts, type MemoryHookStatsFile } from "@bli-cockpit/telemetry-core";
36
+ import type { HookEvent, HookOutcome } from "./contract.js";
37
+ /** Buckets kept. 48 hours is twice the window anybody reads. */
38
+ export declare const HOOK_STATS_MAX_BUCKETS = 48;
39
+ export declare function hookStatsFilePath(homeDir?: string): string;
40
+ /**
41
+ * Which column this outcome lands in.
42
+ *
43
+ * A deadline is a deadline wherever it fired: the whole-run race in `run.ts`
44
+ * reports `deadline_exceeded` and the door call inside it reports `timeout`,
45
+ * and an operator counting "how often did a person lose their recall to the
46
+ * clock" wants one number, not two words for one event.
47
+ */
48
+ export declare function columnFor(outcome: HookOutcome): keyof MemoryHookCounts;
49
+ export declare function applyRun(file: MemoryHookStatsFile, event: HookEvent, outcome: HookOutcome, at: Date): MemoryHookStatsFile;
50
+ export interface RecordHookRunOptions {
51
+ homeDir?: string;
52
+ now?: Date;
53
+ /** Test seam. Real runs use the filesystem. */
54
+ io?: {
55
+ read: (file: string) => string | null;
56
+ write: (file: string, contents: string) => void;
57
+ };
58
+ }
59
+ /**
60
+ * Count one run. Returns a reason label — `recorded`, or why not — so the
61
+ * caller can say something rather than swallow it.
62
+ *
63
+ * A file that does not parse is REPLACED rather than merged: the alternative
64
+ * is guessing at somebody else's shape and publishing a number nobody can
65
+ * explain, and the whole history this protects is 48 hours long.
66
+ */
67
+ export declare function recordHookRun(event: HookEvent, outcome: HookOutcome, options?: RecordHookRunOptions): string;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * HOW OFTEN DOES A HOOK MISS? — the writing half (BLI-3788).
3
+ *
4
+ * A prompt hook that loses its race prints NOTHING. That is the design, and it
5
+ * is right: a person cannot act on "your recall was 40 ms late" and a
6
+ * half-injected block is worse than none. The cost of that design is that the
7
+ * feature can be broken for two thirds of somebody's turns with the only
8
+ * evidence in a stderr line Claude Code shows nobody (QA tick 18: the block
9
+ * printed on 2 of 6 identical runs, each miss at ~1,535 ms against a 1,500 ms
10
+ * budget, and no operator surface counted one).
11
+ *
12
+ * So every run adds one to a counts-only file on this machine. The collector
13
+ * reads it once a sync tick and carries the last 24 hours on the heartbeat's
14
+ * memory receipt; `cockpit ops --memory` prints it. This module writes; the
15
+ * SHAPE, the path and the windowing rule live in
16
+ * `@bli-cockpit/telemetry-core`'s `memory-hook-stats.ts`, because the reader
17
+ * is a different published package and two copies of a JSON contract kept in
18
+ * step by comment is the BLI-2541 failure.
19
+ *
20
+ * ## Three properties, all defended by tests
21
+ *
22
+ * **Nothing here may fail a hook.** Every path catches everything and returns
23
+ * a reason label. A machine with a read-only home keeps recalling memories and
24
+ * stops counting, and says which on stderr.
25
+ *
26
+ * **It is a FLOOR.** Two hooks finishing in the same millisecond both read,
27
+ * both add one, and one of them wins the rename — a count can be short and can
28
+ * never be invented. A lock would be the wrong trade: a filesystem wait in
29
+ * front of a hook whose whole problem is latency, to protect a number that
30
+ * only needs to be right to within a few percent.
31
+ *
32
+ * **Counts only.** No prompt, no memory, no container tag, no path, no token,
33
+ * no reason string that did not come from the hook's own closed vocabulary.
34
+ */
35
+ import fs from "node:fs";
36
+ import os from "node:os";
37
+ import path from "node:path";
38
+ import { MEMORY_HOOK_STATS_SCHEMA_VERSION, emptyMemoryHookCounts, memoryHookHourBucket, memoryHookStatsFilePath, parseMemoryHookStats, } from "@bli-cockpit/telemetry-core";
39
+ /** Buckets kept. 48 hours is twice the window anybody reads. */
40
+ export const HOOK_STATS_MAX_BUCKETS = 48;
41
+ export function hookStatsFilePath(homeDir = os.homedir()) {
42
+ return memoryHookStatsFilePath(homeDir);
43
+ }
44
+ function emptyFile() {
45
+ return { schema_version: MEMORY_HOOK_STATS_SCHEMA_VERSION, buckets: {} };
46
+ }
47
+ /**
48
+ * Which column this outcome lands in.
49
+ *
50
+ * A deadline is a deadline wherever it fired: the whole-run race in `run.ts`
51
+ * reports `deadline_exceeded` and the door call inside it reports `timeout`,
52
+ * and an operator counting "how often did a person lose their recall to the
53
+ * clock" wants one number, not two words for one event.
54
+ */
55
+ export function columnFor(outcome) {
56
+ if (outcome.status === "ok")
57
+ return "printed";
58
+ if (outcome.status === "empty")
59
+ return "empty";
60
+ if (outcome.status === "skipped")
61
+ return "skipped";
62
+ const reason = outcome.reason.split(":")[0] ?? "";
63
+ return reason === "timeout" || reason === "deadline_exceeded" ? "timeouts" : "failed";
64
+ }
65
+ export function applyRun(file, event, outcome, at) {
66
+ const bucket = memoryHookHourBucket(at);
67
+ const buckets = { ...file.buckets };
68
+ const forHour = { ...(buckets[bucket] ?? {}) };
69
+ const counts = { ...(forHour[event] ?? emptyMemoryHookCounts()) };
70
+ counts.runs += 1;
71
+ counts[columnFor(outcome)] += 1;
72
+ forHour[event] = counts;
73
+ buckets[bucket] = forHour;
74
+ // Newest kept, oldest dropped. Lexical order is chronological for this key.
75
+ const kept = Object.keys(buckets).sort().slice(-HOOK_STATS_MAX_BUCKETS);
76
+ const pruned = {};
77
+ for (const key of kept) {
78
+ const value = buckets[key];
79
+ if (value)
80
+ pruned[key] = value;
81
+ }
82
+ return { schema_version: MEMORY_HOOK_STATS_SCHEMA_VERSION, buckets: pruned };
83
+ }
84
+ /**
85
+ * Count one run. Returns a reason label — `recorded`, or why not — so the
86
+ * caller can say something rather than swallow it.
87
+ *
88
+ * A file that does not parse is REPLACED rather than merged: the alternative
89
+ * is guessing at somebody else's shape and publishing a number nobody can
90
+ * explain, and the whole history this protects is 48 hours long.
91
+ */
92
+ export function recordHookRun(event, outcome, options = {}) {
93
+ const at = options.now ?? new Date();
94
+ const file = hookStatsFilePath(options.homeDir ?? os.homedir());
95
+ const io = options.io ?? defaultIo();
96
+ try {
97
+ const existing = io.read(file);
98
+ const parsed = existing === null ? null : parseMemoryHookStats(existing);
99
+ const current = parsed && parsed.ok ? parsed.file : emptyFile();
100
+ io.write(file, `${JSON.stringify(applyRun(current, event, outcome, at))}\n`);
101
+ return "recorded";
102
+ }
103
+ catch (error) {
104
+ const code = error?.code;
105
+ return `hook_stats_unwritable:${typeof code === "string" ? code : "unknown"}`;
106
+ }
107
+ }
108
+ function defaultIo() {
109
+ return {
110
+ read: (file) => {
111
+ try {
112
+ return fs.readFileSync(file, "utf8");
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ },
118
+ write: (file, contents) => {
119
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
120
+ // Written whole and renamed into place: a hook killed by its host
121
+ // mid-write must not leave the collector a truncated file to read.
122
+ const temporary = `${file}.${process.pid}.tmp`;
123
+ fs.writeFileSync(temporary, contents, { mode: 0o600 });
124
+ fs.renameSync(temporary, file);
125
+ },
126
+ };
127
+ }
@@ -58,6 +58,8 @@ export interface RunHookDeps {
58
58
  /** Test seams. */
59
59
  payload?: HookPayload;
60
60
  stopOptions?: StopHookOptions;
61
+ /** BLI-3788. Counts one run; the default writes this machine's stats file. */
62
+ recordRun?: (event: HookEvent, outcome: HookOutcome) => string;
61
63
  }
62
64
  /**
63
65
  * Runs one hook and returns its outcome. The caller (`index.ts`) exits 0
package/dist/hooks/run.js CHANGED
@@ -41,7 +41,8 @@ import path from "node:path";
41
41
  import { resolveContainerTag } from "../container-tag.js";
42
42
  import { loadMemorySession } from "../session.js";
43
43
  import { HOOK_BUDGETS, } from "./contract.js";
44
- import { buildLogLine, buildSystemMessage, renderSystemMessageBlock } from "./log-line.js";
44
+ import { HOOK_LOG_TAG, buildLogLine, buildSystemMessage, renderSystemMessageBlock } from "./log-line.js";
45
+ import { recordHookRun } from "./hook-stats.js";
45
46
  import { readHookPayload } from "./stdin.js";
46
47
  export { HOOK_LOG_TAG } from "./log-line.js";
47
48
  /**
@@ -71,6 +72,19 @@ export async function runHook(event, deps = {}) {
71
72
  }
72
73
  }
73
74
  stderr.write(buildLogLine(event, outcome, elapsedMs, budget.totalMs));
75
+ // 5. COUNT IT (BLI-3788). A miss prints nothing by design, so this file is
76
+ // the only place a person's lost recall is ever recorded; the collector
77
+ // carries the last 24 hours of it on the heartbeat and `cockpit ops
78
+ // --memory` prints it. It happens AFTER stdout and the receipt, so a slow
79
+ // or unwritable disk can delay this process's exit and can never delay,
80
+ // truncate or change what the person's session was given. A write that did
81
+ // not happen says so on stderr rather than leaving the count quietly short.
82
+ const recorded = deps.recordRun
83
+ ? deps.recordRun(event, outcome)
84
+ : recordHookRun(event, outcome, deps.now ? { now: new Date(now()) } : {});
85
+ if (recorded !== "recorded") {
86
+ stderr.write(`${HOOK_LOG_TAG} ${event} stats not counted ${JSON.stringify({ reason: recorded })}\n`);
87
+ }
74
88
  return outcome;
75
89
  }
76
90
  async function resolveOutcome(event, deps) {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@bli-cockpit/memory-mcp",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "private": false,
5
- "description": "BLI Memory an MCP server for the memory layer BLI owns (save, search, update, forget).",
5
+ "description": "BLI Memory \u2014 an MCP server for the memory layer BLI owns (save, search, update, forget).",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "bli-memory-mcp": "dist/index.js"
@@ -28,7 +28,7 @@
28
28
  "start": "node dist/index.js"
29
29
  },
30
30
  "dependencies": {
31
- "@bli-cockpit/telemetry-core": "0.1.30",
31
+ "@bli-cockpit/telemetry-core": "0.1.32",
32
32
  "@modelcontextprotocol/sdk": "^1.29.0",
33
33
  "zod": "^4.3.6"
34
34
  },