@bli-cockpit/cli 0.2.69 → 0.2.70

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.
@@ -29,6 +29,7 @@ import { describeError } from "../health-detail.js";
29
29
  import { shouldSuppressFleetReceipts, } from "../dev-build.js";
30
30
  import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
31
31
  import { readMemoryReceiptFile } from "./memory-install-receipt.js";
32
+ import { readMemoryHookCounts } from "./memory-hook-counts.js";
32
33
  const HEARTBEAT_TIMEOUT_MS = 5_000;
33
34
  /**
34
35
  * The label form of one approved root.
@@ -123,6 +124,13 @@ export function buildCollectorHeartbeat(options) {
123
124
  * reads and a `--print-config` spawn every fifteen minutes to re-prove a state
124
125
  * that changes about once a month. The reason is returned rather than logged
125
126
  * here so the ONE heartbeat log line carries it.
127
+ *
128
+ * BLI-3788: the hook COUNTS are merged in here, on the tick, and deliberately
129
+ * not baked into the cached receipt. The five words change about monthly; how
130
+ * often the prompt hook lost its deadline changes every hour, and a number
131
+ * cached for a day would answer yesterday's question. An install receipt this
132
+ * machine could not read means no counts either — there is nowhere to put
133
+ * them — and that case keeps the receipt's own reason.
126
134
  */
127
135
  export async function readHeartbeatMemoryReceipt(options) {
128
136
  const paths = getCollectorRuntimePaths(options.homeDir);
@@ -142,7 +150,20 @@ export async function readHeartbeatMemoryReceipt(options) {
142
150
  return parsed.success ? parsed.data : null;
143
151
  },
144
152
  });
145
- return { receipt: result.receipt, reason: result.reason };
153
+ if (!result.receipt)
154
+ return { receipt: null, reason: result.reason };
155
+ const hooks = readMemoryHookCounts({
156
+ homeDir: options.homeDir ?? os.homedir(),
157
+ ...(options.now ? { now: options.now } : {}),
158
+ });
159
+ return {
160
+ receipt: {
161
+ ...result.receipt,
162
+ ...(hooks.counts ?? {}),
163
+ hook_stats_reason: hooks.reason,
164
+ },
165
+ reason: result.reason,
166
+ };
146
167
  }
