@bli-cockpit/telemetry-core 0.1.30 → 0.1.32

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.
@@ -58,6 +58,8 @@ export declare const CollectorHeartbeatSchema: z.ZodObject<{
58
58
  sessions_outside_root: z.ZodOptional<z.ZodNumber>;
59
59
  sessions_new_this_tick: z.ZodOptional<z.ZodNumber>;
60
60
  sessions_pending_upload: z.ZodOptional<z.ZodNumber>;
61
+ staging_uncommitted_bytes: z.ZodOptional<z.ZodNumber>;
62
+ staging_uncommitted_reason: z.ZodOptional<z.ZodString>;
61
63
  memory_install: z.ZodOptional<z.ZodObject<{
62
64
  schema_version: z.ZodLiteral<"memory-install-receipt.v1">;
63
65
  checked_at: z.ZodString;
@@ -99,6 +101,11 @@ export declare const CollectorHeartbeatSchema: z.ZodObject<{
99
101
  }, z.core.$strict>;
100
102
  reasons: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
101
103
  bin_found: z.ZodOptional<z.ZodBoolean>;
104
+ hook_runs_24h: z.ZodOptional<z.ZodNumber>;
105
+ hook_timeouts_24h: z.ZodOptional<z.ZodNumber>;
106
+ hook_printed_24h: z.ZodOptional<z.ZodNumber>;
107
+ hook_failed_24h: z.ZodOptional<z.ZodNumber>;
108
+ hook_stats_reason: z.ZodOptional<z.ZodString>;
102
109
  }, z.core.$strict>>;
103
110
  setup_receipt: z.ZodOptional<z.ZodObject<{
104
111
  schema_version: z.ZodLiteral<"setup-receipt.v1">;
@@ -114,6 +114,37 @@ export const CollectorHeartbeatSchema = z
114
114
  .nonnegative()
115
115
  .max(1_000_000)
116
116
  .optional(),
117
+ /**
118
+ * BLI-3797. How many staged raw-evidence bytes on this machine the upload
119
+ * ledger cannot vouch for, and the one label that explains most of them.
120
+ *
121
+ * These are the "collection has not landed" numbers. A machine can be
122
+ * alive, syncing, green on every other counter here and still be holding
123
+ * gigabytes of transcript that never reached storage — on the reference Mac
124
+ * 1,836 MB across 234 objects, the oldest staged 45 days earlier, with the
125
+ * server itself answering `unknown_to_server` when asked. Nothing in this
126
+ * heartbeat could say so, so `cockpit ops` could not either.
127
+ *
128
+ * `staging_uncommitted_reason` follows `last_sync_reason`'s
129
+ * `nothing_in_root:47` idiom — `<dominant reason>:<object count>` — so one
130
+ * bounded label carries both halves and a reader prints it verbatim rather
131
+ * than parsing it. Both fields are optional: a collector too old to compute
132
+ * them keeps posting, and an absent value reads as "this collector predates
133
+ * the counter", never as "this machine holds nothing".
134
+ */
135
+ staging_uncommitted_bytes: z
136
+ .number()
137
+ .int()
138
+ .nonnegative()
139
+ .max(Number.MAX_SAFE_INTEGER)
140
+ .optional(),
141
+ staging_uncommitted_reason: z
142
+ .string()
143
+ .trim()
144
+ .min(1)
145
+ .max(120)
146
+ .regex(/^[a-z0-9_:.-]+$/iu)
147
+ .optional(),
117
148
  /**
118
149
  * BLI-3729. Is BLI Memory actually switched on for both agent hosts on
119
150
  * this machine? Six words computed by reading the host configs BACK, not
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.32",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",