@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.
- package/dist/commands/local-args-collector-status.js +27 -6
- package/dist/commands/local-args-tower-memory.js +151 -0
- package/dist/commands/local-command-shapes-memory.js +22 -0
- package/dist/commands/local-help-commands.js +21 -4
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/local.js +8 -0
- package/dist/commands/memory-store.js +331 -0
- package/dist/commands/public-root.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
|
168
|
-
*
|
|
169
|
-
* `cockpit memory`
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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 [
|
|
559
|
-
"",
|
|
560
|
-
"
|
|
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
|
-
"
|
|
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]",
|
package/dist/commands/local.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.29",
|
|
32
32
|
"@bli-cockpit/telemetry-core": "0.1.42"
|
|
33
33
|
}
|
|
34
34
|
}
|