@bli-cockpit/telemetry-core 0.1.30 → 0.1.31

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.
@@ -99,6 +99,11 @@ export declare const CollectorHeartbeatSchema: z.ZodObject<{
99
99
  }, z.core.$strict>;
100
100
  reasons: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
101
101
  bin_found: z.ZodOptional<z.ZodBoolean>;
102
+ hook_runs_24h: z.ZodOptional<z.ZodNumber>;
103
+ hook_timeouts_24h: z.ZodOptional<z.ZodNumber>;
104
+ hook_printed_24h: z.ZodOptional<z.ZodNumber>;
105
+ hook_failed_24h: z.ZodOptional<z.ZodNumber>;
106
+ hook_stats_reason: z.ZodOptional<z.ZodString>;
102
107
  }, z.core.$strict>>;
103
108
  setup_receipt: z.ZodOptional<z.ZodObject<{
104
109
  schema_version: z.ZodLiteral<"setup-receipt.v1">;
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from "./evidence-reconcile.js";
9
9
  export * from "./evidence-upload.js";
10
10
  export * from "./ingest-dto.js";
11
11
  export * from "./local-config.js";
12
+ export * from "./memory-hook-stats.js";
12
13
  export * from "./memory-install-receipt.js";
13
14
  export * from "./paths.js";
14
15
  export * from "./privacy.js";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export * from "./evidence-reconcile.js";
9
9
  export * from "./evidence-upload.js";
10
10
  export * from "./ingest-dto.js";
11
11
  export * from "./local-config.js";
12
+ export * from "./memory-hook-stats.js";
12
13
  export * from "./memory-install-receipt.js";
13
14
  export * from "./paths.js";
14
15
  export * from "./privacy.js";
@@ -0,0 +1,105 @@
1
+ /**
2
+ * THE HOOK COUNTER, SHARED (BLI-3788).
3
+ *
4
+ * `@bli-cockpit/memory-mcp` WRITES this file — one hook run at a time, on the
5
+ * machine, counts only — and `@bli-cockpit/local-collector` READS it once a
6
+ * tick to put `hook_timeouts_24h` on the heartbeat's memory receipt. The two
7
+ * packages are published separately and never import each other, so the
8
+ * contract between them lives here, in the one package they both depend on:
9
+ * the file's address, its shape, and the rule for turning hourly buckets into
10
+ * a 24-hour number.
11
+ *
12
+ * The alternative was two copies of a JSON shape kept in step by comment, and
13
+ * BLI-2541 is what that costs — a fixture built from our own formatting rather
14
+ * than from the real thing, green while the fleet was broken.
15
+ *
16
+ * WHY IT EXISTS AT ALL: a prompt hook that misses its deadline prints NOTHING,
17
+ * deliberately. That is right for the person and blind for the operator, and
18
+ * QA tick 18 found the recall silently missing on 4 of 6 runs with no surface
19
+ * anywhere reporting it. These counts are that silence made countable.
20
+ *
21
+ * WHAT MAY NEVER BE IN IT: a prompt, a memory, a container tag, a path, a
22
+ * token, or any string that did not come from the hook's own closed reason
23
+ * vocabulary. Counts and hour labels, nothing else.
24
+ */
25
+ import { z } from "zod";
26
+ export declare const MEMORY_HOOK_STATS_SCHEMA_VERSION = "memory-hook-stats.v1";
27
+ /** The file name, in the collector's state directory. One spelling, here. */
28
+ export declare const MEMORY_HOOK_STATS_FILE_NAME = "memory-hook-stats.json";
29
+ /** The three hook events, spelled as the bin's subcommands are. */
30
+ export declare const MEMORY_HOOK_STAT_EVENTS: readonly ["session-start", "prompt", "stop"];
31
+ export type MemoryHookStatEvent = (typeof MEMORY_HOOK_STAT_EVENTS)[number];
32
+ /**
33
+ * One hour of one event.
34
+ *
35
+ * `timeouts` is counted apart from `failed` (a refusal, an outage, an
36
+ * unpaired machine — different fixes) and apart from `empty` (the record was
37
+ * genuinely silent, which is an answer and not a fault). Collapsing them is
38
+ * exactly how "the door is slow" and "the shelf is empty" became one
39
+ * unreadable number in the first place.
40
+ */
41
+ export declare const MemoryHookCountsSchema: z.ZodObject<{
42
+ runs: z.ZodNumber;
43
+ printed: z.ZodNumber;
44
+ empty: z.ZodNumber;
45
+ timeouts: z.ZodNumber;
46
+ failed: z.ZodNumber;
47
+ skipped: z.ZodNumber;
48
+ }, z.core.$strict>;
49
+ export type MemoryHookCounts = z.infer<typeof MemoryHookCountsSchema>;
50
+ /** `YYYY-MM-DDTHH` in UTC — the bucket key, and the reason the window is exact. */
51
+ export declare const MEMORY_HOOK_BUCKET_PATTERN: RegExp;
52
+ export declare const MemoryHookStatsFileSchema: z.ZodObject<{
53
+ schema_version: z.ZodLiteral<"memory-hook-stats.v1">;
54
+ buckets: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodEnum<{
55
+ "session-start": "session-start";
56
+ prompt: "prompt";
57
+ stop: "stop";
58
+ }> & z.core.$partial, z.ZodObject<{
59
+ runs: z.ZodNumber;
60
+ printed: z.ZodNumber;
61
+ empty: z.ZodNumber;
62
+ timeouts: z.ZodNumber;
63
+ failed: z.ZodNumber;
64
+ skipped: z.ZodNumber;
65
+ }, z.core.$strict>>>;
66
+ }, z.core.$strict>;
67
+ export type MemoryHookStatsFile = z.infer<typeof MemoryHookStatsFileSchema>;
68
+ export declare function memoryHookStatsFilePath(homeDir: string): string;
69
+ /** The hour a moment belongs to. UTC, so two machines agree on a boundary. */
70
+ export declare function memoryHookHourBucket(at: Date): string;
71
+ export declare function emptyMemoryHookCounts(): MemoryHookCounts;
72
+ export interface MemoryHookWindow {
73
+ runs: number;
74
+ printed: number;
75
+ timeouts: number;
76
+ failed: number;
77
+ /** Buckets that were inside the window and had something in them. */
78
+ hours: number;
79
+ }
80
+ /**
81
+ * Sum one event's buckets over the last `hours` hours, ending at `now`.
82
+ *
83
+ * The window is inclusive of the hour `now` falls in, so a run three minutes
84
+ * ago counts; that makes the last bucket partial, which is the honest
85
+ * direction for a gauge whose whole job is to notice that something is going
86
+ * wrong RIGHT NOW.
87
+ */
88
+ export declare function summariseMemoryHookWindow(file: MemoryHookStatsFile, event: MemoryHookStatEvent, options: {
89
+ now: Date;
90
+ hours?: number;
91
+ }): MemoryHookWindow;
92
+ /**
93
+ * Parse what is on disk, or say why not.
94
+ *
95
+ * A file this version does not recognise is REPLACED by the writer and
96
+ * reported as unreadable by the reader — never merged into a shape it was not
97
+ * written in, which would publish a number nobody can explain.
98
+ */
99
+ export declare function parseMemoryHookStats(raw: string): {
100
+ ok: true;
101
+ file: MemoryHookStatsFile;
102
+ } | {
103
+ ok: false;
104
+ reason: string;
105
+ };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * THE HOOK COUNTER, SHARED (BLI-3788).
3
+ *
4
+ * `@bli-cockpit/memory-mcp` WRITES this file — one hook run at a time, on the
5
+ * machine, counts only — and `@bli-cockpit/local-collector` READS it once a
6
+ * tick to put `hook_timeouts_24h` on the heartbeat's memory receipt. The two
7
+ * packages are published separately and never import each other, so the
8
+ * contract between them lives here, in the one package they both depend on:
9
+ * the file's address, its shape, and the rule for turning hourly buckets into
10
+ * a 24-hour number.
11
+ *
12
+ * The alternative was two copies of a JSON shape kept in step by comment, and
13
+ * BLI-2541 is what that costs — a fixture built from our own formatting rather
14
+ * than from the real thing, green while the fleet was broken.
15
+ *
16
+ * WHY IT EXISTS AT ALL: a prompt hook that misses its deadline prints NOTHING,
17
+ * deliberately. That is right for the person and blind for the operator, and
18
+ * QA tick 18 found the recall silently missing on 4 of 6 runs with no surface
19
+ * anywhere reporting it. These counts are that silence made countable.
20
+ *
21
+ * WHAT MAY NEVER BE IN IT: a prompt, a memory, a container tag, a path, a
22
+ * token, or any string that did not come from the hook's own closed reason
23
+ * vocabulary. Counts and hour labels, nothing else.
24
+ */
25
+ import { z } from "zod";
26
+ import { getUserLocalCockpitPaths } from "./paths.js";
27
+ export const MEMORY_HOOK_STATS_SCHEMA_VERSION = "memory-hook-stats.v1";
28
+ /** The file name, in the collector's state directory. One spelling, here. */
29
+ export const MEMORY_HOOK_STATS_FILE_NAME = "memory-hook-stats.json";
30
+ /** The three hook events, spelled as the bin's subcommands are. */
31
+ export const MEMORY_HOOK_STAT_EVENTS = ["session-start", "prompt", "stop"];
32
+ /**
33
+ * One hour of one event.
34
+ *
35
+ * `timeouts` is counted apart from `failed` (a refusal, an outage, an
36
+ * unpaired machine — different fixes) and apart from `empty` (the record was
37
+ * genuinely silent, which is an answer and not a fault). Collapsing them is
38
+ * exactly how "the door is slow" and "the shelf is empty" became one
39
+ * unreadable number in the first place.
40
+ */
41
+ export const MemoryHookCountsSchema = z
42
+ .object({
43
+ runs: z.number().int().min(0),
44
+ printed: z.number().int().min(0),
45
+ empty: z.number().int().min(0),
46
+ timeouts: z.number().int().min(0),
47
+ failed: z.number().int().min(0),
48
+ skipped: z.number().int().min(0),
49
+ })
50
+ .strict();
51
+ /** `YYYY-MM-DDTHH` in UTC — the bucket key, and the reason the window is exact. */
52
+ export const MEMORY_HOOK_BUCKET_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}$/u;
53
+ export const MemoryHookStatsFileSchema = z
54
+ .object({
55
+ schema_version: z.literal(MEMORY_HOOK_STATS_SCHEMA_VERSION),
56
+ // `partialRecord`, not `record`: a record keyed by an enum is EXHAUSTIVE
57
+ // in zod 4 — every event would be required — and an hour in which only
58
+ // the prompt hook ran is the ordinary case, not a malformed file.
59
+ buckets: z.record(z.string().regex(MEMORY_HOOK_BUCKET_PATTERN), z.partialRecord(z.enum(MEMORY_HOOK_STAT_EVENTS), MemoryHookCountsSchema)),
60
+ })
61
+ .strict();
62
+ export function memoryHookStatsFilePath(homeDir) {
63
+ const paths = getUserLocalCockpitPaths(homeDir);
64
+ const separator = paths.state_dir.includes("\\") ? "\\" : "/";
65
+ return `${paths.state_dir}${separator}${MEMORY_HOOK_STATS_FILE_NAME}`;
66
+ }
67
+ /** The hour a moment belongs to. UTC, so two machines agree on a boundary. */
68
+ export function memoryHookHourBucket(at) {
69
+ return at.toISOString().slice(0, 13);
70
+ }
71
+ export function emptyMemoryHookCounts() {
72
+ return { runs: 0, printed: 0, empty: 0, timeouts: 0, failed: 0, skipped: 0 };
73
+ }
74
+ /**
75
+ * Sum one event's buckets over the last `hours` hours, ending at `now`.
76
+ *
77
+ * The window is inclusive of the hour `now` falls in, so a run three minutes
78
+ * ago counts; that makes the last bucket partial, which is the honest
79
+ * direction for a gauge whose whole job is to notice that something is going
80
+ * wrong RIGHT NOW.
81
+ */
82
+ export function summariseMemoryHookWindow(file, event, options) {
83
+ const hours = options.hours ?? 24;
84
+ const earliest = memoryHookHourBucket(new Date(options.now.getTime() - (hours - 1) * 3_600_000));
85
+ const latest = memoryHookHourBucket(options.now);
86
+ const total = { runs: 0, printed: 0, timeouts: 0, failed: 0, hours: 0 };
87
+ for (const [bucket, events] of Object.entries(file.buckets)) {
88
+ // Lexical order is chronological for this key, which is the only reason a
89
+ // string comparison is allowed to stand in for a date one here.
90
+ if (bucket < earliest || bucket > latest)
91
+ continue;
92
+ const counts = events[event];
93
+ if (!counts)
94
+ continue;
95
+ total.runs += counts.runs;
96
+ total.printed += counts.printed;
97
+ total.timeouts += counts.timeouts;
98
+ total.failed += counts.failed;
99
+ total.hours += 1;
100
+ }
101
+ return total;
102
+ }
103
+ /**
104
+ * Parse what is on disk, or say why not.
105
+ *
106
+ * A file this version does not recognise is REPLACED by the writer and
107
+ * reported as unreadable by the reader — never merged into a shape it was not
108
+ * written in, which would publish a number nobody can explain.
109
+ */
110
+ export function parseMemoryHookStats(raw) {
111
+ let parsed;
112
+ try {
113
+ parsed = JSON.parse(raw);
114
+ }
115
+ catch {
116
+ return { ok: false, reason: "hook_stats_unparseable" };
117
+ }
118
+ const result = MemoryHookStatsFileSchema.safeParse(parsed);
119
+ if (!result.success)
120
+ return { ok: false, reason: "hook_stats_unrecognised_shape" };
121
+ return { ok: true, file: result.data };
122
+ }
@@ -109,6 +109,11 @@ export declare const MemoryInstallReceiptSchema: z.ZodObject<{
109
109
  }, z.core.$strict>;
