@bli-cockpit/cli 0.2.82 → 0.2.85

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.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The `memory-daemon` row (BLI-3884): is the resident local answerer running?
3
+ *
4
+ * **This row is NEVER red, and it has no fix.** A machine with no daemon is a
5
+ * machine whose recalls take the path they took before the daemon existed —
6
+ * slower by a process start, a TLS handshake and a token check, and completely
7
+ * correct. Colouring that red would train people to ignore doctor, the way an
8
+ * amber "you could be faster" line trains them to ignore everything under it.
9
+ *
10
+ * So there are exactly two outcomes:
11
+ *
12
+ * ok something answered a `ping` on this user's endpoint.
13
+ * skipped nothing did, and the reason is named — `daemon_absent` (the
14
+ * ordinary state on a machine that has not prompted in two hours),
15
+ * `socket_path_too_long`, or whatever the socket said.
16
+ *
17
+ * Starting one is not doctor's job either: the PROMPT HOOK is the daemon's
18
+ * supervisor and spawns one at most once an hour (`memory-mcp`'s
19
+ * `daemon/spawn.ts`). A doctor that started a daemon would be a second
20
+ * supervisor with different rules.
21
+ */
22
+ import { ok, skipped } from "./doctor-report.js";
23
+ import { memoryDaemonLine, probeMemoryDaemon } from "./memory-daemon-probe.js";
24
+ export async function checkMemoryDaemonState(context, deps = {}) {
25
+ const probe = deps.probe ?? probeMemoryDaemon;
26
+ const reading = await probe({
27
+ ...(context.command.homeDir ? { homeDir: context.command.homeDir } : {}),
28
+ });
29
+ return memoryDaemonRow(reading);
30
+ }
31
+ export function memoryDaemonRow(reading) {
32
+ const sentence = memoryDaemonLine(reading);
33
+ return reading.answering
34
+ ? ok("memory-daemon", "answering", sentence)
35
+ : skipped("memory-daemon", reading.reason, sentence);
36
+ }
@@ -1,6 +1,7 @@
1
1
  import { checkSingleInstallState, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
2
2
  import { backfillCompletionStepState, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, } from "./doctor-pipeline.js";
3
3
  import { checkMcpAnswersState } from "./doctor-mcp.js";
4
+ import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
4
5
  import { checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
5
6
  import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, } from "./doctor-report.js";
6
7
  import { refreshSetupReceipt } from "./setup-receipt.js";
@@ -104,6 +105,14 @@ function doctorInvariants() {
104
105
  id: "mcp-answers",
105
106
  check: (context) => context.deps.checkMcpAnswers(context),
106
107
  },
108
+ // BLI-3884. Beside the two rows above because it is the same family — what
109
+ // this machine's agents can reach — and, like `mcp-answers`, it has NO fix:
110
+ // the prompt hook is the daemon's supervisor and starts one itself. It is
111
+ // never red; a machine without one recalls exactly as it did before.
112
+ {
113
+ id: "memory-daemon",
114
+ check: (context) => context.deps.checkMemoryDaemon(context),
115
+ },
107
116
  {
108
117
  id: "backfill-complete",
109
118
  check: (context) => context.deps.checkBackfill(context),
@@ -146,6 +155,7 @@ function defaultDoctorDeps(hooks) {
146
155
  checkMemory: checkMemoryState,
147
156
  fixMemory: fixMemoryState,
148
157
  checkMcpAnswers: (context) => checkMcpAnswersState(context),
158
+ checkMemoryDaemon: (context) => checkMemoryDaemonState(context),
149
159
  checkBackfill: checkBackfillState,
150
160
  fixBackfill: fixBackfillState,
151
161
  checkGc: checkGcState,
@@ -317,6 +317,11 @@ export async function sendCollectorHeartbeatBestEffort(options) {
317
317
  memory_hook_runs_24h: memory.receipt?.hook_runs_24h ?? null,
318
318
  memory_hook_timeouts_24h: memory.receipt?.hook_timeouts_24h ?? null,
319
319
  memory_hook_stats: memory.receipt?.hook_stats_reason ?? null,
320
+ // BLI-3884. Which route answered those recalls. `null` on a machine
321
+ // whose memory-mcp does not name one yet — never zero, which would
322
+ // read as "the daemon answered none".
323
+ memory_hook_via_daemon_24h: memory.receipt?.hook_via_daemon_24h ?? null,
324
+ memory_hook_via_direct_24h: memory.receipt?.hook_via_direct_24h ?? null,
320
325
  }));
321
326
  return true;
322
327
  }