147
168
  /**
148
169
  * Sends the heartbeat. Returns whether it landed; never throws.
@@ -248,6 +269,13 @@ export async function sendCollectorHeartbeatBestEffort(options) {
248
269
  // sync.err.log alone.
249
270
  memory_receipt: memory.reason,
250
271
  memory_gaps: memory.receipt ? memoryInstallGaps(memory.receipt) : null,
272
+ // BLI-3788. The hooks are registered (above) AND they either worked or
273
+ // did not (here). A prompt hook that misses its deadline prints
274
+ // nothing to the person, so these counts are the only trace one leaves
275
+ // on this machine; `null` means no counter file, never zero misses.
276
+ memory_hook_runs_24h: memory.receipt?.hook_runs_24h ?? null,
277
+ memory_hook_timeouts_24h: memory.receipt?.hook_timeouts_24h ?? null,
278
+ memory_hook_stats: memory.receipt?.hook_stats_reason ?? null,
251
279
  }));
252
280
  return true;
253
281
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * HOW OFTEN DID THE HOOKS MISS? — the reading half (BLI-3788).
3
+ *
4
+ * `bli-memory-mcp` counts every hook run into a counts-only file in this
5
+ * machine's state directory. This module reads it once a sync tick and hands
6
+ * the last 24 hours to the heartbeat, where it rides the memory receipt into
7
+ * `ambient_collector_devices.metadata.memory_install` and out onto `cockpit
8
+ * ops --memory`.
9
+ *
10
+ * Nothing here knows the file's shape: the path, the schema and the windowing
11
+ * rule are `@bli-cockpit/telemetry-core`'s `memory-hook-stats.ts`, which the
12
+ * writing package spends too. That is the whole point of putting them there.
13
+ *
14
+ * ## Which event, and why only one
15
+ *
16
+ * The PROMPT hook. It is the one a person waits on with their sentence typed,
17
+ * the one whose budget QA tick 18 caught it losing, and the one that runs on
18
+ * every turn — so it is the only one whose miss rate means anything as a
19
+ * daily number. The file holds all three; a future gauge that wants
20
+ * SessionStart or Stop reads the same rows with the same function.
21
+ *
22
+ * ## Absent is not zero
23
+ *
24
+ * A machine with no file has no counts and says `hook_stats_absent`. A machine
25
+ * whose file will not parse says `hook_stats_unparseable`. Neither reports
26
+ * zeroes, because a zero here would read as "the hooks ran and never missed",
27
+ * which is the exact false green this ticket exists to remove.
28
+ */
29
+ import fs from "node:fs";
30
+ import { memoryHookStatsFilePath, parseMemoryHookStats, summariseMemoryHookWindow, } from "@bli-cockpit/telemetry-core";
31
+ export function readMemoryHookCounts(options) {
32
+ const readText = options.readText ?? defaultReadText;
33
+ const file = memoryHookStatsFilePath(options.homeDir);
34
+ const raw = readText(file);
35
+ if (raw === null)
36
+ return { counts: null, reason: "hook_stats_absent" };
37
+ const parsed = parseMemoryHookStats(raw);
38
+ if (!parsed.ok)
39
+ return { counts: null, reason: parsed.reason };
40
+ const window = summariseMemoryHookWindow(parsed.file, "prompt", {
41
+ now: options.now ?? new Date(),
42
+ hours: 24,
43
+ });
44
+ return {
45
+ counts: {
46
+ hook_runs_24h: window.runs,
47
+ hook_timeouts_24h: window.timeouts,
48
+ hook_printed_24h: window.printed,
49
+ hook_failed_24h: window.failed,
50
+ },
51
+ reason: "ok",
52
+ };
53
+ }
54
+ function defaultReadText(file) {
55
+ try {
56
+ return fs.readFileSync(file, "utf8");
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
@@ -70,6 +70,40 @@ export function renderMemoryUsage(memory, dim) {
70
70
  }
71
71
  return lines;
72
72
  }
73
+ /**
74
+ * The hook half: one line per machine, the server's own words.
75
+ *
76
+ * A machine that reports NO counts is printed too, dimmed and named. It is the
77
+ * whole point: "no measurement" and "no misses" are different facts, and the
78
+ * only reason this section exists is that they used to be indistinguishable.
79
+ */
80
+ export function renderMemoryHooks(hooks, dim) {
81
+ const lines = ["", `HOOKS ${hooks.summary ?? "(no summary)"}`];
82
+ if (hooks.readError) {
83
+ lines.push(` the devices could not be read (${hooks.readError}); nothing is known about whether recalls are arriving`);
84
+ return lines;
85
+ }
86
+ const devices = hooks.devices ?? [];
87
+ if (devices.length === 0) {
88
+ lines.push(dim(" no live collector device to report on"));
89
+ return lines;
90
+ }
91
+ // Worst first: the machine losing recalls is the one somebody acts on, and a
92
+ // machine that reports nothing sinks below both — it is a rollout gap, not a
93
+ // failure.
94
+ const ranked = [...devices].sort((left, right) => {
95
+ const leftKnown = typeof left.runs === "number" ? 0 : 1;
96
+ const rightKnown = typeof right.runs === "number" ? 0 : 1;
97
+ if (leftKnown !== rightKnown)
98
+ return leftKnown - rightKnown;
99
+ return (right.timeouts ?? 0) - (left.timeouts ?? 0);
100
+ });
101
+ for (const device of ranked) {
102
+ const line = ` ${device.line ?? `${device.displayName ?? "?"}: (no line)`}`;
103
+ lines.push((device.timeouts ?? 0) > 0 ? line : dim(line));
104
+ }
105
+ return lines;
106
+ }
73
107
  /** The two sources that mean a PERSON's agent used memory. Never the import. */
74
108
  function agentSaves(counts) {
75
109
  return (counts?.mcp ?? 0) + (counts?.stop_hook ?? 0);
@@ -12,7 +12,7 @@
12
12
  * artifact does and does not prove — because that is exactly the moment
13
13
  * somebody is about to conclude something from it.
14
14
  */
15
- export { renderMemoryUsage } from "./ops-render-memory.js";
15
+ export { renderMemoryHooks, renderMemoryUsage } from "./ops-render-memory.js";
16
16
  /** The word a person reads. Short, fixed width, and never a bare colour. */
17
17
  export function verdictWord(verdict) {
18
18
  switch (verdict) {
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { colorEnabled, dim, writeLine } from "./cli-io.js";
27
27
  import { renderOpsStatus } from "./ops-render.js";
28
- import { renderMemoryUsage, } from "./ops-render-memory.js";
28
+ import { renderMemoryHooks, renderMemoryUsage, } from "./ops-render-memory.js";
29
29
  import { asRecord, callTower, openTower, writeCommandFailure } from "./tower-command.js";
30
30
  /**
31
31
  * A whole compile, plus a little. `maxDuration` on `/api/ops/recompile` is 800
@@ -79,7 +79,7 @@ async function runOpsStatus(command, io, tower) {
79
79
  // BLI-3762: a job that ran and wrote less than it owed is not healthy.
80
80
  row.verdict === "degraded");
81
81
  if (command.json) {
82
- writeLine(io.stdout, JSON.stringify(memory ? { ...payload, memory: memory.section } : payload));
82
+ writeLine(io.stdout, JSON.stringify(memory ? { ...payload, memory: memory.section, hooks: memory.hooks } : payload));
83
83
  }
84
84
  else {
85
85
  const styled = colorEnabled(io);
@@ -91,7 +91,16 @@ async function runOpsStatus(command, io, tower) {
91
91
  writeLine(io.stdout, line);
92
92
  }
93
93
  }
94
- else if (memory) {
94
+ // BLI-3788. Printed whenever the door sent it, including when the usage
95
+ // half could not be read: "are the recalls arriving" and "is anybody
96
+ // saving" are two questions, and one being unreadable does not silence
97
+ // the other.
98
+ if (memory?.hooks) {
99
+ for (const line of renderMemoryHooks(memory.hooks, (text) => dim(text, styled))) {
100
+ writeLine(io.stdout, line);
101
+ }
102
+ }
103
+ if (memory && !memory.section) {
95
104
  // Never a silent gap where a section was asked for: the reason the gauge
96
105
  // could not be read is printed where the gauge would have been.
97
106
  writeLine(io.stdout, "");
@@ -119,6 +128,12 @@ async function runOpsStatus(command, io, tower) {
119
128
  memory_agent_saves: memory?.section
120
129
  ? (memory.section.counts?.mcp ?? 0) + (memory.section.counts?.stop_hook ?? 0)
121
130
  : null,
131
+ // BLI-3788: whether the per-turn recalls arrived, on the same line. A
132
+ // null here is "this dashboard does not report it yet", never zero
133
+ // misses.
134
+ memory_hook_runs_24h: memory?.hooks?.totals?.runs ?? null,
135
+ memory_hook_timeouts_24h: memory?.hooks?.totals?.timeouts ?? null,
136
+ memory_hook_devices_reporting: memory?.hooks?.reporting ?? null,
122
137
  })}`);
123
138
  return unhealthy.length > 0 ? 1 : 0;
124
139
  }
@@ -144,12 +159,15 @@ async function readMemoryUsage(command, io, tower) {
144
159
  reason: result.reason,
145
160
  http_status: result.httpStatus ?? null,
146
161
  })}`);
147
- return { section: null, reason: result.reason };
162
+ return { section: null, hooks: null, reason: result.reason };
148
163
  }
149
164
  const body = asRecord(result.body);
165
+ // BLI-3788: the hook counts ride the same answer, and their absence is a
166
+ // fact about the SERVER's version rather than about this fleet — an older
167
+ // dashboard sends no `hooks` key, and printing nothing is right there.
150
168
  if (!body.memory)
151
- return { section: null, reason: "memory_section_absent" };
152
- return { section: body.memory, reason: "ok" };
169
+ return { section: null, hooks: body.hooks ?? null, reason: "memory_section_absent" };
170
+ return { section: body.memory, hooks: body.hooks ?? null, reason: "ok" };
153
171
  }
154
172
  async function runOpsRecompile(command, io, tower) {
155
173
  const person = command.person ?? "";
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.69");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.70");
19
19
  return 0;
20
20
  }
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.69",
3
+ "version": "0.2.70",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,8 +27,8 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.9",
31
- "@bli-cockpit/mcp": "0.1.10",
32
- "@bli-cockpit/telemetry-core": "0.1.30"
30
+ "@bli-cockpit/memory-mcp": "0.1.10",
31
+ "@bli-cockpit/mcp": "0.1.11",
32
+ "@bli-cockpit/telemetry-core": "0.1.31"
33
33
  }
34
34
  }