@bli-cockpit/cli 0.2.128 → 0.2.132

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,10 +1,13 @@
1
1
  /**
2
- * `cockpit memory search|save|update|forget` — the argument table (BLI-4047).
2
+ * `cockpit memory search|save|update|forget|profile|consolidation|requeue` —
3
+ * the argument table (BLI-4047; `profile` added by BLI-4533, `consolidation`
4
+ * by BLI-4534, `requeue` by BLI-4539).
3
5
  *
4
6
  * BLI Memory was the one Tower surface with no command-line door: the browser
5
7
  * had one, the `bli-memory` MCP server had one, and a person or a script at a
6
- * terminal had nothing. These four verbs are the twins of that server's four
7
- * tools, over the SAME `/api/memory/*` doors.
8
+ * terminal had nothing. The first four verbs are the twins of that server's
9
+ * four tools, over the SAME `/api/memory/*` doors; `profile` is the twin of
10
+ * `memory_profile` on `bli-tower`, over `GET /api/memory/profile`.
8
11
  *
9
12
  * Two rules this table exists to enforce, both of them the same discipline
10
13
  * `cockpit docs create` and `cockpit notes paste` already follow:
@@ -34,6 +37,12 @@ export const MEMORY_STORE_ACTIONS = new Set([
34
37
  "save",
35
38
  "update",
36
39
  "forget",
40
+ "profile",
41
+ // BLI-4534: a READ of the nightly consolidation receipts, not a fifth way to
42
+ // write a memory. It runs no pass and changes nothing — the pass is a
43
+ // Railway cron; this is how a person or an agent asks what it did.
44
+ "consolidation",
45
+ "requeue",
37
46
  ]);
38
47
  const ALLOWED_FLAGS = [
39
48
  "--home",
@@ -49,6 +58,11 @@ const ALLOWED_FLAGS = [
49
58
  "--file",
50
59
  "--content-stdin",
51
60
  "--reason",
61
+ "--since",
62
+ "--until",
63
+ "--source",
64
+ "--dry-run",
65
+ "--apply",
52
66
  "--json",
53
67
  ];
54
68
  const VALUE_FLAGS = [
@@ -62,25 +76,39 @@ const VALUE_FLAGS = [
62
76
  "--alias",
63
77
  "--file",
64
78
  "--reason",
79
+ "--since",
80
+ "--until",
81
+ "--source",
65
82
  ];
66
83
  export function parseMemoryStoreArgs(action, args) {
67
84
  const values = parseNamedArgs(args, { allowedFlags: ALLOWED_FLAGS, valueFlags: VALUE_FLAGS });
68
85
  const rest = values.positionals.slice(1);
69
86
  const containers = values.flagValues.get("--container") ?? [];
70
- if (action !== "search" && containers.length > 1) {
87
+ if (action !== "search" && action !== "consolidation" && containers.length > 1) {
71
88
  throw new Error(`memory ${action} takes at most one --container.`);
72
89
  }
73
90
  if (action !== "search" && (values.flags.has("--limit") || values.booleans.has("--no-recent"))) {
74
91
  throw new Error("--limit and --no-recent only apply to memory search.");
75
92
  }
93
+ if (action === "consolidation" && rest.length > 0) {
94
+ throw new Error("memory consolidation takes no positionals: `cockpit memory consolidation [--container <tag>] [--json]`.");
95
+ }
76
96
  if (action !== "save" && values.flags.has("--alias")) {
77
97
  throw new Error("--alias only applies to memory save.");
78
98
  }
79
99
  if (action !== "save" && (values.flags.has("--custom-id") || values.booleans.has("--extract"))) {
80
100
  throw new Error("--custom-id and --extract only apply to memory save.");
81
101
  }
82
- if (action !== "forget" && (values.flags.has("--reason") || values.booleans.has("--content-stdin"))) {
83
- throw new Error("--reason and --content-stdin only apply to memory forget.");
102
+ if (action !== "forget" && values.booleans.has("--content-stdin")) {
103
+ throw new Error("--content-stdin only applies to memory forget.");
104
+ }
105
+ if (action !== "forget" && action !== "requeue" && values.flags.has("--reason")) {
106
+ throw new Error("--reason only applies to memory forget and memory requeue.");
107
+ }
108
+ if (action !== "requeue"
109
+ && (values.flags.has("--since") || values.flags.has("--until") || values.flags.has("--source")
110
+ || values.booleans.has("--dry-run") || values.booleans.has("--apply"))) {
111
+ throw new Error("--since, --until, --source, --dry-run and --apply only apply to memory requeue.");
84
112
  }
85
113
  if (values.flags.has("--file") && action !== "save" && action !== "update") {
86
114
  throw new Error("--file only applies to memory save and memory update.");
@@ -96,6 +124,46 @@ export function parseMemoryStoreArgs(action, args) {
96
124
  .filter((tag) => tag.length > 0),
97
125
  json: values.booleans.has("--json"),
98
126
  };
127
+ if (action === "requeue") {
128
+ // A window is REQUIRED. There is no requeue-everything: applying re-runs
129
+ // one model call per receipt, so the ceiling on a wrong filter is a bill.
130
+ if (rest.length > 0) {
131
+ throw new Error("memory requeue takes no words; name the window with --since and --until.");
132
+ }
133
+ const since = readInstant(values.flags.get("--since"), "--since");
134
+ if (!since) {
135
+ throw new Error("memory requeue needs --since <iso>, e.g. `cockpit memory requeue --since 2026-09-21T04:10:00Z --dry-run`.");
136
+ }
137
+ const until = readInstant(values.flags.get("--until"), "--until");
138
+ if (until && until < since)
139
+ throw new Error("--until is before --since; nothing could match.");
140
+ const source = optionalNonEmpty(values.flags.get("--source"));
141
+ if (source && source !== "hook" && source !== "harvest") {
142
+ throw new Error("--source is hook or harvest — the two values a receipt can carry.");
143
+ }
144
+ if (values.booleans.has("--dry-run") && values.booleans.has("--apply")) {
145
+ throw new Error("--dry-run and --apply are opposites; name one.");
146
+ }
147
+ return {
148
+ ...command,
149
+ action,
150
+ since,
151
+ until,
152
+ ...(source ? { source: source } : {}),
153
+ // The safe question unless somebody typed otherwise.
154
+ dryRun: !values.booleans.has("--apply"),
155
+ reason: optionalNonEmpty(values.flags.get("--reason")),
156
+ };
157
+ }
158
+ if (action === "profile") {
159
+ // A pure read of ONE container's compiled page. No positional: there is
160
+ // nothing to ask it, and a stray word would look like a query this verb
161
+ // silently ignored.
162
+ if (rest.length > 0) {
163
+ throw new Error("memory profile takes no words; it reads this repository's compiled page. Name another space with --container.");
164
+ }
165
+ return { ...command, action };
166
+ }
99
167
  if (action === "search") {
100
168
  // Every remaining positional joined, so `cockpit memory search what did we
101
169
  // decide about pagination` needs no quotes — the same shape `cockpit
@@ -105,6 +173,9 @@ export function parseMemoryStoreArgs(action, args) {
105
173
  throw new Error("memory search needs a question, e.g. `cockpit memory search \"pagination rule\"`.");
106
174
  return { ...command, action, query, limit: readLimit(values.flags.get("--limit")), includeRecent: !values.booleans.has("--no-recent") };
107
175
  }
176
+ if (action === "consolidation") {
177
+ return { ...command, action };
178
+ }
108
179
  if (action === "save") {
109
180
  if (rest.length > 0) {
110
181
  throw new Error("memory save reads the memory from stdin or --file, never from the command line: `echo \"...\" | cockpit memory save`.");
@@ -145,6 +216,16 @@ export function parseMemoryStoreArgs(action, args) {
145
216
  reason: optionalNonEmpty(values.flags.get("--reason")),
146
217
  };
147
218
  }
219
+ /** An ISO instant, normalised, or a refusal naming the flag that was wrong. */
220
+ function readInstant(value, flag) {
221
+ if (value === undefined)
222
+ return undefined;
223
+ const parsed = Date.parse(value);
224
+ if (!Number.isFinite(parsed)) {
225
+ throw new Error(`${flag} must be an ISO instant like 2026-09-21T04:10:00Z.`);
226
+ }
227
+ return new Date(parsed).toISOString();
228
+ }
148
229
  /** 1..20, the same window the MCP tool and the door both accept. */