@@ -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 {};
@@ -0,0 +1,118 @@
1
+ /**
2
+ * IS THE RESIDENT MEMORY DAEMON ANSWERING? (BLI-3884)
3
+ *
4
+ * `bli-memory-mcp daemon` holds a warm connection, credential and scope so a
5
+ * prompt hook stops paying for a process, a TLS handshake and the device-token
6
+ * gate on every turn. Two collector surfaces need to know whether it is there:
7
+ * `cockpit doctor`'s `memory-daemon` row and `cockpit memory status`.
8
+ *
9
+ * The probe is written HERE, in twenty lines of `node:net`, rather than by
10
+ * importing the memory-mcp client: that package is a devDependency of this one
11
+ * and is not copied into the packed CLI, so an import would be green in the
12
+ * repo and missing on every fleet machine (the `runtimeFiles` trap this repo
13
+ * has paid for more than once). What IS shared is the CONTRACT — the address,
14
+ * the protocol version and the line limit come from
15
+ * `@bli-cockpit/telemetry-core`'s `memory-daemon-endpoint.ts`, which both
16
+ * packages depend on.
17
+ *
18
+ * **It never fixes anything and never fails anything.** A machine with no
19
+ * daemon is a machine whose hooks work exactly as they did before the daemon
20
+ * existed; this reports presence, absence and the reason, and that is all.
21
+ */
22
+ import net from "node:net";
23
+ import os from "node:os";
24
+ import { MEMORY_DAEMON_MAX_LINE_BYTES, MEMORY_DAEMON_PROTOCOL_VERSION, memoryDaemonEndpoint, } from "@bli-cockpit/telemetry-core";
25
+ /** A probe waits this long in total. A live local socket answers in ~1 ms. */
26
+ export const DAEMON_PROBE_TIMEOUT_MS = 400;
27
+ export function probeMemoryDaemon(options = {}) {
28
+ const now = options.now ?? Date.now;
29
+ const started = now();
30
+ const endpoint = memoryDaemonEndpoint({
31
+ homeDir: options.homeDir ?? os.homedir(),
32
+ ...(options.platform ? { platform: options.platform } : {}),
33
+ username: options.username ?? safeUsername(),
34
+ });
35
+ const base = { kind: endpoint.kind, pid: null, startedAt: null };
36
+ if (endpoint.kind === "unix" && endpoint.addressTooLong) {
37
+ return Promise.resolve({
38
+ ...base,
39
+ answering: false,
40
+ reason: "socket_path_too_long",
41
+ elapsedMs: 0,
42
+ });
43
+ }
44
+ return new Promise((resolve) => {
45
+ let settled = false;
46
+ let buffered = "";
47
+ const finish = (answering, reason, extra = {}) => {
48
+ if (settled)
49
+ return;
50
+ settled = true;
51
+ clearTimeout(timer);
52
+ socket.destroy();
53
+ resolve({ ...base, ...extra, answering, reason, elapsedMs: now() - started });
54
+ };
55
+ const timer = setTimeout(() => finish(false, "probe_timeout"), options.timeoutMs ?? DAEMON_PROBE_TIMEOUT_MS);
56
+ timer.unref?.();
57
+ const socket = (options.connectImpl ?? ((address) => net.createConnection({ path: address })))(endpoint.address);
58
+ socket.on("error", (error) => {
59
+ const code = error.code;
60
+ finish(false, code === "ENOENT" || code === "ECONNREFUSED"
61
+ ? "daemon_absent"
62
+ : `daemon_socket_${(code ?? "error").toLowerCase()}`);
63
+ });
64
+ socket.on("connect", () => {
65
+ socket.write(`${JSON.stringify({ v: MEMORY_DAEMON_PROTOCOL_VERSION, event: "ping" })}\n`);
66
+ });
67
+ socket.on("data", (chunk) => {
68
+ buffered += chunk.toString("utf8");
69
+ if (buffered.length > MEMORY_DAEMON_MAX_LINE_BYTES) {
70
+ finish(false, "daemon_answer_too_large");
71
+ return;
72
+ }
73
+ const newline = buffered.indexOf("\n");
74
+ if (newline === -1)
75
+ return;
76
+ let parsed;
77
+ try {
78
+ parsed = JSON.parse(buffered.slice(0, newline));
79
+ }
80
+ catch {
81
+ finish(false, "daemon_answer_unparseable");
82
+ return;
83
+ }
84
+ const record = (parsed ?? {});
85
+ if (record["ok"] !== true) {
86
+ finish(false, typeof record["reason"] === "string" ? record["reason"] : "daemon_refused");
87
+ return;
88
+ }
89
+ finish(true, "answering", {
90
+ pid: typeof record["pid"] === "number" ? record["pid"] : null,
91
+ startedAt: typeof record["startedAt"] === "string" ? record["startedAt"] : null,
92
+ });
93
+ });
94
+ socket.on("close", () => finish(false, "daemon_closed_without_answer"));
95
+ });
96
+ }
97
+ /** One sentence a person reads, on either branch. Never a path. */
98
+ export function memoryDaemonLine(reading) {
99
+ if (reading.answering) {
100
+ return (`the memory daemon is answering on this machine's ${reading.kind === "pipe" ? "named pipe" : "local socket"}` +
101
+ `${reading.pid === null ? "" : ` (pid ${reading.pid})`}` +
102
+ ` — prompt recalls skip a process start, a fresh connection and the token check`);
103
+ }
104
+ if (reading.reason === "socket_path_too_long") {
105
+ return ("the memory daemon cannot run here: this machine's home directory is deep enough that its socket path" +
106
+ " exceeds the operating system's limit. Recall still works — every prompt takes the direct path.");
107
+ }
108
+ return (`no memory daemon is answering (${reading.reason}); every prompt recall takes the direct path,` +
109
+ " which is how it worked before the daemon existed. The next prompt hook starts one.");
110
+ }
111
+ function safeUsername() {
112
+ try {
113
+ return os.userInfo().username;
114
+ }
115
+ catch {
116
+ return undefined;
117
+ }
118
+ }
@@ -47,6 +47,13 @@ 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
+ ...(window.viaMeasured
51
+ ? {
52
+ hook_via_daemon_24h: window.viaDaemon,
53
+ hook_via_direct_24h: window.viaDirect,
54
+ }
55
+ : {}),
56
+ hook_skipped_trivial_24h: window.skippedTrivial,
50
57
  },
