@bli-cockpit/cli 0.2.97 → 0.2.99

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.
@@ -1,5 +1,6 @@
1
1
  import { defaultIo, errorMessage, writeLine } from "./cli-io.js";
2
2
  import { isLocalHelpRequest, localCommandHelp, rootCommandNames } from "./local-help.js";
3
+ import { beginStage, endStage } from "../crash-guard.js";
3
4
  import { describeError } from "../health-detail.js";
4
5
  import { reportInstallEventsBestEffort } from "./install-receipts.js";
5
6
  import { persistOnboardingRootConfig, resolveOnboardingRootsForCommand, } from "./collection-roots.js";
@@ -33,6 +34,7 @@ import { runAutostart } from "./autostart-command.js";
33
34
  import { runAgentRules } from "./agent-rules-command.js";
34
35
  import { runMemoryLog } from "./memory-log.js";
35
36
  import { runMemoryInstall } from "./memory-install.js";
37
+ import { runMemoryStore, runMemoryVerbList } from "./memory-store.js";
36
38
  import { runClean } from "./clean.js";
37
39
  import { runDocs } from "./docs.js";
38
40
  import { runMsg } from "./msg.js";
@@ -41,6 +43,7 @@ import { runCal } from "./cal.js";
41
43
  import { runMail } from "./mail.js";
42
44
  import { runProject } from "./project.js";
43
45
  import { runSearch } from "./search.js";
46
+ import { runUsage } from "./usage.js";
44
47
  import { parseLocalArgs } from "./local-args.js";
45
48
  // `./local.js` is the published entry point for this command surface: the
46
49
  // public CLI's generated root, commands/root.ts, doctor.ts and the test suite
@@ -76,6 +79,9 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
76
79
  // including the scheduled one nobody types into (BLI-2362). Done here so it
77
80
  // applies to whichever command carried the flag.
78
81
  await rememberDiscoveryLimits(command);
82
+ // BLI-4110: the outermost stage name, so a crash that escapes every catch
83
+ // below still says which subcommand was running.
84
+ beginStage(`command:${command.kind}`);
79
85
  try {
80
86
  switch (command.kind) {
81
87
  case "install":
@@ -148,7 +154,14 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
148
154
  case "memory-log":
149
155
  return await runMemoryLog(command, io);
150
156
  case "memory":
157
+ // BLI-4047: a bare `cockpit memory` says what the noun does. It used
158
+ // to install; `install` is still one word away and every unattended
159
+ // caller asks for it by name in code.
160
+ if (command.action === "help")
161
+ return runMemoryVerbList(io, command.json);
151
162
  return await runMemoryInstall(command, io);
163
+ case "memory-store":
164
+ return await runMemoryStore(command, io);
152
165
  case "clean":
153
166
  return await runClean(command, io);
154
167
  case "docs":
@@ -167,6 +180,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
167
180
  return await runSearch(command, io);
168
181
  case "release":
169
182
  return await runRelease(command, io);
183
+ case "usage":
184
+ return await runUsage(command, io);
170
185
  }
171
186
  }
172
187
  catch (error) {
@@ -184,6 +199,9 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
184
199
  writeLine(io.stderr, errorMessage(error));
185
200
  return 1;
186
201
  }
202
+ finally {
203
+ endStage(`command:${command.kind}`);
204
+ }
187
205
  }
