@bli-cockpit/telemetry-core 0.1.36 → 0.1.38

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.
@@ -108,6 +108,10 @@ export declare const CollectorHeartbeatSchema: z.ZodObject<{
108
108
  hook_via_daemon_24h: z.ZodOptional<z.ZodNumber>;
109
109
  hook_via_direct_24h: z.ZodOptional<z.ZodNumber>;
110
110
  hook_skipped_trivial_24h: z.ZodOptional<z.ZodNumber>;
111
+ hook_billing_exhausted_24h: z.ZodOptional<z.ZodNumber>;
112
+ hook_chars_p50: z.ZodOptional<z.ZodNumber>;
113
+ hook_chars_p95: z.ZodOptional<z.ZodNumber>;
114
+ hook_chars_samples: z.ZodOptional<z.ZodNumber>;
111
115
  hook_stats_reason: z.ZodOptional<z.ZodString>;
112
116
  hook_performance: z.ZodOptional<z.ZodObject<{
113
117
  schema_version: z.ZodLiteral<"memory-hook-performance.v1">;
@@ -29,6 +29,42 @@ export declare const MEMORY_HOOK_STATS_FILE_NAME = "memory-hook-stats.json";
29
29
  /** The three hook events, spelled as the bin's subcommands are. */
30
30
  export declare const MEMORY_HOOK_STAT_EVENTS: readonly ["session-start", "prompt", "stop"];
31
31
  export type MemoryHookStatEvent = (typeof MEMORY_HOOK_STAT_EVENTS)[number];
32
+ /**
33
+ * THE CHARACTER HISTOGRAM (ticket 3934).
34
+ *
35
+ * 250 characters a bin, 41 bins: `[0,250)`, … `[9750,10000)`, `[10000,∞)`.
36
+ *
37
+ * The width is chosen against what the hook can actually print, not against a
38
+ * round number: a recall is at most `RECALL_LIMIT` (5) memories truncated to
39
+ * `RECALL_LINE_CHARS` (300) each, plus at most `RECALL_TOWER_LIMIT` (3) Tower
40
+ * lines and the two block wrappers — call it 2,000 characters in the ordinary
41
+ * case. 250 puts eight bins across that range, which is enough resolution for
42
+ * a p50 to move visibly, and the open top bin catches a SessionStart profile
43
+ * block, which has no such limit and can be far larger.
44
+ */
45
+ export declare const MEMORY_HOOK_CHARS_BIN_WIDTH = 250;
46
+ export declare const MEMORY_HOOK_CHARS_BIN_COUNT = 41;
47
+ /** A fresh all-zero character histogram. */
48
+ export declare function blankCharsHistogram(): number[];
49
+ /**
50
+ * The bin one sample falls in. Clamped at both ends rather than throwing: this
51
+ * runs inside a hook, and a counter must never be the thing that fails a
52
+ * person's turn. A negative or non-finite length is impossible from
53
+ * `stdout.length` and is floored at bin 0 if it ever happens.
54
+ */
55
+ export declare function memoryHookCharsBin(chars: number): number;
56
+ /**
57
+ * The nearest-rank percentile of a character histogram, as BIN BOUNDS.
58
+ *
59
+ * `null` when nothing was sampled — which is the honest answer for a machine
60
+ * whose hooks never printed, and is never rendered as a zero. The top bin is
61
+ * open, so its `upper` is `null` too: "10,000 or more" is what the data says
62
+ * and inventing a ceiling would be a claim nobody measured.
63
+ */
64
+ export declare function memoryHookCharsPercentile(histogram: readonly number[], percentile: number): {
65
+ lower: number;
66
+ upper: number | null;
67
+ } | null;
32
68
  /**
33
69
  * One hour of one event.
34
70
  *
@@ -48,6 +84,8 @@ export declare const MemoryHookCountsSchema: z.ZodObject<{
48
84
  via_daemon: z.ZodOptional<z.ZodNumber>;
49
85
  via_direct: z.ZodOptional<z.ZodNumber>;
50
86
  skipped_trivial: z.ZodOptional<z.ZodNumber>;
87
+ billing_exhausted: z.ZodOptional<z.ZodNumber>;
88
+ chars: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
51
89
  }, z.core.$strict>;
52
90
  export type MemoryHookCounts = z.infer<typeof MemoryHookCountsSchema>;
53
91
  /** `YYYY-MM-DDTHH` in UTC — the bucket key, and the reason the window is exact. */
@@ -68,6 +106,8 @@ export declare const MemoryHookStatsFileSchema: z.ZodObject<{
68
106
  via_daemon: z.ZodOptional<z.ZodNumber>;
69
107
  via_direct: z.ZodOptional<z.ZodNumber>;
70
108
  skipped_trivial: z.ZodOptional<z.ZodNumber>;
109
+ billing_exhausted: z.ZodOptional<z.ZodNumber>;
110
+ chars: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
71
111
  }, z.core.$strict>>>;
72
112
  }, z.core.$strict>;
73
113
  export type MemoryHookStatsFile = z.infer<typeof MemoryHookStatsFileSchema>;
@@ -92,6 +132,23 @@ export interface MemoryHookWindow {
92
132
  viaMeasured: boolean;
93
133
  /** Trivial prompts the hook declined to search for (BLI-3881). A subset of skips. */
94
134
  skippedTrivial: number;
135
+ /**
136
+ * BLI-3891: runs the provider refused because the account has no credit. A
137
+ * subset of `failed`, summed only over buckets that carry the field, so a
138
+ * window spanning an upgrade reports what was measured rather than crediting
139
+ * older runs to zero.
140
+ */
141
+ billingExhausted: number;
142
+ /**
143
+ * Ticket 3934: the CHARACTERS injected per recall, summed bin by bin over
144
+ * buckets that CARRY the field. `charsMeasured` is false when no bucket in
145
+ * the window recorded one — a machine on an older memory-mcp — so a reader
146
+ * says "not recorded" rather than printing a percentile of nothing.
147
+ */
148
+ chars: number[];
149
+ charsMeasured: boolean;
150
+ /** Runs that contributed a character sample. Never assumed equal to `printed`. */
151
+ charsSamples: number;
95
152
  /** Buckets that were inside the window and had something in them. */
96
153
  hours: number;
97
154
  }
@@ -29,6 +29,71 @@ export const MEMORY_HOOK_STATS_SCHEMA_VERSION = "memory-hook-stats.v1";
29
29
  export const MEMORY_HOOK_STATS_FILE_NAME = "memory-hook-stats.json";
30
30
  /** The three hook events, spelled as the bin's subcommands are. */
31
31
  export const MEMORY_HOOK_STAT_EVENTS = ["session-start", "prompt", "stop"];
32
+ /**
33
+ * THE CHARACTER HISTOGRAM (ticket 3934).
34
+ *
35
+ * 250 characters a bin, 41 bins: `[0,250)`, … `[9750,10000)`, `[10000,∞)`.
36
+ *
37
+ * The width is chosen against what the hook can actually print, not against a
38
+ * round number: a recall is at most `RECALL_LIMIT` (5) memories truncated to
39
+ * `RECALL_LINE_CHARS` (300) each, plus at most `RECALL_TOWER_LIMIT` (3) Tower
40
+ * lines and the two block wrappers — call it 2,000 characters in the ordinary
41
+ * case. 250 puts eight bins across that range, which is enough resolution for
42
+ * a p50 to move visibly, and the open top bin catches a SessionStart profile
43
+ * block, which has no such limit and can be far larger.
44
+ */
45
+ export const MEMORY_HOOK_CHARS_BIN_WIDTH = 250;
46
+ export const MEMORY_HOOK_CHARS_BIN_COUNT = 41;
47
+ /** A fresh all-zero character histogram. */
48
+ export function blankCharsHistogram() {
49
+ return Array.from({ length: MEMORY_HOOK_CHARS_BIN_COUNT }, () => 0);
50
+ }
51
+ /**
52
+ * The bin one sample falls in. Clamped at both ends rather than throwing: this
53
+ * runs inside a hook, and a counter must never be the thing that fails a
54
+ * person's turn. A negative or non-finite length is impossible from
55
+ * `stdout.length` and is floored at bin 0 if it ever happens.
56
+ */
57
+ export function memoryHookCharsBin(chars) {
58
+ // NaN has no size, so it goes in the smallest bin; a positive infinity is
59
+ // enormous and goes in the open top one. Collapsing both to bin 0 would file
60
+ // an impossibly large recall as the smallest thing on the machine.
61
+ if (Number.isNaN(chars) || chars <= 0)
62
+ return 0;
63
+ if (!Number.isFinite(chars))
64
+ return MEMORY_HOOK_CHARS_BIN_COUNT - 1;
65
+ return Math.min(Math.floor(chars / MEMORY_HOOK_CHARS_BIN_WIDTH), MEMORY_HOOK_CHARS_BIN_COUNT - 1);
66
+ }
67
+ /**
68
+ * The nearest-rank percentile of a character histogram, as BIN BOUNDS.
69
+ *
70
+ * `null` when nothing was sampled — which is the honest answer for a machine
71
+ * whose hooks never printed, and is never rendered as a zero. The top bin is
72
+ * open, so its `upper` is `null` too: "10,000 or more" is what the data says
73
+ * and inventing a ceiling would be a claim nobody measured.
74
+ */
75
+ export function memoryHookCharsPercentile(histogram, percentile) {
76
+ if (!Number.isFinite(percentile) || percentile < 0 || percentile > 100) {
77
+ throw new RangeError("percentile must be between 0 and 100");
78
+ }
79
+ const samples = histogram.reduce((sum, count) => sum + count, 0);
80
+ if (samples === 0)
81
+ return null;
82
+ const rank = Math.max(1, Math.ceil((percentile / 100) * samples));
83
+ let cumulative = 0;
84
+ for (let index = 0; index < histogram.length; index += 1) {
85
+ cumulative += histogram[index] ?? 0;
86
+ if (cumulative >= rank) {
87
+ return {
88
+ lower: index * MEMORY_HOOK_CHARS_BIN_WIDTH,
89
+ upper: index === MEMORY_HOOK_CHARS_BIN_COUNT - 1
90
+ ? null
91
+ : (index + 1) * MEMORY_HOOK_CHARS_BIN_WIDTH,
92
+ };
93
+ }
94
+ }
95
+ return null;
96
+ }
32
97
  /**
33
98
  * One hour of one event.
34
99
  *
@@ -76,6 +141,45 @@ export const MemoryHookCountsSchema = z
76
141
  * failed + skipped` still holds.
77
142
  */
78
143
  skipped_trivial: z.number().int().min(0).optional(),
144
+ /**
145
+ * BLI-3891 — the SUBSET of `failed` that was the ACCOUNT, not the code.
146
+ *
147
+ * The provider refused the embedding because there is no credit left
148
+ * (`provider_billing_exhausted`). On 2026-09-06 that state made every
149
+ * recall on every machine miss its deadline and print nothing, and the
150
+ * only number anywhere would have said "timeouts", which sends a person
151
+ * to re-fit a budget rather than to a billing page.
152
+ *
153
+ * Counted BESIDE `failed`, never instead of it, so
154
+ * `runs = printed + empty + timeouts + failed + skipped` still holds, and
155
+ * OPTIONAL for the same reason `skipped_trivial` is: a file written by an
156
+ * older memory-mcp has no such key, this schema is strict, and a required
157
+ * field would make every existing machine's history
158
+ * `hook_stats_unrecognised_shape` overnight.
159
+ */
160
+ billing_exhausted: z.number().int().min(0).optional(),
161
+ /**
162
+ * HOW BIG WAS THE RECALL? (ticket 3934) — a counts-only histogram of the
163
+ * CHARACTERS the hook injected, one sample per run that printed a block.
164
+ *
165
+ * The timing histogram beside it (`memory-hook-performance.ts`) says how
166
+ * long a recall took. Nothing anywhere said how MUCH it put into a
167
+ * person's context, and that is the number behind two questions the fleet
168
+ * cannot otherwise answer: is the recall a line or a page, and is a slow
169
+ * turn slow because the door was slow or because the block was enormous.
170
+ *
171
+ * BINS, NEVER A LENGTH. A histogram of counts cannot leak a memory; a
172
+ * stored length could not either, but a bin is the smaller claim and the
173
+ * percentile is all any surface prints. The block's TEXT never comes near
174
+ * this file — see the header's prohibition, which this does not weaken.
175
+ *
176
+ * OPTIONAL, and it must stay optional. This schema is strict, a file
177
+ * written by an older memory-mcp has no such key, and a required field
178
+ * would make every existing machine's history `hook_stats_unrecognised_shape`
179
+ * overnight — the same rule `skipped_trivial` and `billing_exhausted` are
180
+ * kept under. Absent means NOT RECORDED, never "the recalls were empty".
181
+ */
182
+ chars: z.array(z.number().int().min(0)).length(MEMORY_HOOK_CHARS_BIN_COUNT).optional(),
79
183
  })