51
58
  reason: "ok",
52
59
  };
@@ -4,6 +4,7 @@
4
4
  * Sibling of `memory-install.ts`, named in its header.
5
5
  */
6
6
  import { memoryInstallGaps } from "@bli-cockpit/telemetry-core";
7
+ import { memoryDaemonLine } from "./memory-daemon-probe.js";
7
8
  import { memoryReceiptLine } from "./memory-install-receipt.js";
8
9
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
9
10
  export async function resolveDashboardUrl(command, deps) {
@@ -94,6 +95,10 @@ export function memoryOutcomeLines(outcome) {
94
95
  ? "BLI Memory was not registered and nothing was written: the bli-memory-mcp server is not on this machine yet."
95
96
  : `BLI Memory is not fully registered: ${outcome.reason}.`;
96
97
  const lines = [headline, ` ${memoryReceiptLine(outcome.receipt)}`];
98
+ // BLI-3884. Only `status` asks; an absent reading is not printed as "no
99
+ // daemon", because nothing looked.
100
+ if (outcome.daemon)
101
+ lines.push(` ${memoryDaemonLine(outcome.daemon)}`);
97
102
  // The trust step is the one thing a person has to do by hand, so it is said
98
103
  // here in words rather than left as a status word they have to look up.
99
104
  if (outcome.receipt.codex.hooks === "needs_trust") {
@@ -48,6 +48,7 @@ import { defaultMemoryFileIo, } from "./memory-install-files.js";
48
48
  import { installClaudeMemoryIntegration, inspectClaudeMemoryIntegration, } from "./memory-install-claude.js";
49
49
  import { installCodexMemoryIntegration, inspectCodexMemoryIntegration, } from "./memory-install-codex.js";
50
50
  import { resolveMemoryConfig } from "./memory-install-config.js";
51
+ import { probeMemoryDaemon } from "./memory-daemon-probe.js";
51
52
  import { expectedCodexTrustRows, readCodexHookTrustFromDisk, } from "./memory-install-codex-hooks.js";
52
53
  import { buildMemoryInstallReceipt, writeMemoryReceiptFile, } from "./memory-install-receipt.js";
53
54
  import { getCollectorRuntimePaths } from "../local-state.js";
@@ -208,6 +209,10 @@ export async function inspectMemoryIntegration(command, io, deps = {}) {
208
209
  })));
209
210
  const outcome = {
210
211
  action: "status",
212
+ // The one live question on this surface: is the daemon answering? It is
213
+ // bounded, it opens nothing but a local socket, and a machine without one
214
+ // is reported as exactly that rather than as a problem.
215
+ daemon: await probeMemoryDaemon({ ...(homeDir ? { homeDir } : {}) }),
211
216
  ...aggregate(targets),
212
217
  config_source: resolved.source,
213
218
  bin_found: resolved.config !== null,
@@ -103,6 +103,10 @@ export function renderMemoryHooks(hooks, dim) {
103
103
  lines.push((device.timeouts ?? 0) > 0 ? line : dim(line));
104
104
  if (device.performanceLine)
105
105
  lines.push(` ${device.performanceLine}`);
106
+ // BLI-3884. Written on the server (`lib/ops/memory-hook-misses.ts`) and
107
+ // printed verbatim, like every other sentence in this section.
108
+ if (device.viaLine)
109
+ lines.push(dim(` ${device.viaLine}`));
106
110
  }
107
111
  return lines;
108
112
  }
@@ -144,7 +144,14 @@ 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,
151
+ // BLI-3884: which route answered them, fleet-wide. Null while no machine
152
+ // names one — a rollout fact, not a count of zero.
153
+ memory_hook_via_daemon_24h: memory?.hooks?.via?.daemon ?? null,
154
+ memory_hook_via_direct_24h: memory?.hooks?.via?.direct ?? null,
148
155
  })}`);
149
156
  return unhealthy.length > 0 ? 1 : 0;
150
157
  }
@@ -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.82");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.85");
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.82",
3
+ "version": "0.2.85",
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.14",
31
- "@bli-cockpit/mcp": "0.1.16",
32
- "@bli-cockpit/telemetry-core": "0.1.33"
30
+ "@bli-cockpit/memory-mcp": "0.1.17",
31
+ "@bli-cockpit/mcp": "0.1.19",
32
+ "@bli-cockpit/telemetry-core": "0.1.35"
33
33
  }
34
34
  }