@bli-cockpit/telemetry-core 0.1.37 → 0.1.39

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.
@@ -109,6 +109,9 @@ export declare const CollectorHeartbeatSchema: z.ZodObject<{
109
109
  hook_via_direct_24h: z.ZodOptional<z.ZodNumber>;
110
110
  hook_skipped_trivial_24h: z.ZodOptional<z.ZodNumber>;
111
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>;
112
115
  hook_stats_reason: z.ZodOptional<z.ZodString>;
113
116
  hook_performance: z.ZodOptional<z.ZodObject<{
114
117
  schema_version: z.ZodLiteral<"memory-hook-performance.v1">;
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export * from "./ingest-dto.js";
11
11
  export * from "./local-config.js";
12
12
  export * from "./memory-daemon-endpoint.js";
13
13
  export * from "./memory-hook-stats.js";
14
+ export * from "./memory-extract-context.js";
14
15
  export * from "./memory-hook-performance.js";
15
16
  export * from "./memory-install-receipt.js";
16
17
  export * from "./paths.js";
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export * from "./ingest-dto.js";
11
11
  export * from "./local-config.js";
12
12
  export * from "./memory-daemon-endpoint.js";
13
13
  export * from "./memory-hook-stats.js";
14
+ export * from "./memory-extract-context.js";
14
15
  export * from "./memory-hook-performance.js";
15
16
  export * from "./memory-install-receipt.js";
16
17
  export * from "./paths.js";
@@ -0,0 +1,5 @@
1
+ /** The shared, deliberately conservative transcript characters-per-token estimate. */
2
+ export declare const CHARS_PER_TOKEN_ESTIMATE = 4;
3
+ /** The Stop-hook context window measured by the arm-6 replay. */
4
+ export declare const MEMORY_EXTRACT_CONTEXT_TOKENS = 20000;
5
+ export declare const MEMORY_EXTRACT_CONTEXT_MAX_CHARS: number;
@@ -0,0 +1,5 @@
1
+ /** The shared, deliberately conservative transcript characters-per-token estimate. */
2
+ export const CHARS_PER_TOKEN_ESTIMATE = 4;
3
+ /** The Stop-hook context window measured by the arm-6 replay. */
4
+ export const MEMORY_EXTRACT_CONTEXT_TOKENS = 20_000;
5
+ export const MEMORY_EXTRACT_CONTEXT_MAX_CHARS = MEMORY_EXTRACT_CONTEXT_TOKENS * CHARS_PER_TOKEN_ESTIMATE;
@@ -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
  *
@@ -49,6 +85,7 @@ export declare const MemoryHookCountsSchema: z.ZodObject<{
49
85
  via_direct: z.ZodOptional<z.ZodNumber>;
50
86
  skipped_trivial: z.ZodOptional<z.ZodNumber>;
51
87
  billing_exhausted: z.ZodOptional<z.ZodNumber>;
88
+ chars: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
52
89
  }, z.core.$strict>;
53
90
  export type MemoryHookCounts = z.infer<typeof MemoryHookCountsSchema>;
54
91
  /** `YYYY-MM-DDTHH` in UTC — the bucket key, and the reason the window is exact. */
@@ -70,6 +107,7 @@ export declare const MemoryHookStatsFileSchema: z.ZodObject<{
70
107
  via_direct: z.ZodOptional<z.ZodNumber>;
71
108
  skipped_trivial: z.ZodOptional<z.ZodNumber>;
72
109
  billing_exhausted: z.ZodOptional<z.ZodNumber>;
110
+ chars: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
73
111
  }, z.core.$strict>>>;
74
112
  }, z.core.$strict>;
75
113
  export type MemoryHookStatsFile = z.infer<typeof MemoryHookStatsFileSchema>;
@@ -101,6 +139,16 @@ export interface MemoryHookWindow {
101
139
  * older runs to zero.
102
140
  */
103
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;
104
152
  /** Buckets that were inside the window and had something in them. */
105
153
  hours: number;
106
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
  *
@@ -93,6 +158,28 @@ export const MemoryHookCountsSchema = z
93
158
  * `hook_stats_unrecognised_shape` overnight.
94
159
  */
95
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(),
96
183
  })
97
184
  .strict();
98
185
  /** `YYYY-MM-DDTHH` in UTC — the bucket key, and the reason the window is exact. */
@@ -125,6 +212,10 @@ export function emptyMemoryHookCounts() {
125
212
  skipped: 0,
126
213
  skipped_trivial: 0,
127
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.
128
219
  };
129
220
  }
130
221
  /**
@@ -149,6 +240,9 @@ export function summariseMemoryHookWindow(file, event, options) {
149
240
  viaMeasured: false,
150
241
  skippedTrivial: 0,
151
242
  billingExhausted: 0,
243
+ chars: blankCharsHistogram(),
244
+ charsMeasured: false,
245
+ charsSamples: 0,
152
246
  hours: 0,
153
247
  };
154
248
  for (const [bucket, events] of Object.entries(file.buckets)) {
@@ -175,6 +269,16 @@ export function summariseMemoryHookWindow(file, event, options) {
175
269
  // which sums as zero and is honest — that machine genuinely had none it
176
270
  // could name.
177
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
+ }
178
282
  total.hours += 1;
179
283
  }
180
284
  return total;
@@ -117,6 +117,9 @@ export declare const MemoryInstallReceiptSchema: z.ZodObject<{
117
117
  hook_via_direct_24h: z.ZodOptional<z.ZodNumber>;
118
118
  hook_skipped_trivial_24h: z.ZodOptional<z.ZodNumber>;
119
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>;
120
123
  hook_stats_reason: z.ZodOptional<z.ZodString>;
121
124
  hook_performance: z.ZodOptional<z.ZodObject<{
122
125
  schema_version: z.ZodLiteral<"memory-hook-performance.v1">;
@@ -163,6 +163,23 @@ export const MemoryInstallReceiptSchema = z
163
163
  * Optional like the counts above — absent reads as "not reported".
164
164
  */
165
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(),
166
183
  /** Named when the counts are absent because the file could not be read. */
167
184
  hook_stats_reason: ReasonLabelSchema.optional(),
168
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.37",
3
+ "version": "0.1.39",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",