@bli-cockpit/cli 0.2.81 → 0.2.84

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.
@@ -31,6 +31,7 @@ import { shouldSuppressFleetReceipts, } from "../dev-build.js";
31
31
  import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
32
32
  import { readMemoryReceiptFile } from "./memory-install-receipt.js";
33
33
  import { readMemoryHookCounts } from "./memory-hook-counts.js";
34
+ import { readMemoryHookPerformance } from "./memory-hook-performance.js";
34
35
  const HEARTBEAT_TIMEOUT_MS = 5_000;
35
36
  /**
36
37
  * The label form of one approved root.
@@ -191,11 +192,16 @@ export async function readHeartbeatMemoryReceipt(options) {
191
192
  homeDir: options.homeDir ?? os.homedir(),
192
193
  ...(options.now ? { now: options.now } : {}),
193
194
  });
195
+ const performance = await readMemoryHookPerformance({
196
+ homeDir: options.homeDir ?? os.homedir(),
197
+ ...(options.now ? { now: options.now } : {}),
198
+ });
194
199
  return {
195
200
  receipt: {
196
201
  ...result.receipt,
197
202
  ...(hooks.counts ?? {}),
198
203
  hook_stats_reason: hooks.reason,
204
+ hook_performance: performance,
199
205
  },
200
206
  reason: result.reason,
201
207
  };
@@ -4,8 +4,9 @@
4
4
  * Its own sibling of `local-args-tower.ts` rather than folded into
5
5
  * `local-args-tower-pages.ts` or `-docs-msg.ts`: search is not one surface's
6
6
  * verb, it is the door OVER all of them — documents, messages, issues, meeting
7
- * notes and memory and putting it under any one family's doc comment would
8
- * say something untrue about what it reads.
7
+ * notes, the caller's own mail and calendar (BLI-3880), and memory and
8
+ * putting it under any one family's doc comment would say something untrue
9
+ * about what it reads.
9
10
  *
10
11
  * The query is a POSITIONAL, not a flag, because that is how every search
11
12
  * command a person has ever typed works (`grep`, `rg`, `gh search`). Several
@@ -13,8 +14,20 @@
13
14
  * behaves the way it looks, without demanding quotes.
14
15
  */