110
110
  reasons: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
111
111
  bin_found: z.ZodOptional<z.ZodBoolean>;
112
+ hook_runs_24h: z.ZodOptional<z.ZodNumber>;
113
+ hook_timeouts_24h: z.ZodOptional<z.ZodNumber>;
114
+ hook_printed_24h: z.ZodOptional<z.ZodNumber>;
115
+ hook_failed_24h: z.ZodOptional<z.ZodNumber>;
116
+ hook_stats_reason: z.ZodOptional<z.ZodString>;
112
117
  }, z.core.$strict>;
113
118
  export type MemoryInstallReceipt = z.infer<typeof MemoryInstallReceiptSchema>;
114
119
  /** Every piece is `ok`. The one question the board asks first. */
@@ -103,6 +103,36 @@ export const MemoryInstallReceiptSchema = z
103
103
  * (`no_bin_no_write`).
104
104
  */
105
105
  bin_found: z.boolean().optional(),
106
+ /**
107
+ * DID THE HOOKS ACTUALLY WORK? (BLI-3788)
108
+ *
109
+ * The five words above say a hook is REGISTERED. They cannot say whether
110
+ * it did anything, and a prompt hook that loses its deadline prints
111
+ * nothing by design — so a machine could read `memory: claude mcp ✓ hooks
112
+ * ✓` while two thirds of that person's turns silently got no recall (QA
113
+ * tick 18). These four counts, summed over the last 24 hours from the
114
+ * hook's own on-disk counter, are that gap closed:
115
+ *
116
+ * `hook_runs_24h` hooks that ran at all — the denominator, without
117
+ * which a timeout count says nothing
118
+ * `hook_timeouts_24h` runs that lost the clock and printed nothing
119
+ * `hook_printed_24h` runs that injected a block
120
+ * `hook_failed_24h` runs that could not run (a refusal, an outage)
121
+ *
122
+ * An honestly EMPTY shelf is in none of them but `runs`, because zero hits
123
+ * is a real answer and not a fault. All four are optional: a collector or
124
+ * a memory-mcp older than this ticket reports none, and absent must read
125
+ * as "not reported", never as zero.
126
+ *
127
+ * They are a FLOOR. Two hooks finishing in the same millisecond can lose
128
+ * one increment to the file rename, which understates and cannot invent.
129
+ */
130
+ hook_runs_24h: z.number().int().min(0).optional(),
131
+ hook_timeouts_24h: z.number().int().min(0).optional(),
132
+ hook_printed_24h: z.number().int().min(0).optional(),
133
+ hook_failed_24h: z.number().int().min(0).optional(),
134
+ /** Named when the counts are absent because the file could not be read. */
135
+ hook_stats_reason: ReasonLabelSchema.optional(),
106
136
  })
107
137
  .strict();
108
138
  /** Every piece is `ok`. The one question the board asks first. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/telemetry-core",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",