@bli-cockpit/memory-mcp 0.1.8 → 0.1.10
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/dist/hooks/contract.js +40 -6
- package/dist/hooks/hook-stats.d.ts +67 -0
- package/dist/hooks/hook-stats.js +127 -0
- package/dist/hooks/run.d.ts +2 -0
- package/dist/hooks/run.js +15 -1
- package/package.json +3 -3
package/dist/hooks/contract.js
CHANGED
|
@@ -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
|
|
46
|
-
*
|
|
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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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:
|
|
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
|
+
}
|
package/dist/hooks/run.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "BLI Memory
|
|
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.
|
|
31
|
+
"@bli-cockpit/telemetry-core": "0.1.31",
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
33
33
|
"zod": "^4.3.6"
|
|
34
34
|
},
|