@bli-cockpit/cli 0.2.97 → 0.2.98

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.
@@ -11,6 +11,7 @@
11
11
  import { DEFAULT_AUTOSTART_INTERVAL_SECONDS } from "../autostart.js";
12
12
  import { assertNoPositionals, normalizeUrl, optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, workRootFlagValue, } from "./local-arg-values.js";
13
13
  import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
14
+ import { MEMORY_STORE_ACTIONS, parseMemoryStoreArgs, } from "./local-args-tower-memory.js";
14
15
  export function parseStatusArgs(args) {
15
16
  const values = parseNamedArgs(args, {
16
17
  allowedFlags: [
@@ -164,11 +165,30 @@ export function parseAgentRulesArgs(args) {
164
165
  };
165
166
  }
166
167
  /**
167
- * BLI-3580. `install` is the default action on purpose: this command exists to
168
- * be run unasked (by `do-everything` and by the sync tick), and a bare
169
- * `cockpit memory` should do the thing rather than print usage.
168
+ * BLI-3580, amended by BLI-4047.
169
+ *
170
+ * `cockpit memory` is now SIX verbs behind one noun: the two that configure
171
+ * this machine (`install`, `status`), the experience log (`log`), and the four
172
+ * that read and write the memory store itself (`search`, `save`, `update`,
173
+ * `forget`, parsed next door in `local-args-tower-memory.ts`).
174
+ *
175
+ * **A bare `cockpit memory` prints the verb list; it no longer installs.**
176
+ * BLI-3580 made `install` the default because the command is run unasked by
177
+ * `do-everything` and by the sync tick — but those callers build
178
+ * `{ kind: "memory", action: "install" }` in code (`doctor-registration.ts`,
179
+ * `sync-followups-memory.ts`, `onboard-completion.ts`) and never pass through
180
+ * this parser. The only caller the default ever served was a PERSON typing the
181
+ * noun to find out what it does, and what they got was an MCP server and three
182
+ * hooks registered on their machine. Installing stays one word away.
170
183
  */
171
184
  export function parseMemoryArgs(args) {
185
+ // The verb word first, always: reading it off `args[0]` rather than "the
186
+ // first token without a leading dash" is what keeps `cockpit memory --home
187
+ // /tmp/x status` from being read as the verb `/tmp/x`.
188
+ const first = args[0];
189
+ if (first !== undefined && MEMORY_STORE_ACTIONS.has(first)) {
190
+ return parseMemoryStoreArgs(first, args);
191
+ }
172
192
  const values = parseNamedArgs(args, {
173
193
  allowedFlags: ["--home", "--dashboard-url", "--dry-run", "--json", "--store", "--reason-stdin"],
174
194
  valueFlags: ["--home", "--dashboard-url", "--store"],
@@ -192,9 +212,10 @@ export function parseMemoryArgs(args) {
192
212
  if (values.positionals.length > 1) {
193
213
  throw new Error("memory accepts at most one action (install|status).");
194
214
  }
195
- const action = values.positionals[0] ?? "install";
196
- if (action !== "install" && action !== "status") {
197
- throw new Error("memory action must be install or status.");
215
+ // BLI-4047: no positional means "tell me what this noun does", not "install".
216
+ const action = values.positionals[0] ?? "help";
217
+ if (action !== "install" && action !== "status" && action !== "help") {
218
+ throw new Error("memory action must be install, status, help, log, search, save, update, or forget.");
198
219
  }
199
220
  return {
200
221
  kind: "memory",
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `cockpit memory search|save|update|forget` — the argument table (BLI-4047).
3
+ *
4
+ * BLI Memory was the one Tower surface with no command-line door: the browser
5
+ * 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
+ *
9
+ * Two rules this table exists to enforce, both of them the same discipline
10
+ * `cockpit docs create` and `cockpit notes paste` already follow:
11
+ *
12
+ * - **A body never travels on the command line.** `save`, `update` and
13
+ * `forget --content` read their text from stdin (or `--file`, which is the
14
+ * safest way to say it on Windows). An argument list is visible in `ps`,
15
+ * lands in a shell history file, and is quoted wrong on the first line that
16
+ * contains a newline. A QUERY is not a body — `search` takes one
17
+ * positional, the same way `cockpit search` and `cockpit docs list --query`
18
+ * do — but the text of a memory always is.
19
+ * - **The container tag is derived, not typed.** Omit `--container` and the
20
+ * tag comes from this repository, resolved by `@bli-cockpit/memory-mcp`'s
21
+ * own resolver, so a memory saved from the terminal lands in the same space
22
+ * the MCP server and the hooks write to. `--container` may be repeated on
23
+ * `search` alone, because searching two spaces is meaningful and saving
24
+ * into two is not.
25
+ *
26
+ * The verbs live in their own `kind` (`memory-store`) rather than widening
27
+ * `kind: "memory"`, because that command acts on THIS MACHINE (it registers an
28
+ * MCP server and three hooks) and these act on Tower. `memory-store.ts` runs
29
+ * them; `parseMemoryArgs` in `local-args-collector-status.ts` routes to here.
30
+ */
31
+ import { optionalNonEmpty, optionalUrl, parseNamedArgs, workRootFlagValue, } from "./local-arg-values.js";
32
+ export const MEMORY_STORE_ACTIONS = new Set([
33
+ "search",
34
+ "save",
35
+ "update",
36
+ "forget",
37
+ ]);
38
+ const ALLOWED_FLAGS = [
39
+ "--home",
40
+ "--dashboard-url",
41
+ "--workspace",
42
+ "--repo",
43
+ "--container",
44
+ "--limit",
45
+ "--no-recent",
46
+ "--custom-id",
47
+ "--extract",
48
+ "--file",
49
+ "--content-stdin",
50
+ "--reason",
51
+ "--json",
52
+ ];
53
+ const VALUE_FLAGS = [
54
+ "--home",
55
+ "--dashboard-url",
56
+ "--workspace",
57
+ "--repo",
58
+ "--container",
59
+ "--limit",
60
+ "--custom-id",
61
+ "--file",
62
+ "--reason",
63
+ ];
64
+ export function parseMemoryStoreArgs(action, args) {
65
+ const values = parseNamedArgs(args, { allowedFlags: ALLOWED_FLAGS, valueFlags: VALUE_FLAGS });
66
+ const rest = values.positionals.slice(1);
67
+ const containers = values.flagValues.get("--container") ?? [];
68
+ if (action !== "search" && containers.length > 1) {
69
+ throw new Error(`memory ${action} takes at most one --container.`);
70
+ }
71
+ if (action !== "search" && (values.flags.has("--limit") || values.booleans.has("--no-recent"))) {
72
+ throw new Error("--limit and --no-recent only apply to memory search.");
73
+ }
74
+ if (action !== "save" && (values.flags.has("--custom-id") || values.booleans.has("--extract"))) {
75
+ throw new Error("--custom-id and --extract only apply to memory save.");
76
+ }
77
+ if (action !== "forget" && (values.flags.has("--reason") || values.booleans.has("--content-stdin"))) {
78
+ throw new Error("--reason and --content-stdin only apply to memory forget.");
79
+ }
80
+ if (values.flags.has("--file") && action !== "save" && action !== "update") {
81
+ throw new Error("--file only applies to memory save and memory update.");
82
+ }
83
+ const command = {
84
+ kind: "memory-store",
85
+ action,
86
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
87
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
88
+ workspace: optionalNonEmpty(workRootFlagValue(values)),
89
+ containerTags: containers
90
+ .map((tag) => tag.trim())
91
+ .filter((tag) => tag.length > 0),
92
+ json: values.booleans.has("--json"),
93
+ };
94
+ if (action === "search") {
95
+ // Every remaining positional joined, so `cockpit memory search what did we
96
+ // decide about pagination` needs no quotes — the same shape `cockpit
97
+ // search` and `cockpit cal find` already have.
98
+ const query = rest.join(" ").trim();
99
+ if (!query)
100
+ throw new Error("memory search needs a question, e.g. `cockpit memory search \"pagination rule\"`.");
101
+ return { ...command, action, query, limit: readLimit(values.flags.get("--limit")), includeRecent: !values.booleans.has("--no-recent") };
102
+ }
103
+ if (action === "save") {
104
+ if (rest.length > 0) {
105
+ throw new Error("memory save reads the memory from stdin or --file, never from the command line: `echo \"...\" | cockpit memory save`.");
106
+ }
107
+ return {
108
+ ...command,
109
+ action,
110
+ customId: optionalNonEmpty(values.flags.get("--custom-id")),
111
+ extract: values.booleans.has("--extract"),
112
+ filePath: optionalNonEmpty(values.flags.get("--file")),
113
+ };
114
+ }
115
+ if (action === "update") {
116
+ const memoryId = optionalNonEmpty(rest[0]);
117
+ if (!memoryId)
118
+ throw new Error("memory update needs the memory id `cockpit memory search` printed.");
119
+ if (rest.length > 1) {
120
+ throw new Error("memory update takes one id; the replacement text comes from stdin or --file, never from the command line.");
121
+ }
122
+ return { ...command, action, memoryId, filePath: optionalNonEmpty(values.flags.get("--file")) };
123
+ }
124
+ const memoryId = optionalNonEmpty(rest[0]);
125
+ if (rest.length > 1)
126
+ throw new Error("memory forget takes one id, or --content-stdin with a --container.");
127
+ const contentStdin = values.booleans.has("--content-stdin");
128
+ if (memoryId && contentStdin) {
129
+ throw new Error("memory forget takes an id OR --content-stdin, never both — they name different rows.");
130
+ }
131
+ if (!memoryId && !contentStdin) {
132
+ throw new Error("memory forget needs an id, or --content-stdin plus --container to forget by exact text.");
133
+ }
134
+ return {
135
+ ...command,
136
+ action,
137
+ memoryId,
138
+ contentStdin,
139
+ reason: optionalNonEmpty(values.flags.get("--reason")),
140
+ };
141
+ }
142
+ /** 1..20, the same window the MCP tool and the door both accept. */
143
+ function readLimit(value) {
144
+ if (value === undefined)
145
+ return undefined;
146
+ const parsed = Number.parseInt(value, 10);
147
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > 20) {
148
+ throw new Error("--limit must be between 1 and 20.");
149
+ }
150
+ return parsed;
151
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The `cockpit memory` family's command shapes (BLI-4047).
3
+ *
4
+ * Split out of `local-command-shapes.ts` the same way `SearchCommandShape` and
5
+ * the product shapes were: that file crossed the repo's 500-line band floor
6
+ * when the store verbs arrived, and one noun's shapes are exactly the kind of
7
+ * thing that reads better in its own file than as a third of a 540-line union.
8
+ *
9
+ * Three kinds, and the difference between them is the point:
10
+ *
11
+ * `memory` install / status / help — acts on THIS MACHINE, writing an
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.
16
+ * `memory-log` the agent-experience opinion, part local file part Tower.
17
+ *
18
+ * They are separate kinds rather than one widening `action` union because they
19
+ * refuse different things, run on different credentials, and one of them can
20
+ * be run unasked by a scheduler while the others never are.
21
+ */
22
+ export {};
@@ -555,15 +555,32 @@ export function localSubcommandHelp(command) {
555
555
  [
556
556
  "memory",
557
557
  [
558
- "Usage: cockpit memory [install|status|log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"] [--dashboard-url <url>] [--dry-run] [--json]",
559
- "",
560
- "Registers BLI Memory on this machine: the `bli-memory` MCP server plus the",
558
+ "Usage: cockpit memory [search|save|update|forget|install|status|log] [flags]",
559
+ "",
560
+ "The STORE, over the same /api/memory/* doors the bli-memory MCP server calls:",
561
+ " search \"<question>\" [--container <tag>] [--limit 1..20] [--no-recent]",
562
+ " repeat --container to search more than one space; omit it for this repository's own.",
563
+ " save [--container <tag>] [--custom-id <id>] [--extract] [--file <path>]",
564
+ " the memory comes from stdin or --file, never from the command line:",
565
+ " `echo \"Edward decided X on 2026-09-10\" | cockpit memory save`.",
566
+ " --custom-id makes a repeat save update instead of duplicating;",
567
+ " --extract distils atomic facts first (one model call) instead of storing as given.",
568
+ " update <id> [--file <path>] replacement text on stdin; the old row is kept as history.",
569
+ " forget <id> [--reason <label>]",
570
+ " forget --content-stdin --container <tag> [--reason <label>] forget by exact text.",
571
+ "Refusals keep the door's own label — needs_rls_client, unknown_memory,",
572
+ "already_superseded, no_match — and --json prints the door's answer unchanged.",
573
+ "",
574
+ "BARE `cockpit memory` PRINTS THIS LIST AND DOES NOTHING ELSE (BLI-4047).",
575
+ "It used to run the installer; installing is now `cockpit memory install`.",
576
+ "",
577
+ "install/status register BLI Memory on this machine: the `bli-memory` MCP server plus the",
561
578
  "recall/save hooks, for Claude Code (~/.claude.json, ~/.claude/settings.json)",
562
579
  "and for Codex (~/.codex/config.toml, ~/.codex/skills/bli-memory/).",
563
580
  "Idempotent: it merges with what is already there, never duplicates its own",
564
581
  "entries, and reads the stored config back before reporting success.",
565
582
  "`do-everything` runs it, and the sync tick re-runs it at most once a day.",
566
- "Action defaults to `install`. See docs/runbooks/bli-memory-install.md.",
583
+ "See docs/runbooks/bli-memory-install.md.",
567
584
  "log appends an opinion to ~/.codex/AGENT-EXPERIENCE.md and posts it to Tower.",
568
585
  "Use --reason-stdin instead of a quoted reason; --json reports shipped status.",
569
586
  "Reasons: one line, max 500 characters; no prompts, memory bodies or secrets.",
@@ -88,7 +88,7 @@ export function localCommandHelp(command) {
88
88
  " cockpit serve [--port <port>] [--workspace <path>]",
89
89
  " cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
90
90
  " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
91
- " cockpit memory [install|status|log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"] [--dashboard-url <url>] [--dry-run] [--json]",
91
+ " 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]",
92
92
  " cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
93
93
  " 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]",
94
94
  " 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]",
@@ -33,6 +33,7 @@ import { runAutostart } from "./autostart-command.js";
33
33
  import { runAgentRules } from "./agent-rules-command.js";
34
34
  import { runMemoryLog } from "./memory-log.js";
35
35
  import { runMemoryInstall } from "./memory-install.js";
36
+ import { runMemoryStore, runMemoryVerbList } from "./memory-store.js";
36
37
  import { runClean } from "./clean.js";
37
38
  import { runDocs } from "./docs.js";
38
39
  import { runMsg } from "./msg.js";
@@ -148,7 +149,14 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
148
149
  case "memory-log":
149
150
  return await runMemoryLog(command, io);
150
151
  case "memory":
152
+ // BLI-4047: a bare `cockpit memory` says what the noun does. It used
153
+ // to install; `install` is still one word away and every unattended
154
+ // caller asks for it by name in code.
155
+ if (command.action === "help")
156
+ return runMemoryVerbList(io, command.json);
151
157
  return await runMemoryInstall(command, io);
158
+ case "memory-store":
159
+ return await runMemoryStore(command, io);
152
160
  case "clean":
153
161
  return await runClean(command, io);
154
162
  case "docs":
@@ -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
+ }
@@ -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.98");
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.97",
3
+ "version": "0.2.98",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.25",
31
- "@bli-cockpit/mcp": "0.1.28",
31
+ "@bli-cockpit/mcp": "0.1.29",
32
32
  "@bli-cockpit/telemetry-core": "0.1.42"
33
33
  }
34
34
  }