15
16
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
16
- /** The five corpora. Kept here so an unknown kind is refused BEFORE a round trip. */
17
- export const SEARCH_KINDS = ["doc", "msg", "issue", "note", "memory"];
17
+ /**
18
+ * Every corpus. Kept here so an unknown kind is refused BEFORE a round trip,
19
+ * and mirrors `SEARCH_KINDS` in the dashboard — nothing in the type system ties
20
+ * the two lists together.
21
+ */
22
+ export const SEARCH_KINDS = [
23
+ "doc",
24
+ "msg",
25
+ "issue",
26
+ "note",
27
+ "mail",
28
+ "cal",
29
+ "memory",
30
+ ];
18
31
  export function parseSearchArgs(args) {
19
32
  const values = parseNamedArgs(args, {
20
33
  allowedFlags: ["--home", "--dashboard-url", "--kind", "--limit", "--json"],
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The shape of `cockpit search` (BLI-3728; split out of
3
+ * `local-command-shapes.ts` by BLI-3880).
4
+ *
5
+ * Its own module for the reason the verb itself has one
6
+ * (`local-args-tower-search.ts`): search is not one surface's verb, it is the
7
+ * door OVER all of them, so it sits beside the surface families rather than
8
+ * inside any of them. Two more corpora also pushed the shape file past this
9
+ * repo's readability ceiling, and a member with a paragraph of its own is
10
+ * exactly the member to lift out first.
11
+ *
12
+ * `local-command-shapes.ts` imports this type and keeps `SearchCommandShape`
13
+ * in the `LocalCommand` union, so nothing that reads a parsed command changed.
14
+ * A new module here must also join `scripts/build-public-cli.mjs`
15
+ * `runtimeFiles` — but this one is types only, erased at build, and the CLI's
16
+ * own runtime-file assert is what proves it either way.
17
+ */
18
+ export {};
@@ -321,6 +321,9 @@ export function localSubcommandHelp(command) {
321
321
  " --memory-days N changes the window (1-30) and implies --memory. Searches are",
322
322
  " NOT counted — nothing in Tower records one, and the gauge says so rather than",
323
323
  " printing a zero you would read as nobody searching.",
324
+ " It also shows ordinary prompt-hook observations: outcome counts, 100 ms",
325
+ " duration bounds, actual vector-cache reuse and named coverage gaps. These",
326
+ " are hook-body timings, not full host wait or retrieval-quality scores.",
324
327
  " cockpit ops recompile --person <email|name|id> [--dry-run]",
325
328
  " Writes that person's page again, now. Your own page is always yours to",
326
329
  " recompile; somebody else's is for the people who read across the team.",
@@ -47,6 +47,7 @@ export function readMemoryHookCounts(options) {
47
47
  hook_timeouts_24h: window.timeouts,
48
48
  hook_printed_24h: window.printed,
49
49
  hook_failed_24h: window.failed,
50
+ hook_skipped_trivial_24h: window.skippedTrivial,
50
51
  },
51
52
  reason: "ok",
52
53
  };
@@ -0,0 +1,175 @@
1
+ /** Read independent observations; never turn a missing sample into a zero. */
2
+ import { randomUUID } from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { MemoryHookSampleSchema, MemoryHookSamplingLeaseSchema, memoryHookSamplesDirectory, blankHistogram, addDuration, } from "@bli-cockpit/telemetry-core";
6
+ const HOUR_MS = 3_600_000;
7
+ export const HOOK_SAMPLE_RETENTION_HOURS = 48;
8
+ export const HOOK_SAMPLE_READ_LIMIT = 20_000;
9
+ const MAX_SAMPLE_BYTES = 1_024;
10
+ const READ_BUDGET_MS = 1_000;
11
+ const HOUR_DIRECTORY = /^\d{4}-\d{2}-\d{2}T\d{2}$/u;
12
+ const SAMPLE_FILE = /^[a-f0-9-]{36}\.json$/u;
13
+ export async function readMemoryHookPerformance(options) {
14
+ const now = options.now ?? new Date();
15
+ const directory = memoryHookSamplesDirectory(options.homeDir);
16
+ const start = now.getTime() - 24 * HOUR_MS;
17
+ const reasons = new Set();
18
+ const result = {
19
+ schema_version: "memory-hook-performance.v1",
20
+ window_start: new Date(start).toISOString(),
21
+ window_end: now.toISOString(),
22
+ sampling_since: null,
23
+ samples: 0,
24
+ outcomes: { printed: 0, empty: 0, timeouts: 0, failed: 0, skipped: 0 },
25
+ duration_by_cache: {
26
+ hit: blankHistogram(), miss: blankHistogram(), shared: blankHistogram(), unknown: blankHistogram(),
27
+ },
28
+ producer_versions: [],
29
+ invalid_samples: 0,
30
+ incomplete_samples: 0,
31
+ unreadable_samples: 0,
32
+ capped: false,
33
+ reasons: [],
34
+ };
35
+ const versions = new Map();
36
+ const started = performance.now();
37
+ let filesRead = 0;
38
+ try {
39
+ const lease = await renewLease(directory, now, reasons);
40
+ result.sampling_since = lease.continuous_since;
41
+ if (Date.parse(lease.continuous_since) > start)
42
+ reasons.add("partial_window");
43
+ // Exactly the 25 hour directories touching this rolling 24-hour window.
44
+ // Timestamp filtering below trims both partial boundary hours.
45
+ hours: for (let offset = 0; offset <= 24; offset += 1) {
46
+ const hour = new Date(now.getTime() - offset * HOUR_MS).toISOString().slice(0, 13);
47
+ let entries;
48
+ try {
49
+ entries = await fs.opendir(path.join(directory, hour));
50
+ }
51
+ catch (error) {
52
+ if (error.code !== "ENOENT")
53
+ reasons.add("read_failed");
54
+ continue;
55
+ }
56
+ for await (const entry of entries) {
57
+ if (!entry.isFile() || !SAMPLE_FILE.test(entry.name))
58
+ continue;
59
+ if (filesRead >= (options.maxFiles ?? HOOK_SAMPLE_READ_LIMIT) ||
60
+ performance.now() - started >= (options.readBudgetMs ?? READ_BUDGET_MS)) {
61
+ result.capped = true;
62
+ reasons.add("read_limit");
63
+ break hours;
64
+ }
65
+ filesRead += 1;
66
+ const file = path.join(directory, hour, entry.name);
67
+ let raw;
68
+ try {
69
+ const stat = await fs.stat(file);
70
+ if (stat.size > MAX_SAMPLE_BYTES) {
71
+ result.invalid_samples += 1;
72
+ continue;
73
+ }
74
+ raw = await fs.readFile(file, "utf8");
75
+ }
76
+ catch {
77
+ result.unreadable_samples += 1;
78
+ continue;
79
+ }
80
+ if (!raw.endsWith("\n")) {
81
+ // Could be a live writer or one killed by the host. Leave it for the
82
+ // next tick, and keep the incomplete observation out of every rate.
83
+ result.incomplete_samples += 1;
84
+ continue;
85
+ }
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(raw);
89
+ }
90
+ catch {
91
+ result.invalid_samples += 1;
92
+ continue;
93
+ }
94
+ const sample = MemoryHookSampleSchema.safeParse(parsed);
95
+ if (!sample.success || sample.data.recorded_at.slice(0, 13) !== hour) {
96
+ result.invalid_samples += 1;
97
+ continue;
98
+ }
99
+ const at = Date.parse(sample.data.recorded_at);
100
+ if (at < start || at > now.getTime())
101
+ continue;
102
+ const version = sample.data.producer_version;
103
+ if (!versions.has(version) && versions.size >= 16) {
104
+ result.capped = true;
105
+ reasons.add("read_limit");
106
+ break hours;
107
+ }
108
+ result.samples += 1;
109
+ result.outcomes[sample.data.outcome] += 1;
110
+ addDuration(result.duration_by_cache[sample.data.embed_cache], sample.data.elapsed_ms);
111
+ versions.set(version, (versions.get(version) ?? 0) + 1);
112
+ }
113
+ }
114
+ await pruneExpiredHours(directory, now, reasons);
115
+ }
116
+ catch {
117
+ reasons.add("read_failed");
118
+ }
119
+ if (!result.samples)
120
+ reasons.add("no_samples");
121
+ if (result.invalid_samples)
122
+ reasons.add("invalid_samples");
123
+ if (result.incomplete_samples)
124
+ reasons.add("incomplete_samples");
125
+ if (result.unreadable_samples)
126
+ reasons.add("unreadable_samples");
127
+ result.producer_versions = [...versions].sort(([a], [b]) => a.localeCompare(b))
128
+ .map(([version, samples]) => ({ version, samples }));
129
+ result.reasons = [...reasons];
130
+ return result;
131
+ }
132
+ async function renewLease(directory, now, reasons) {
133
+ let continuousSince = now.toISOString();
134
+ try {
135
+ const previous = MemoryHookSamplingLeaseSchema.safeParse(JSON.parse(await fs.readFile(path.join(directory, "lease.json"), "utf8")));
136
+ if (!previous.success || Date.parse(previous.data.continuous_since) > now.getTime())
137
+ reasons.add("lease_invalid");
138
+ else if (Date.parse(previous.data.expires_at) <= now.getTime())
139
+ reasons.add("lease_expired");
140
+ else
141
+ continuousSince = previous.data.continuous_since;
142
+ }
143
+ catch (error) {
144
+ reasons.add(error.code === "ENOENT" ? "lease_absent" : "lease_invalid");
145
+ }
146
+ const lease = {
147
+ schema_version: "memory-hook-sampling-lease.v1",
148
+ continuous_since: continuousSince,
149
+ expires_at: new Date(now.getTime() + HOOK_SAMPLE_RETENTION_HOURS * HOUR_MS).toISOString(),
150
+ };
151
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
152
+ const temporary = path.join(directory, `${randomUUID()}.lease.tmp`);
153
+ await fs.writeFile(temporary, `${JSON.stringify(lease)}\n`, { flag: "wx", mode: 0o600 });
154
+ await fs.rename(temporary, path.join(directory, "lease.json"));
155
+ return lease;
156
+ }
157
+ async function pruneExpiredHours(directory, now, reasons) {
158
+ const oldest = new Date(now.getTime() - HOOK_SAMPLE_RETENTION_HOURS * HOUR_MS).toISOString().slice(0, 13);
159
+ try {
160
+ const entries = await fs.opendir(directory);
161
+ let pruned = 0;
162
+ for await (const entry of entries) {
163
+ if (!entry.isDirectory() || !HOUR_DIRECTORY.test(entry.name) || entry.name >= oldest)
164
+ continue;
165
+ // Cleanup belongs to the collector and cannot delay a prompt. A bounded
166
+ // number of expired hours is enough to catch up over successive ticks.
167
+ await fs.rm(path.join(directory, entry.name), { recursive: true, force: true });
168
+ if (++pruned >= 8)
169
+ break;
170
+ }
171
+ }
172
+ catch {
173
+ reasons.add("cleanup_failed");
174
+ }
175
+ }
@@ -101,6 +101,8 @@ export function renderMemoryHooks(hooks, dim) {
101
101
  for (const device of ranked) {
102
102
  const line = ` ${device.line ?? `${device.displayName ?? "?"}: (no line)`}`;
103
103
  lines.push((device.timeouts ?? 0) > 0 ? line : dim(line));
104
+ if (device.performanceLine)
105
+ lines.push(` ${device.performanceLine}`);
104
106
  }
105
107
  return lines;
106
108
  }
@@ -144,6 +144,9 @@ async function runOpsStatus(command, io, tower) {
144
144
  // misses.
145
145
  memory_hook_runs_24h: memory?.hooks?.totals?.runs ?? null,
146
146
  memory_hook_timeouts_24h: memory?.hooks?.totals?.timeouts ?? null,
147
+ // BLI-3881: runs the hook declined to search for. A SUBSET of runs, so
148
+ // `runs - skipped_trivial` is how many turns actually asked the door.
149
+ memory_hook_skipped_trivial_24h: memory?.hooks?.totals?.skippedTrivial ?? null,
147
150
  memory_hook_devices_reporting: memory?.hooks?.reporting ?? null,
148
151
  })}`);
149
152
  return unhealthy.length > 0 ? 1 : 0;
@@ -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.81");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.84");
19
19
  return 0;
20
20
  }
21
21
 
@@ -29,14 +29,21 @@ import { writeLine } from "./cli-io.js";
29
29
  const TAG = "[search cli]";
30
30
  const READ_DEADLINE_MS = 30_000;
31
31
  /** Group headings, in the order a person reads them. Mirrors the overlay. */
32
+ // Mirrors `SEARCH_KIND_LABELS` / `SEARCH_KINDS` in the dashboard
33
+ // (BLI-3880 added mail and cal). A kind the door returns that is missing here
34
+ // still prints — upper-cased — rather than vanishing, which is why the map is
35
+ // keyed loosely: an older CLI against a newer door must show the group, not
36
+ // swallow it.
32
37
  const KIND_LABELS = {
33
38
  doc: "DOCUMENTS",
34
39
  msg: "MESSAGES",
35
40
  issue: "ISSUES",
36
41
  note: "MEETING NOTES",
42
+ mail: "MAIL",
43
+ cal: "CALENDAR",
37
44
  memory: "MEMORY",
38
45
  };
39
- const KIND_ORDER = ["doc", "msg", "issue", "note", "memory"];
46
+ const KIND_ORDER = ["doc", "msg", "issue", "note", "mail", "cal", "memory"];
40
47
  export async function runSearch(command, io) {
41
48
  const door = await openAgentDoor("search", command, io);
42
49
  const params = new URLSearchParams({ q: command.query });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.81",
3
+ "version": "0.2.84",
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-verb-help.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.13",
31
- "@bli-cockpit/mcp": "0.1.15",
32
- "@bli-cockpit/telemetry-core": "0.1.32"
30
+ "@bli-cockpit/memory-mcp": "0.1.16",
31
+ "@bli-cockpit/mcp": "0.1.18",
32
+ "@bli-cockpit/telemetry-core": "0.1.34"
33
33
  }
34
34
  }