80
184
  .strict();
81
185
  /** `YYYY-MM-DDTHH` in UTC — the bucket key, and the reason the window is exact. */
@@ -99,7 +203,20 @@ export function memoryHookHourBucket(at) {
99
203
  return at.toISOString().slice(0, 13);
100
204
  }
101
205
  export function emptyMemoryHookCounts() {
102
- return { runs: 0, printed: 0, empty: 0, timeouts: 0, failed: 0, skipped: 0, skipped_trivial: 0 };
206
+ return {
207
+ runs: 0,
208
+ printed: 0,
209
+ empty: 0,
210
+ timeouts: 0,
211
+ failed: 0,
212
+ skipped: 0,
213
+ skipped_trivial: 0,
214
+ billing_exhausted: 0,
215
+ // `chars` is deliberately NOT here (ticket 3934). An hour in which the
216
+ // hook ran and never printed has no recall to measure, and seeding 41
217
+ // zeroes into every event of every bucket would both bloat the file and
218
+ // make `charsMeasured` true for a machine that has measured nothing.
219
+ };
103
220
  }
104
221
  /**
105
222
  * Sum one event's buckets over the last `hours` hours, ending at `now`.
@@ -122,6 +239,10 @@ export function summariseMemoryHookWindow(file, event, options) {
122
239
  viaDirect: 0,
123
240
  viaMeasured: false,
124
241
  skippedTrivial: 0,
242
+ billingExhausted: 0,
243
+ chars: blankCharsHistogram(),
244
+ charsMeasured: false,
245
+ charsSamples: 0,
125
246
  hours: 0,
126
247
  };
127
248
  for (const [bucket, events] of Object.entries(file.buckets)) {
@@ -144,6 +265,20 @@ export function summariseMemoryHookWindow(file, event, options) {
144
265
  // Absent means an older writer never counted one, which sums as zero and
145
266
  // is honest: that machine genuinely skipped none, because it could not.
146
267
  total.skippedTrivial += counts.skipped_trivial ?? 0;
268
+ // Same rule (BLI-3891): absent means an older writer never counted one,
269
+ // which sums as zero and is honest — that machine genuinely had none it
270
+ // could name.
271
+ total.billingExhausted += counts.billing_exhausted ?? 0;
272
+ // Ticket 3934: summed only over buckets that CARRY the histogram, so a
273
+ // window spanning an upgrade reports what was measured rather than
274
+ // crediting the older hours with zero-length recalls.
275
+ if (counts.chars) {
276
+ total.charsMeasured = true;
277
+ for (let index = 0; index < total.chars.length; index += 1) {
278
+ total.chars[index] = (total.chars[index] ?? 0) + (counts.chars[index] ?? 0);
279
+ total.charsSamples += counts.chars[index] ?? 0;
280
+ }
281
+ }
147
282
  total.hours += 1;
148
283
  }
149
284
  return total;
@@ -116,6 +116,10 @@ export declare const MemoryInstallReceiptSchema: z.ZodObject<{
116
116
  hook_via_daemon_24h: z.ZodOptional<z.ZodNumber>;
117
117
  hook_via_direct_24h: z.ZodOptional<z.ZodNumber>;
118
118
  hook_skipped_trivial_24h: z.ZodOptional<z.ZodNumber>;
119
+ hook_billing_exhausted_24h: z.ZodOptional<z.ZodNumber>;
120
+ hook_chars_p50: z.ZodOptional<z.ZodNumber>;
121
+ hook_chars_p95: z.ZodOptional<z.ZodNumber>;
122
+ hook_chars_samples: z.ZodOptional<z.ZodNumber>;
119
123
  hook_stats_reason: z.ZodOptional<z.ZodString>;
120
124
  hook_performance: z.ZodOptional<z.ZodObject<{
121
125
  schema_version: z.ZodLiteral<"memory-hook-performance.v1">;
@@ -154,6 +154,32 @@ export const MemoryInstallReceiptSchema = z
154
154
  * none.
155
155
  */