149
230
  function readLimit(value) {
150
231
  if (value === undefined)
@@ -0,0 +1,12 @@
1
+ // The six human "set my machine up" doors. They are one thing wearing six
2
+ // hats, so they all run the convergence command, but they keep accepting the
3
+ // flags they always accepted, because DMs, runbooks and AGENTS.md rules across
4
+ // the fleet still spell them out.
5
+ export const DOCTOR_SETUP_ALIASES = [
6
+ "install",
7
+ "onboard",
8
+ "login",
9
+ "pair",
10
+ "update",
11
+ "upgrade",
12
+ ];
@@ -10,9 +10,9 @@
10
10
  *
11
11
  * `memory` install / status / help — acts on THIS MACHINE, writing an
12
12
  * MCP registration and three hooks into a home directory.
13
- * `memory-store` search / save / update / forget — acts on TOWER, over
14
- * `/api/memory/*`, the same doors the `bli-memory` MCP
15
- * server and the browser call.
13
+ * `memory-store` search / save / update / forget / profile / requeue —
14
+ * acts on TOWER, over `/api/memory/*`, the same doors the
15
+ * `bli-memory` MCP server and the browser call.
16
16
  * `memory-log` the agent-experience opinion, part local file part Tower.
17
17
  *
18
18
  * They are separate kinds rather than one widening `action` union because they
@@ -1,12 +1 @@
1
- // The six human "set my machine up" doors. They are one thing wearing six
2
- // hats, so they all run the convergence command — but they keep accepting the
3
- // flags they always accepted, because DMs, runbooks and AGENTS.md rules across
4
- // the fleet still spell them out.
5
- export const DOCTOR_SETUP_ALIASES = [
6
- "install",
7
- "onboard",
8
- "login",
9
- "pair",
10
- "update",
11
- "upgrade",
12
- ];
1
+ export { DOCTOR_SETUP_ALIASES } from "./local-command-shapes-doctor.js";
@@ -580,7 +580,7 @@ export function localSubcommandHelp(command) {
580
580
  [
581
581
  "memory",
582
582
  [
583
- "Usage: cockpit memory [search|save|update|forget|install|status|log] [flags]",
583
+ "Usage: cockpit memory [search|save|update|forget|profile|consolidation|requeue|install|status|log] [flags]",
584
584
  "",
585
585
  "The STORE, over the same /api/memory/* doors the bli-memory MCP server calls:",
586
586
  " search \"<question>\" [--container <tag>] [--limit 1..20] [--no-recent]",
@@ -593,6 +593,16 @@ export function localSubcommandHelp(command) {
593
593
  " update <id> [--file <path>] replacement text on stdin; the old row is kept as history.",
594
594
  " forget <id> [--reason <label>]",
595
595
  " forget --content-stdin --container <tag> [--reason <label>] forget by exact text.",
596
+ " profile [--container <tag>] the compiled one-page briefing; a read, never a compile.",
597
+ " consolidation [--container <tag>] what the nightly tidy-up pass did.",
598
+ " Clusters looked at, edits by verb, refusals by reason, model and cost.",
599
+ " Read-only: the pass is a nightly cron, it never deletes a memory, and",
600
+ " report mode changes nothing on the shelf at all.",
601
+ " requeue --since <iso> [--until <iso>] [--source hook|harvest] [--reason <label>] [--apply]",
602
+ " puts finished extractions back in the queue after the store refused their",
603
+ " writes. Super admins only. A DRY RUN unless you pass --apply, because an",
604
+ " apply re-runs one model call per receipt. Nothing is ever deleted: each",
605
+ " row keeps its previous outcome under result.requeued_from.",
596
606
  "Refusals keep the door's own label — needs_rls_client, unknown_memory,",
597
607
  "already_superseded, no_match — and --json prints the door's answer unchanged.",
598
608
  "",
@@ -94,7 +94,7 @@ export function localCommandHelp(command) {
94
94
  " cockpit serve [--port <port>] [--workspace <path>]",
95
95
  " cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
96
96
  " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
97
- " cockpit memory [search \"<question>\"|save|update <id>|forget <id>|install|status|log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"] [--container <tag>] [--limit <n>] [--no-recent] [--custom-id <id>] [--extract] [--content-stdin] [--reason <label>] [--file <path>] [--dashboard-url <url>] [--dry-run] [--json]",
97
+ " cockpit memory [search \"<question>\"|save|update <id>|forget <id>|profile|install|status|log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"] [--container <tag>] [--limit <n>] [--no-recent] [--custom-id <id>] [--extract] [--content-stdin] [--reason <label>] [--file <path>] [--dashboard-url <url>] [--dry-run] [--json]",
98
98
  " cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
99
99
  " cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>] [--parent <id>|root|--clear-parent] [--query <text>] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
100
100
  " cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `cockpit memory consolidation` — what last night's tidy-up did (BLI-4534).
3
+ *
4
+ * The nightly pass runs on Railway, not here. This verb is a READ of its
5
+ * receipts over `GET /api/memory/consolidation`, with this machine's collector
6
+ * device token, through `agent-door.ts` — the same door the
7
+ * `memory_consolidation` MCP tool calls and the same credential every other
8
+ * `cockpit memory` verb uses.
9
+ *
10
+ * With `--container` it answers that space's most recent runs; without one it
11
+ * answers the newest run per container, because a list of forty nights of one
12
+ * shelf answers nothing.
13
+ *
14
+ * **It cannot start, stop or change a pass, and it never prints a memory.**
15
+ * The rows carry ids, counts, reason labels, a model id and a dollar figure;
16
+ * the door does not send text and this does not ask for any. `report` in the
17
+ * `mode` column means the pass computed proposals and wrote nothing to the
18
+ * shelf; `apply` means the accepted edits were written as supersessions, which
19
+ * never delete anything.
20
+ *
21
+ * **A refusal keeps the door's own reason label**, printed verbatim by
22
+ * `failAgentDoor`. An EMPTY list is not a refusal and says so in its own
23
+ * words: the run table is readable by a super_admin, so a caller who is not
24
+ * one sees nothing, and that is RLS answering rather than an outage.
25
+ */
26
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, } from "./agent-door.js";
27
+ import { writeLine } from "./cli-io.js";
28
+ const TAG = "[memory cli]";
29
+ const READ_DEADLINE_MS = 30_000;
30
+ /** `memory-store.ts` opens the door once for the whole command run and hands it here. */
31
+ export async function runMemoryConsolidation(command, door) {
32
+ const container = command.containerTags[0];
33
+ const query = container ? `?container=${encodeURIComponent(container)}` : "";
34
+ const answer = await askAgentDoor(door, {
35
+ path: `/api/memory/consolidation${query}`,
36
+ method: "GET",
37
+ label: "memory consolidation",
38
+ timeoutMs: READ_DEADLINE_MS,
39
+ });
40
+ if (!answer.ok)
41
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
42
+ const body = answer.body;
43
+ const runs = body.runs ?? [];
44
+ writeLine(door.io.stderr, `${TAG} consolidation ${JSON.stringify({
45
+ reason: body.reason ?? (runs.length > 0 ? "runs_read" : "no_runs_visible"),
46
+ container: container ?? null,
47
+ runs: runs.length,
48
+ })}`);
49
+ if (door.json)
50
+ return emitAgentDoor(door, answer.body);
51
+ if (runs.length === 0) {
52
+ writeLine(door.io.stdout, body.role === "super_admin"
53
+ ? "No consolidation pass has been recorded yet. The pass is a nightly Railway cron; nothing has run, or nothing has changed."
54
+ : "No consolidation runs are visible to this account. The receipts are readable by a super_admin; this is the access rule answering, not an outage.");
55
+ return 0;
56
+ }
57
+ for (const run of runs) {
58
+ writeLine(door.io.stdout, `${run.containerTag ?? "(no tag)"} ${run.mode ?? "?"}`);
59
+ writeLine(door.io.stdout, ` ${run.startedAt ?? "?"} -> ${run.finishedAt ?? "unfinished"} ${run.reason ?? "no reason recorded"}`);
60
+ writeLine(door.io.stdout, ` clusters ${run.clusters ?? 0} proposed ${countsOf(run.proposed)} applied ${countsOf(run.applied)}`);
61
+ const refused = run.refused ?? {};
62
+ const refusedText = Object.entries(refused)
63
+ .map(([label, count]) => `${label} ${count}`)
64
+ .join(", ");
65
+ writeLine(door.io.stdout, ` refused ${refusedText.length > 0 ? refusedText : "none"} ${moneyOf(run.costUsd)} ${run.model ?? "no model"}`);
66
+ }
67
+ writeLine(door.io.stdout, "");
68
+ writeLine(door.io.stdout, "report = proposals only, nothing on the shelf changed. apply = the accepted edits were written as supersessions; nothing is ever deleted.");
69
+ return 0;
70
+ }
71
+ /** `merge 2, rewrite 1` out of a verb-count object. Never a sentence. */
72
+ function countsOf(value) {
73
+ const verbs = value?.by_verb;
74
+ if (!verbs || typeof verbs !== "object")
75
+ return "none";
76
+ const parts = Object.entries(verbs)
77
+ .filter(([, count]) => Number(count) > 0)
78
+ .map(([verb, count]) => `${verb} ${Number(count)}`);
79
+ return parts.length > 0 ? parts.join(", ") : "none";
80
+ }
81
+ function moneyOf(value) {
82
+ return value === null || value === undefined ? "$0.000000" : `$${Number(value).toFixed(6)}`;
83
+ }
@@ -18,7 +18,10 @@
18
18
  * repo forever.
19
19
  * 2. **The file shape is Claude Code's, verbatim.** Event → array of matcher
20
20
  * groups → `hooks: [{ type: "command", command, timeout }]`, with the same
21
- * event NAMES: `SessionStart`, `UserPromptSubmit`, `Stop`. That is why the
21
+ * event NAMES: `SessionStart`, `UserPromptSubmit`, `Stop` — and, since
22
+ * BLI-4535, `PreCompact`, which Codex publishes under that exact name with
23
+ * the same `manual`/`auto` trigger Claude Code sends
24
+ * (<https://learn.chatgpt.com/docs/hooks>, read 2026-09-21). That is why the
22
25
  * merge here is `applyHookGroups` from `memory-install-claude.ts` and not
23
26
  * a second copy: same contract, one owner.
24
27
  * 3. **Hooks are on by default now.** `[features] hooks = false` turns them
@@ -196,5 +199,8 @@ function trustTableNamesOurFile(tableHeader, wantedSource) {
196
199
  * which is what `applyHookGroups` writes.
197
200
  */
198
201
  export function expectedCodexTrustRows(config) {
202
+ // Read off the config rather than written as a number here, so adding a
203
+ // fourth event (BLI-4535) moved this expectation with it instead of leaving
204
+ // every trusted machine reading `needs_trust` for a row nobody counted.
199
205
  return config.hooks.length;
200
206
  }
@@ -27,7 +27,8 @@
27
27
  * "hooks": [
28
28
  * { "event": "SessionStart", "command": "bli-memory-mcp hook session-start", "timeout_seconds": 30 },
29
29
  * { "event": "UserPromptSubmit", "command": "bli-memory-mcp hook prompt", "timeout_seconds": 5 },
30
- * { "event": "Stop", "command": "bli-memory-mcp hook stop", "timeout_seconds": 30 }
30
+ * { "event": "Stop", "command": "bli-memory-mcp hook stop", "timeout_seconds": 30 },
31
+ * { "event": "PreCompact", "command": "bli-memory-mcp hook pre-compact", "timeout_seconds": 30 }
31
32
  * ],
32
33
  * "permissions_allow": ["mcp__bli-memory__search_memory"]
33
34
  * }
@@ -47,7 +48,7 @@
47
48
  * `@bli-cockpit/cli`'s `node_modules` rather than on PATH — so
48
49
  * `withResolvedBinPath` re-points every command at the absolute path this
49
50
  * machine resolved before anything is written. Skipping that step registered
50
- * three hooks that answered `command not found` on every turn.
51
+ * four hooks that answered `command not found` on every turn.
51
52
  *
52
53
  * **And the path it is re-pointed at is written for a SHELL, not for the
53
54
  * filesystem** — `shellSafeBinPath`. A hook command is a shell string, Windows
@@ -64,22 +65,39 @@ export const MEMORY_MCP_BIN = "bli-memory-mcp";
64
65
  export const MEMORY_MCP_SERVER_ID = "bli-memory";
65
66
  /** The one env var the server is handed. A URL, never a token. */
66
67
  export const MEMORY_DASHBOARD_URL_ENV = "COCKPIT_DASHBOARD_URL";
67
- /** Claude Code hook events this installer registers, in the order it writes them. */
68
+ /**
69
+ * Hook events this installer registers, in the order it writes them.
70
+ *
71
+ * **All four go to BOTH hosts (BLI-4535).** `PreCompact` was checked against
72
+ * each vendor's own reference before it was added here rather than assumed
73
+ * from Claude Code's list: Claude Code fires it "before Claude Code is about
74
+ * to run a compact operation" with `trigger` of `manual` or `auto`
75
+ * (<https://code.claude.com/docs/en/hooks>, read 2026-09-21), and Codex fires
76
+ * `PreCompact` "before Codex compacts the chat" with the same two trigger
77
+ * values and the same common input fields
78
+ * (<https://learn.chatgpt.com/docs/hooks>, read 2026-09-21). Same event name,
79
+ * same file shape, same bin — so `memory-install-codex-hooks.ts` needs no
80
+ * per-host filter and Codex is NOT left at three events.
81
+ */
68
82
  export const MEMORY_HOOK_EVENTS = [
69
83
  "SessionStart",
70
84
  "UserPromptSubmit",
71
85
  "Stop",
86
+ "PreCompact",
72
87
  ];
73
88
  /**
74
89
  * The bin's own hook subcommands. `session-start` recalls at session open,
75
90
  * `prompt` recalls per turn, `stop` saves the turn — the same three jobs the
76
91
  * Supermemory plugin's `session-start.js` / `recall-directive.js` / `capture.js`
77
- * did, collapsed onto one bin.
92
+ * did, collapsed onto one bin — and `pre-compact` writes the session's recap
93
+ * before the host throws the conversation away (BLI-4535), which the vendor
94
+ * plugin never did at all.
78
95
  */
79
96
  export const MEMORY_HOOK_SUBCOMMAND = {
80
97
  SessionStart: "hook session-start",
81
98
  UserPromptSubmit: "hook prompt",
82
99
  Stop: "hook stop",
100
+ PreCompact: "hook pre-compact",
83
101
  };
84
102
  /**
85
103
  * Injection hooks must stay fast — Claude Code discards a hook that outlives
@@ -90,6 +108,10 @@ export const MEMORY_HOOK_TIMEOUT_SECONDS = {
90
108
  SessionStart: 30,
91
109
  UserPromptSubmit: 5,
92
110
  Stop: 30,
111
+ // BLI-4535. Stop's number for Stop's reason: this hook WRITES, its door call
112
+ // is one queued INSERT, and the recap is the last thing that can be saved
113
+ // before the context is gone.
114
+ PreCompact: 30,
93
115
  };
94
116
  /**
95
117
  * Auto-approved tools. READ ONLY, deliberately: the vendor's
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `cockpit memory requeue` — put a window of extractions back (BLI-4539).
3
+ *
4
+ * The terminal twin of `POST /api/memory/requeue`, and the reason it exists at
5
+ * all: between 04:16Z and 11:36Z on 2026-09-21 every insert into the memory
6
+ * store that carried aliases was refused, 122 extraction receipts closed as if
7
+ * nothing were wrong, and putting them back took a one-off script somebody
8
+ * wrote by hand at a keyboard. The masked content sits on those rows for seven
9
+ * days; recovering it should be one command.
10
+ *
11
+ * Its own module rather than a sixth branch of `memory-store.ts`, because that
12
+ * file is already at the readability band and because this verb refuses
13
+ * different things from its five siblings: a window is required, the gate is
14
+ * super_admin, and the default is a DRY RUN.
15
+ *
16
+ * **A dry run is the default.** `--dry-run` is the safe question and an apply
17
+ * takes `--apply` explicitly. Applying re-runs one model call per receipt, so
18
+ * the shape of the mistake is somebody's bill, and a flag you have to type is
19
+ * the cheapest possible guard against it.
20
+ *
21
+ * **The door's own reason label reaches the terminal unchanged** —
22
+ * `since_required`, `window_inverted`, `source_unknown`, `reason_not_a_label`,
23
+ * `requeue_count_failed` — printed verbatim by `failAgentDoor`, never
24
+ * rephrased here.
25
+ */
26
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
27
+ import { writeLine } from "./cli-io.js";
28
+ const TAG = "[memory requeue cli]";
29
+ /** Long: the door walks up to twenty pages of receipts and writes each row on its own. */
30
+ const REQUEUE_DEADLINE_MS = 300_000;
31
+ export async function runMemoryRequeue(command, io) {
32
+ const door = await openAgentDoor("memory", command, io);
33
+ const answer = await askAgentDoor(door, {
34
+ path: "/api/memory/requeue",
35
+ method: "POST",
36
+ label: "memory requeue",
37
+ timeoutMs: REQUEUE_DEADLINE_MS,
38
+ body: {
39
+ since: command.since ?? "",
40
+ ...(command.until ? { until: command.until } : {}),
41
+ ...(command.reason ? { reason: command.reason } : {}),
42
+ ...(command.source ? { source: command.source } : {}),
43
+ dryRun: command.dryRun,
44
+ },
45
+ });
46
+ if (!answer.ok)
47
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
48
+ const body = answer.body;
49
+ // An HTTP 200 is not an answer. A dashboard that has not shipped this door
50
+ // yet renders its own 404 PAGE at 200 with an HTML body, and this verb
51
+ // printed `applied {matched: 0}` for it — a silent success over a route that
52
+ // does not exist, which is the exact shape of lie the whole ticket is about.
53
+ // Proven against production on 2026-09-21, before the door was deployed.
54
+ if (typeof body.matched !== "number") {
55
+ return failAgentDoor(door, TAG, "requeue_answer_unreadable", "Tower answered 200 with no requeue receipt in it. The usual cause is a dashboard that has not shipped POST /api/memory/requeue yet; nothing was requeued.");
56
+ }
57
+ // Counts and labels only: a receipt's content never enters a log line.
58
+ writeLine(door.io.stderr, `${TAG} ${body.dryRun ? "dry run" : "applied"} ${JSON.stringify({
59
+ since: command.since ?? "",
60
+ until: command.until ?? null,
61
+ source: command.source ?? null,
62
+ matched: body.matched ?? 0,
63
+ scanned: body.scanned ?? 0,
64
+ requeued: body.requeued ?? 0,
65
+ refused: body.refused ?? 0,
66
+ coverage: body.coverage ?? "unknown",
67
+ })}`);
68
+ if (door.json)
69
+ return emitAgentDoor(door, answer.body);
70
+ const matched = body.matched ?? 0;
71
+ if (matched === 0) {
72
+ // Silence is not an outage, and it is not a wrong filter either — say
73
+ // which one this is by naming the window that was asked about.
74
+ writeLine(door.io.stdout, `No finished extraction receipt is in that window (${command.since} → ${command.until ?? "now"}${command.source ? `, ${command.source} only` : ""}). Nothing to requeue; the receipts table answered.`);
75
+ return 0;
76
+ }
77
+ writeLine(door.io.stdout, body.dryRun
78
+ ? `${matched} receipt(s) would be requeued (${describeSplit(body.bySource)}).`
79
+ : `${body.requeued ?? 0} of ${matched} receipt(s) requeued (${describeSplit(body.bySource)}).`);
80
+ const reasons = describeSplit(body.byReason);
81
+ if (reasons)
82
+ writeLine(door.io.stdout, ` by reason: ${reasons}`);
83
+ if ((body.refused ?? 0) > 0) {
84
+ writeLine(door.io.stdout, ` ${body.refused} row(s) were refused — a drain claimed them between the read and the write, which is the conditional update doing its job.`);
85
+ }
86
+ if (body.coverage === "capped") {
87
+ writeLine(door.io.stdout, " The page ceiling stopped this run before the window ended. Run it again for the rest; nothing is lost.");
88
+ }
89
+ if (body.dryRun) {
90
+ writeLine(door.io.stdout, "");
91
+ writeLine(door.io.stdout, "Nothing was written. Add --apply to do it for real.");
92
+ }
93
+ else {
94
+ writeLine(door.io.stdout, "");
95
+ writeLine(door.io.stdout, "They are queued again: the hourly sweep reruns the hook receipts and the harvest pass reserves the rest. The previous result is kept on each row under result.requeued_from.");
96
+ }
97
+ return 0;
98
+ }
99
+ /** `hook:104, harvest:18` — counts and labels, in the order the door sorted them. */
100
+ function describeSplit(split) {
101
+ if (!split)
102
+ return "";
103
+ return Object.entries(split)
104
+ .sort((left, right) => right[1] - left[1] || (left[0] < right[0] ? -1 : 1))
105
+ .map(([label, count]) => `${label}:${count}`)
106
+ .join(", ");
107
+ }
@@ -1,16 +1,21 @@
1
1
  /**
2
- * `cockpit memory search|save|update|forget` — BLI Memory at a terminal
3
- * (BLI-4047).
2
+ * `cockpit memory search|save|update|forget|profile|consolidation|requeue` —
3
+ * BLI Memory at a terminal (BLI-4047; `profile` added by BLI-4533,
4
+ * `consolidation` by BLI-4534 and `requeue` by BLI-4539, the last two running
5
+ * in `memory-consolidation.ts` and `memory-requeue.ts` next door).
4
6
  *
5
7
  * Memory was the one Tower surface with no command-line door. The browser had
6
8
  * one, the `bli-memory` MCP server had four tools, and a person or a script
7
9
  * had nothing — so a machine without an agent host could not read or write the
8
10
  * 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
+ * instead of saying what the noun does. The first four verbs are the twins of
12
+ * that server's four tools; `profile` (BLI-4533) is the twin of the
13
+ * `memory_profile` tool on `bli-tower`, and reads the compiled one-page
14
+ * briefing a session start injects.
11
15
  *
12
16
  * **The same doors, never a second store.** `POST /api/memory/{search,save,
13
- * update,forget}`, with this machine's collector device token, through
17
+ * update,forget}` and `GET /api/memory/profile`, with this machine's collector
18
+ * device token, through
14
19
  * `agent-door.ts` — the identical route the MCP server calls in
15
20
  * `packages/cockpit-memory-mcp/src/server.ts` and the browser calls from
16
21
  * `lib/memory/agent-memories/`. Nothing here talks to Postgres, embeds
@@ -40,6 +45,8 @@
40
45
  import { resolveContainerTag } from "@bli-cockpit/memory-mcp/dist/container-tag.js";
41
46
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
42
47
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
48
+ import { runMemoryConsolidation } from "./memory-consolidation.js";
49
+ import { runMemoryRequeue } from "./memory-requeue.js";
43
50
  import { readNoteFile } from "./notes-file.js";
44
51
  const TAG = "[memory cli]";
45
52
  const SEARCH_DEADLINE_MS = 60_000;
@@ -60,8 +67,69 @@ export async function runMemoryStore(command, io) {
60
67
  return updateMemory(command, door);
61
68
  case "forget":
62
69
  return forgetMemory(command, door);
70
+ case "profile":
71
+ return profileMemory(command, door);
72
+ case "consolidation":
73
+ // BLI-4534. Its own module: it reads a different door, prints a
74
+ // different shape, and is the only memory verb that is about the JOB
75
+ // rather than about a memory.
76
+ return runMemoryConsolidation(command, door);
77
+ case "requeue":
78
+ // Its own module too: a window-shaped, super-admin, dry-run-by-default
79
+ // verb that refuses nothing the others refuse (BLI-4539).
80
+ return runMemoryRequeue(command, io);
63
81
  }
64
82
  }
83
+ /**
84
+ * `cockpit memory profile` — the compiled one-page briefing (BLI-4533).
85
+ *
86
+ * The same page `POST /api/memory/v4/profile` injects at session start, read on
87
+ * its own over `GET /api/memory/profile?container=<tag>`. It is a READ: no
88
+ * compile happens here and none can be asked for. The daily `memory-profile`
89
+ * service writes the row; this verb answers "what does this project's memory
90
+ * say about itself right now?", which is the question a person asks when a
91
+ * session opened with something wrong in it.
92
+ *
93
+ * A container nobody has compiled yet answers 200 with `profile: null` and the
94
+ * reason `not_compiled_yet`. That is not an error and is not printed as one —
95
+ * "nothing is compiled" and "the store is down" are different facts, and the
96
+ * second one arrives as a 502 through `failAgentDoor` with the door's own
97
+ * label.
98
+ */
99
+ async function profileMemory(command, door) {
100
+ const containerTag = containerTagsFor(command)[0];
101
+ const answer = await askAgentDoor(door, {
102
+ path: `/api/memory/profile?container=${encodeURIComponent(containerTag)}`,
103
+ method: "GET",
104
+ label: "memory profile",
105
+ timeoutMs: SEARCH_DEADLINE_MS,
106
+ });
107
+ if (!answer.ok)
108
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
109
+ const body = answer.body;
110
+ const profile = body.profile ?? null;
111
+ const lines = profile?.lines ?? [];
112
+ // Counts and labels only: a profile line never enters a log line.
113
+ writeLine(door.io.stderr, `${TAG} profile ${JSON.stringify({
114
+ container_tag: body.containerTag ?? containerTag,
115
+ reason: body.reason ?? "unknown",
116
+ lines: lines.length,
117
+ source_rows: profile?.sourceRows ?? 0,
118
+ compiled_at: profile?.compiledAt ?? null,
119
+ model: profile?.model ?? null,
120
+ })}`);
121
+ if (door.json)
122
+ return emitAgentDoor(door, answer.body);
123
+ if (!profile || lines.length === 0) {
124
+ writeLine(door.io.stdout, `No page is compiled for ${body.containerTag ?? containerTag} yet. The daily compile writes one once this space has memories; the store is fine.`);
125
+ return 0;
126
+ }
127
+ for (const line of lines)
128
+ writeLine(door.io.stdout, line);
129
+ writeLine(door.io.stdout, "");
130
+ writeLine(door.io.stdout, `Compiled ${String(profile.compiledAt ?? "—")} from ${String(profile.sourceRows ?? 0)} memories by ${String(profile.model ?? "—")}.`);
131
+ return 0;
132
+ }
65
133
  /**
66
134
  * The verb list a bare `cockpit memory` prints (BLI-4047).
67
135
  *
@@ -77,7 +145,7 @@ export function runMemoryVerbList(io, json) {
77
145
  ok: true,
78
146
  noun: "memory",
79
147
  machine_verbs: ["install", "status"],
80
- store_verbs: ["search", "save", "update", "forget"],
148
+ store_verbs: ["search", "save", "update", "forget", "profile", "consolidation", "requeue"],
81
149
  log_verbs: ["log"],
82
150
  }));
83
151
  return 0;
@@ -92,6 +160,13 @@ export function runMemoryVerbList(io, json) {
92
160
  " cockpit memory update <id> [--file <path>] [--json] replacement text on stdin",
93
161
  " cockpit memory forget <id> [--reason <label>] [--json]",
94
162
  " cockpit memory forget --content-stdin --container <tag> [--reason <label>] [--json]",
163
+ " cockpit memory profile [--container <tag>] [--json]",
164
+ " the compiled one-page briefing every session opens with; a read, never a compile",
165
+ " cockpit memory consolidation [--container <tag>] [--json]",
166
+ " what the nightly tidy-up pass did: clusters, edits by verb, refusals, cost",
167
+ " cockpit memory requeue --since <iso> [--until <iso>] [--source hook|harvest] [--reason <label>] [--apply] [--json]",
168
+ " put a window of finished extractions back in the queue after the store refused",
169
+ " their writes; super admins only, and a DRY RUN unless you pass --apply",
95
170
  "",
96
171
  "This machine:",
97
172
  " cockpit memory install [--dry-run] [--json] register the MCP server and the three hooks",
@@ -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.128");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.132");
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.128",
3
+ "version": "0.2.132",
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-exit-contract.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.34",
31
- "@bli-cockpit/mcp": "0.1.55",
30
+ "@bli-cockpit/memory-mcp": "0.1.35",
31
+ "@bli-cockpit/mcp": "0.1.58",
32
32
  "@bli-cockpit/telemetry-core": "0.1.48"
33
33
  }
34
34
  }