188
206
  async function runDoctorLogin(command, io) {
189
207
  return runLogin({
@@ -11,13 +11,19 @@
11
11
  * rule are `@bli-cockpit/telemetry-core`'s `memory-hook-stats.ts`, which the
12
12
  * writing package spends too. That is the whole point of putting them there.
13
13
  *
14
- * ## Which event, and why only one
14
+ * ## Which event, and why the detail is on one of them
15
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.
16
+ * The PROMPT hook gets the full breakdown. It is the one a person waits on
17
+ * with their sentence typed, the one whose budget QA tick 18 caught it
18
+ * losing, and the one that runs on every turn — so it is the only one whose
19
+ * MISS RATE means anything as a daily number.
20
+ *
21
+ * BLI-4057 added the other two as a run count and a failure count each. Until
22
+ * then the fleet heard from one hook of three, so a SAVE hook that had
23
+ * stopped firing was invisible on every surface: `hooks ok` on the install
24
+ * receipt, 40 prompt recalls in the counts, and nothing anywhere saying that
25
+ * no memory had been written in ten days. Two numbers each is the smallest
26
+ * thing that can say "it ran" and "it ran and could not finish" apart.
21
27
  *
22
28
  * ## Absent is not zero
23
29
  *
@@ -37,8 +43,14 @@ export function readMemoryHookCounts(options) {
37
43
  const parsed = parseMemoryHookStats(raw);
38
44
  if (!parsed.ok)
39
45
  return { counts: null, reason: parsed.reason };
40
- const window = summariseMemoryHookWindow(parsed.file, "prompt", {
41
- now: options.now ?? new Date(),
46
+ const at = options.now ?? new Date();
47
+ const window = summariseMemoryHookWindow(parsed.file, "prompt", { now: at, hours: 24 });
48
+ // BLI-4057. The same rows, the same function, the other two events — said
49
+ // out loud in the module header: the file always held all three and only one
50
+ // of them ever left the machine.
51
+ const stop = summariseMemoryHookWindow(parsed.file, "stop", { now: at, hours: 24 });
52
+ const sessionStart = summariseMemoryHookWindow(parsed.file, "session-start", {
53
+ now: at,
42
54
  hours: 24,
43
55
  });
44
56
  return {
@@ -55,6 +67,15 @@ export function readMemoryHookCounts(options) {
55
67
  : {}),
56
68
  hook_skipped_trivial_24h: window.skippedTrivial,
57
69
  hook_billing_exhausted_24h: window.billingExhausted,
70
+ // A lost deadline and a refusal are ONE number for these two, unlike the
71
+ // prompt hook above: for a recall the difference decides whether to
72
+ // re-fit a budget or pay a bill, and for a save both mean the same
73
+ // thing — nothing was written. The prompt hook keeps them apart because
74
+ // it is the only one whose miss rate is read as a rate.
75
+ hook_stop_runs_24h: stop.runs,
76
+ hook_stop_failed_24h: stop.failed + stop.timeouts,
77
+ hook_session_start_runs_24h: sessionStart.runs,
78
+ hook_session_start_failed_24h: sessionStart.failed + sessionStart.timeouts,
58
79
  // Ticket 3934. Reported as the bin's LOWER bound — the smaller, true
59
80
  // claim — and only when something was measured. A machine that recorded
60
81
  // no histogram carries no key at all, which the board reads as
@@ -0,0 +1,331 @@
1
+ /**
2
+ * `cockpit memory search|save|update|forget` — BLI Memory at a terminal
3
+ * (BLI-4047).
4
+ *
5
+ * Memory was the one Tower surface with no command-line door. The browser had
6
+ * one, the `bli-memory` MCP server had four tools, and a person or a script
7
+ * had nothing — so a machine without an agent host could not read or write the
8
+ * store at all, and `cockpit memory` typed on its own registered an MCP server
9
+ * instead of saying what the noun does. These four verbs are the twins of that
10
+ * server's four tools.
11
+ *
12
+ * **The same doors, never a second store.** `POST /api/memory/{search,save,
13
+ * update,forget}`, with this machine's collector device token, through
14
+ * `agent-door.ts` — the identical route the MCP server calls in
15
+ * `packages/cockpit-memory-mcp/src/server.ts` and the browser calls from
16
+ * `lib/memory/agent-memories/`. Nothing here talks to Postgres, embeds
17
+ * anything, or knows what a container alias is; every ranking, redaction and
18
+ * lifecycle decision stays server-side where its one implementation lives.
19
+ *
20
+ * **A refusal keeps the door's own reason label.** `needs_rls_client`,
21
+ * `unknown_memory`, `already_forgotten`, `already_superseded`,
22
+ * `needs_container_tag`, `no_match`, `custom_id_conflict`, `search_timed_out`
23
+ * — printed verbatim by `failAgentDoor`, never rephrased here. A CLI that
24
+ * invents its own prose for a server's refusal is a CLI whose error messages
25
+ * lie one release later.
26
+ *
27
+ * **The container tag is `@bli-cockpit/memory-mcp`'s, not a second copy.**
28
+ * `resolveContainerTag` is imported from that package rather than
29
+ * reimplemented, so a memory saved by `cockpit memory save` lands in exactly
30
+ * the space the MCP server and the three hooks read from. The scheme is the
31
+ * vendor plugin's byte for byte and two implementations of it would strand
32
+ * memories in a space nothing asks about — see that module's header.
33
+ *
34
+ * **A body never comes from the command line.** `save`, `update` and
35
+ * `forget --content-stdin` read stdin (or `--file`). Same discipline as
36
+ * `cockpit docs create` and `cockpit notes paste`, and for the same reasons:
37
+ * `ps` shows an argument list, shell history keeps it, and a memory with a
38
+ * newline in it cannot be quoted correctly anyway.
39
+ */
40
+ import { resolveContainerTag } from "@bli-cockpit/memory-mcp/dist/container-tag.js";
41
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
42
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
43
+ import { readNoteFile } from "./notes-file.js";
44
+ const TAG = "[memory cli]";
45
+ const SEARCH_DEADLINE_MS = 60_000;
46
+ /** `--extract` is one model call, sometimes two; the door waits for it when asked to. */
47
+ const WRITE_DEADLINE_MS = 120_000;
48
+ /** The door's own ceiling on a memory (`saveMemoryInput`, 200k characters). */
49
+ const BODY_MAX_CHARS = 200_000;
50
+ /** The door's own ceiling on forget-by-text. */
51
+ const FORGET_MAX_CHARS = 10_000;
52
+ export async function runMemoryStore(command, io) {
53
+ const door = await openAgentDoor("memory", command, io);
54
+ switch (command.action) {
55
+ case "search":
56
+ return searchMemory(command, door);
57
+ case "save":
58
+ return saveMemory(command, door);
59
+ case "update":
60
+ return updateMemory(command, door);
61
+ case "forget":
62
+ return forgetMemory(command, door);
63
+ }
64
+ }
65
+ /**
66
+ * The verb list a bare `cockpit memory` prints (BLI-4047).
67
+ *
68
+ * It used to run the installer. Typing a noun to find out what it does is the
69
+ * single most common thing a person does with a new CLI, and this was the one
70
+ * command in the collector that answered that by writing five files in a home
71
+ * directory. Installing is still one word away, and every unattended caller
72
+ * asks for `install` by name in code.
73
+ */
74
+ export function runMemoryVerbList(io, json) {
75
+ if (json) {
76
+ writeLine(io.stdout, JSON.stringify({
77
+ ok: true,
78
+ noun: "memory",
79
+ machine_verbs: ["install", "status"],
80
+ store_verbs: ["search", "save", "update", "forget"],
81
+ log_verbs: ["log"],
82
+ }));
83
+ return 0;
84
+ }
85
+ for (const line of [
86
+ "cockpit memory — BLI Memory: the store, and this machine's registration.",
87
+ "",
88
+ "The store (Tower, over /api/memory/*):",
89
+ " cockpit memory search \"<question>\" [--container <tag>] [--limit 1..20] [--no-recent] [--json]",
90
+ " cockpit memory save [--container <tag>] [--custom-id <id>] [--extract] [--file <path>] [--json]",
91
+ " the memory comes from stdin, or --file; never from the command line",
92
+ " cockpit memory update <id> [--file <path>] [--json] replacement text on stdin",
93
+ " cockpit memory forget <id> [--reason <label>] [--json]",
94
+ " cockpit memory forget --content-stdin --container <tag> [--reason <label>] [--json]",
95
+ "",
96
+ "This machine:",
97
+ " cockpit memory install [--dry-run] [--json] register the MCP server and the three hooks",
98
+ " cockpit memory status [--json] read the stored config back",
99
+ "",
100
+ "The experience log:",
101
+ " cockpit memory log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"",
102
+ "",
103
+ "Omit --container and this repository's own memory space is used — the same",
104
+ "space the bli-memory MCP server and its hooks read and write.",
105
+ ]) {
106
+ writeLine(io.stdout, line);
107
+ }
108
+ return 0;
109
+ }
110
+ /**
111
+ * This repository's memory space, resolved by the MCP server's own resolver.
112
+ * `--container` wins; `--workspace` says which directory to derive it from.
113
+ */
114
+ function containerTagsFor(command) {
115
+ if (command.containerTags.length > 0)
116
+ return command.containerTags;
117
+ return [resolveContainerTag({ cwd: command.workspace ?? process.cwd() }).containerTag];
118
+ }
119
+ async function searchMemory(command, door) {
120
+ const containerTags = containerTagsFor(command);
121
+ const answer = await askAgentDoor(door, {
122
+ path: "/api/memory/search",
123
+ method: "POST",
124
+ label: "memory search",
125
+ timeoutMs: SEARCH_DEADLINE_MS,
126
+ body: {
127
+ query: command.query,
128
+ containerTag: containerTags,
129
+ ...(command.limit === undefined ? {} : { limit: command.limit }),
130
+ includeProfile: command.includeRecent !== false,
131
+ },
132
+ });
133
+ if (!answer.ok)
134
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
135
+ const body = answer.body;
136
+ const results = body.results ?? [];
137
+ const recent = body.recent ?? [];
138
+ // Counts and labels only: a memory body never enters a log line.
139
+ writeLine(door.io.stderr, `${TAG} searched ${JSON.stringify({
140
+ requested_containers: containerTags.length,
141
+ searched_containers: body.containerTags?.length ?? containerTags.length,
142
+ results: results.length,
143
+ recent: recent.length,
144
+ dropped_below_floor: body.dropped_below_floor ?? 0,
145
+ degraded: body.degraded ?? null,
146
+ })}`);
147
+ if (door.json)
148
+ return emitAgentDoor(door, answer.body);
149
+ if (results.length === 0) {
150
+ // The distinction the whole store is built on: silence is not an outage.
151
+ // A failure took the `!answer.ok` branch above and named itself there.
152
+ writeLine(door.io.stdout, "No memories matched. The record is silent on this — it does not mean the store is down.");
153
+ }
154
+ else {
155
+ for (const row of results) {
156
+ writeLine(door.io.stdout, `${similarityLabel(row)}${oneLine(row["memory"])}`);
157
+ writeLine(door.io.stdout, ` ${String(row["id"] ?? "")} ${String(row["containerTag"] ?? "")}`);
158
+ }
159
+ }
160
+ if (recent.length > 0) {
161
+ writeLine(door.io.stdout, "");
162
+ writeLine(door.io.stdout, "Recent context:");
163
+ for (const row of recent)
164
+ writeLine(door.io.stdout, ` - ${oneLine(row["preview"] ?? row["memory"])}`);
165
+ }
166
+ if (body.degraded) {
167
+ writeLine(door.io.stdout, "");
168
+ writeLine(door.io.stdout, `This answer is thinner than usual (${body.degraded}) — one search channel did not run.`);
169
+ }
170
+ return 0;
171
+ }
172
+ async function saveMemory(command, door) {
173
+ const containerTag = containerTagsFor(command)[0];
174
+ const body = await readBody(command, door, BODY_MAX_CHARS, "A memory");
175
+ if (!body.ok)
176
+ return failAgentDoor(door, TAG, body.reason, body.detail);
177
+ if (!body.text.trim()) {
178
+ return failAgentDoor(door, TAG, "content_empty", "There is nothing to save. Pipe the memory in: `echo \"...\" | cockpit memory save`, or pass --file.");
179
+ }
180
+ const answer = await askAgentDoor(door, {
181
+ path: "/api/memory/save",
182
+ method: "POST",
183
+ label: "memory save",
184
+ timeoutMs: WRITE_DEADLINE_MS,
185
+ body: {
186
+ content: body.text,
187
+ containerTag,
188
+ ...(command.customId ? { customId: command.customId } : {}),
189
+ mode: command.extract ? "extract" : "verbatim",
190
+ // Same reason the MCP tool sets it: a caller that ASKED for distillation
191
+ // is waiting for the answer, so take the synchronous branch instead of
192
+ // the 202 the Stop hook wants.
193
+ ...(command.extract ? { wait: true } : {}),
194
+ sourceRef: { kind: "cli" },
195
+ },
196
+ });
197
+ if (!answer.ok)
198
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
199
+ const saved = answer.body;
200
+ writeLine(door.io.stderr, `${TAG} saved ${JSON.stringify({
201
+ outcome: saved["outcome"] ?? null,
202
+ chars: body.text.length,
203
+ embedded: saved["embedded"] ?? null,
204
+ embed_skipped_reason: saved["embedSkippedReason"] ?? null,
205
+ extract: saved["extract"] ?? null,
206
+ masked_spans: saved["maskedSpans"] ?? 0,
207
+ })}`);
208
+ if (door.json)
209
+ return emitAgentDoor(door, answer.body);
210
+ // The container the SERVER used, which is the canonical one when this
211
+ // repository has more than one tag on the shelf; saying the tag we sent
212
+ // would name a space the row is not in.
213
+ const landedIn = String(saved["containerTag"] ?? containerTag);
214
+ writeLine(door.io.stdout, `Memory ${String(saved["outcome"] ?? "created")} in ${landedIn} (id: ${String(saved["id"] ?? "—")}).`);
215
+ if (saved["embedded"] === false && saved["embedSkippedReason"]) {
216
+ writeLine(door.io.stdout, `Stored without a search vector (${String(saved["embedSkippedReason"])}): findable by keyword, not yet by meaning.`);
217
+ }
218
+ if (saved["extract"] === "fell_back_verbatim") {
219
+ writeLine(door.io.stdout, `Saved as written; extraction failed (${String(saved["extractFailureReason"] ?? "unknown")}).`);
220
+ }
221
+ return 0;
222
+ }
223
+ async function updateMemory(command, door) {
224
+ const body = await readBody(command, door, BODY_MAX_CHARS, "A memory");
225
+ if (!body.ok)
226
+ return failAgentDoor(door, TAG, body.reason, body.detail);
227
+ if (!body.text.trim()) {
228
+ return failAgentDoor(door, TAG, "content_empty", "An update needs replacement text on stdin or --file. To remove a memory, use `cockpit memory forget`.");
229
+ }
230
+ const answer = await askAgentDoor(door, {
231
+ path: "/api/memory/update",
232
+ method: "POST",
233
+ label: "memory update",
234
+ timeoutMs: WRITE_DEADLINE_MS,
235
+ body: { id: command.memoryId, content: body.text },
236
+ });
237
+ if (!answer.ok)
238
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
239
+ const updated = answer.body;
240
+ writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({
241
+ chars: body.text.length,
242
+ re_embedded: updated["reEmbedded"] ?? null,
243
+ embed_skipped_reason: updated["embedSkippedReason"] ?? null,
244
+ })}`);
245
+ if (door.json)
246
+ return emitAgentDoor(door, answer.body);
247
+ writeLine(door.io.stdout, `Updated. The new memory is ${String(updated["id"] ?? "")}, and ${String(updated["supersededId"] ?? command.memoryId ?? "")} is kept as its previous version.`);
248
+ return 0;
249
+ }
250
+ async function forgetMemory(command, door) {
251
+ let content;
252
+ if (command.contentStdin) {
253
+ const body = await readBody(command, door, FORGET_MAX_CHARS, "Text to forget");
254
+ if (!body.ok)
255
+ return failAgentDoor(door, TAG, body.reason, body.detail);
256
+ if (!body.text.trim()) {
257
+ return failAgentDoor(door, TAG, "needs_id_or_content", "Nothing arrived on stdin to forget.");
258
+ }
259
+ content = body.text;
260
+ }
261
+ const answer = await askAgentDoor(door, {
262
+ path: "/api/memory/forget",
263
+ method: "POST",
264
+ label: "memory forget",
265
+ timeoutMs: WRITE_DEADLINE_MS,
266
+ body: {
267
+ ...(command.memoryId ? { id: command.memoryId } : {}),
268
+ ...(content ? { content } : {}),
269
+ // Only defaulted for a text match, which is the case that would
270
+ // otherwise sweep every space this caller can read. An id needs none.
271
+ ...(content ? { containerTag: containerTagsFor(command)[0] } : {}),
272
+ ...(command.reason ? { reason: command.reason } : {}),
273
+ },
274
+ });
275
+ if (!answer.ok)
276
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
277
+ const result = answer.body;
278
+ writeLine(door.io.stderr, `${TAG} forgot ${JSON.stringify({ count: result.count ?? 0, by: content ? "content" : "id" })}`);
279
+ if (door.json)
280
+ return emitAgentDoor(door, answer.body);
281
+ // The door answers with WHAT it removed, deliberately: a bare "done" is the
282
+ // only shape in which "forget X" quietly removing Y goes unnoticed.
283
+ writeLine(door.io.stdout, result.message ?? `Forgotten (${result.count ?? 0}).`);
284
+ for (const row of result.forgotten ?? []) {
285
+ const entry = row;
286
+ writeLine(door.io.stdout, ` ${String(entry["id"] ?? "")} ${oneLine(entry["textPreview"])}`);
287
+ }
288
+ return 0;
289
+ }
290
+ /** A body from `--file`, else stdin. An interactive terminal yields nothing rather than hanging. */
291
+ async function readBody(command, door, maxChars, what) {
292
+ if (command.filePath) {
293
+ const read = await readNoteFile(command.filePath);
294
+ if (!read.ok) {
295
+ return { ok: false, reason: read.refusal, detail: `Could not read ${command.filePath}: ${read.detail}` };
296
+ }
297
+ return { ok: true, text: read.bytes.toString("utf8") };
298
+ }
299
+ if (isInteractiveStdin(door.io))
300
+ return { ok: true, text: "" };
301
+ try {
302
+ return {
303
+ ok: true,
304
+ text: await readPipedText(door.io.stdin, {
305
+ maxChars,
306
+ overflowMessage: `${what} is limited to ${maxChars} characters.`,
307
+ }),
308
+ };
309
+ }
310
+ catch (error) {
311
+ return {
312
+ ok: false,
313
+ reason: "content_too_long",
314
+ detail: error instanceof Error ? error.message : String(error),
315
+ };
316
+ }
317
+ }
318
+ /** `[87%] ` when the row was found by meaning, empty when it was a keyword hit. */
319
+ function similarityLabel(row) {
320
+ const channels = row["channels"];
321
+ if (Array.isArray(channels) && !channels.includes("semantic"))
322
+ return "";
323
+ const similarity = Number(row["similarity"]);
324
+ return Number.isFinite(similarity) && similarity >= 0 && similarity <= 1
325
+ ? `[${Math.round(similarity * 100)}%] `
326
+ : "";
327
+ }
328
+ /** One terminal line: newlines folded, so a long memory cannot break the listing. */
329
+ function oneLine(value) {
330
+ return String(value ?? "").replace(/\s+/g, " ").trim();
331
+ }
@@ -180,6 +180,43 @@ export async function shareNote(command, door) {
180
180
  sayUpload(door, body);
181
181
  return 0;
182
182
  }
183
+ /**
184
+ * `cockpit notes place <note-id> [--apply]` — BLI-4058.
185
+ *
186
+ * Asks Tower where a note belongs and prints the reason. Without `--apply` it
187
+ * moves nothing: knowing where a note would go and filing it there are two
188
+ * different acts, and the read-only one is the default because it is the one
189
+ * somebody types while they are still deciding.
190
+ */
191
+ export async function placeNote(command, door) {
192
+ const applying = command.apply === true;
193
+ const answer = await ask(door, {
194
+ path: "/api/notes/place",
195
+ method: "POST",
196
+ label: applying ? "notes place --apply" : "notes place",
197
+ timeoutMs: READ_DEADLINE_MS,
198
+ body: { note_id: command.noteId, apply: applying },
199
+ });
200
+ if (!answer.ok)
201
+ return fail(door, answer.reason, answer.detail);
202
+ const body = answer.body;
203
+ writeLine(door.io.stderr, `${TAG} place answered ${JSON.stringify({
204
+ note_id: command.noteId ?? null,
205
+ ok: body.ok === true,
206
+ decided_by: body.decidedBy ?? null,
207
+ applied: body.applied === true,
208
+ reason: body.reason ?? null,
209
+ })}`);
210
+ if (door.json)
211
+ return emit(door, body, body.ok === true ? 0 : 1);
212
+ writeLine(door.io.stdout, body.headline ?? "Tower answered without a sentence.");
213
+ for (const line of body.lines ?? [])
214
+ writeLine(door.io.stdout, line);
215
+ if (body.ok === true && !applying) {
216
+ writeLine(door.io.stdout, "Nothing moved. Add --apply to file it there.");
217
+ }
218
+ return body.ok === true ? 0 : 1;
219
+ }
183
220
  export async function moveNote(command, door) {
184
221
  const answer = await ask(door, {
185
222
  path: "/api/notes/move",
@@ -27,7 +27,7 @@
27
27
  * table of contents). Every responsibility lives in a `notes-*.ts` sibling:
28
28
  *
29
29
  * - `notes-reads.ts` — the library, shelves list, one note, your own shelf.
30
- * - `notes-writes.ts` — upload, paste, share/unshare, move.
30
+ * - `notes-writes.ts` — upload, paste, share/unshare, move, place.
31
31
  * - `notes-door.ts` — the one HTTP request, and the shared answer / refusal /
32
32
  * emit / say-* rendering both halves above call.
33
33
  *
@@ -35,9 +35,9 @@
35
35
  */
36
36
  import { loadPairedSession } from "../tower-client.js";
37
37
  import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
38
- import { uploadNotes, pasteNote, shareNote, moveNote } from "./notes-writes.js";
38
+ import { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
39
39
  export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
40
- export { uploadNotes, pasteNote, shareNote, moveNote } from "./notes-writes.js";
40
+ export { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
41
41
  export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
42
42
  export async function runNotes(command, io) {
43
43
  const session = await loadPairedSession("notes", command.homeDir);
@@ -65,5 +65,7 @@ export async function runNotes(command, io) {
65
65
  return shareNote(command, door);
66
66
  case "move":
67
67
  return moveNote(command, door);
68
+ case "place":
69
+ return placeNote(command, door);
68
70
  }
69
71
  }
@@ -101,7 +101,11 @@ options = {}) {
101
101
  // `STALE collector-fleet 11h` was already misread once (BLI-3699) as "the
102
102
  // fleet", not "one machine in it", and the name is otherwise buried in
103
103
  // `detail` below.
104
- const staleName = row.verdict === "stale" && row.staleDeviceName ? ` ${dim(`(${row.staleDeviceName})`)}` : "";
104
+ // BLI-4057 generalised it from `stale` alone: `memory-hooks` names the
105
+ // machine whose hooks stopped and reads `failing`, because nothing about
106
+ // it is late. A row that took the trouble to name a device is naming it
107
+ // for the reader, whatever word it printed beside it.
108
+ const staleName = row.staleDeviceName ? ` ${dim(`(${row.staleDeviceName})`)}` : "";
105
109
  lines.push(`${verdict} ${id} ${age}${staleName} ${dim(intervalWord(row))}`);
106
110
  if (row.verdict !== "healthy") {
107
111
  // The absence of an age is explained BEFORE the detail, because it is
@@ -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.97");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.99");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,23 @@
1
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
2
+ import { writeLine } from "./cli-io.js";
3
+ export async function runUsage(command, io) {
4
+ const door = await openAgentDoor("usage", command, io);
5
+ const query = new URLSearchParams({ since: command.since });
6
+ if (command.until)
7
+ query.set("until", command.until);
8
+ if (command.includeAutomated)
9
+ query.set("include_automated", "1");
10
+ const answer = await askAgentDoor(door, { path: `/api/usage/people?${query}`, method: "GET", label: "usage people", timeoutMs: 30_000 });
11
+ if (!answer.ok)
12
+ return failAgentDoor(door, "[usage]", answer.reason, answer.detail);
13
+ const body = answer.body;
14
+ if (door.json)
15
+ return emitAgentDoor(door, body);
16
+ writeLine(io.stdout, "PERSON TOKENS OUTPUT LIST EQUIVALENT COVERAGE");
17
+ for (const row of body.people ?? [])
18
+ writeLine(io.stdout, `${(row.display_name ?? row.email ?? "Unknown").slice(0, 20).padEnd(20)} ${String(row.tokens_total).padStart(11)} ${String(row.output_tokens).padStart(11)} ${`$${row.api_list_price_equivalent_usd.toFixed(2)}`.padStart(15)} ${row.sessions_extracted}/${row.sessions_observed}`);
19
+ writeLine(io.stdout, "");
20
+ writeLine(io.stdout, body.api_list_price_equivalent_label ?? "API list-price equivalent (not actual spend)");
21
+ writeLine(io.stdout, `${body.coverage?.sessions_extracted ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions extracted`);
22
+ return 0;
23
+ }