156
156
  hook_skipped_trivial_24h: z.number().int().min(0).optional(),
157
+ /**
158
+ * BLI-3891: runs whose recall failed because the PROVIDER ACCOUNT has no
159
+ * credit. A subset of `hook_failed_24h`, and the reason it is on the
160
+ * receipt at all: on 2026-09-06 every machine's recall died for four hours
161
+ * and the only word any surface had for it was "missed the deadline",
162
+ * which sends a person to re-fit a budget instead of to a billing page.
163
+ * Optional like the counts above — absent reads as "not reported".
164
+ */
165
+ hook_billing_exhausted_24h: z.number().int().min(0).optional(),
166
+ /**
167
+ * HOW BIG WAS A RECALL (ticket 3934) — the p50 and p95 CHARACTERS the
168
+ * prompt hook injected over the same 24 hours, as the lower bound of the
169
+ * bin each percentile fell in, with the number of runs behind them.
170
+ *
171
+ * The timing counters above say whether a recall ARRIVED. These say how
172
+ * much of a person's context it took when it did, which is the other half
173
+ * of "is the recall worth its budget" and had no surface anywhere.
174
+ *
175
+ * All three optional, and ABSENT on a machine whose memory-mcp is older
176
+ * than the histogram — which reads as "not recorded", never as "the
177
+ * recalls were empty". They are not derivable from the counts above and
178
+ * must never be inferred from them.
179
+ */
180
+ hook_chars_p50: z.number().int().min(0).optional(),
181
+ hook_chars_p95: z.number().int().min(0).optional(),
182
+ hook_chars_samples: z.number().int().min(0).optional(),
157
183
  /** Named when the counts are absent because the file could not be read. */
158
184
  hook_stats_reason: ReasonLabelSchema.optional(),
159
185
  /** Observed ordinary invocations, not legacy floors or a full host trace. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/telemetry-core",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",