@bli-cockpit/cli 0.2.48 → 0.2.50
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/adapters/raw-evidence-attribution-gaps.js +133 -0
- package/dist/adapters/raw-evidence-claude-reader.js +108 -0
- package/dist/adapters/raw-evidence-codex-reader.js +147 -0
- package/dist/adapters/raw-evidence-collection-state.js +199 -0
- package/dist/adapters/raw-evidence-facts.js +338 -0
- package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
- package/dist/adapters/raw-evidence-image-reader.js +107 -0
- package/dist/adapters/raw-evidence-sanitize.js +56 -0
- package/dist/adapters/raw-evidence-transcript-file.js +182 -0
- package/dist/adapters/raw-evidence.js +94 -1203
- package/dist/autostart-contract.js +79 -0
- package/dist/autostart-darwin-plist.js +265 -0
- package/dist/autostart-darwin.js +171 -0
- package/dist/autostart-windows-scripts.js +310 -0
- package/dist/autostart-windows-task-xml.js +260 -0
- package/dist/autostart-windows.js +237 -0
- package/dist/autostart-xml.js +23 -0
- package/dist/autostart.js +35 -1148
- package/dist/commands/agent-rules-command.js +55 -0
- package/dist/commands/agent-session-report.js +290 -0
- package/dist/commands/analyze.js +131 -0
- package/dist/commands/autostart-command.js +105 -0
- package/dist/commands/backfill-batches.js +34 -0
- package/dist/commands/backfill-candidates.js +54 -0
- package/dist/commands/backfill-checkpoint.js +101 -0
- package/dist/commands/backfill-command-line.js +70 -0
- package/dist/commands/backfill-evidence-outcomes.js +104 -0
- package/dist/commands/backfill-issues.js +265 -0
- package/dist/commands/backfill-output.js +75 -0
- package/dist/commands/backfill-plan.js +71 -0
- package/dist/commands/backfill-reasons.js +107 -0
- package/dist/commands/backfill-report.js +298 -0
- package/dist/commands/backfill-result.js +150 -0
- package/dist/commands/backfill-scan.js +274 -0
- package/dist/commands/backfill-scope.js +114 -0
- package/dist/commands/backfill-session-report.js +145 -0
- package/dist/commands/backfill-types.js +1 -0
- package/dist/commands/backfill-upload.js +212 -0
- package/dist/commands/backfill.js +58 -1705
- package/dist/commands/cli-io.js +13 -0
- package/dist/commands/doctor.js +57 -0
- package/dist/commands/jarvis-trace.js +184 -0
- package/dist/commands/jarvis.js +323 -7
- package/dist/commands/local-arg-values.js +169 -0
- package/dist/commands/local-args-collector.js +604 -0
- package/dist/commands/local-args-tower.js +891 -0
- package/dist/commands/local-args.js +10 -1549
- package/dist/commands/local-help.js +30 -5
- package/dist/commands/local.js +21 -1786
- package/dist/commands/login.js +53 -0
- package/dist/commands/logout.js +66 -0
- package/dist/commands/memory-install-claude.js +294 -0
- package/dist/commands/memory-install-codex.js +205 -0
- package/dist/commands/memory-install-contract.js +231 -0
- package/dist/commands/memory-install-files.js +63 -0
- package/dist/commands/memory-install-skills.js +121 -0
- package/dist/commands/memory-install-toml.js +265 -0
- package/dist/commands/memory-install.js +378 -0
- package/dist/commands/onboard-receipts.js +66 -0
- package/dist/commands/onboard-report.js +274 -0
- package/dist/commands/onboard.js +449 -0
- package/dist/commands/ops-render.js +36 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/serve.js +13 -0
- package/dist/commands/session-sync.js +513 -534
- package/dist/commands/settings-render.js +28 -0
- package/dist/commands/settings.js +66 -2
- package/dist/commands/start.js +47 -0
- package/dist/commands/sync-followups.js +308 -0
- package/dist/commands/sync.js +387 -0
- package/dist/local-state-attributed-target.js +75 -0
- package/dist/local-state-config.js +147 -0
- package/dist/local-state-files.js +59 -0
- package/dist/local-state-identity.js +73 -0
- package/dist/local-state-pairing.js +263 -0
- package/dist/local-state-paths.js +61 -0
- package/dist/local-state-session.js +68 -0
- package/dist/local-state-status.js +163 -0
- package/dist/local-state-work-context.js +190 -0
- package/dist/local-state.js +34 -848
- package/dist/tower-client.js +3 -2
- package/dist/tower-stream.js +76 -6
- package/package.json +2 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interface this installer consumes from `@bli-cockpit/memory-mcp`
|
|
3
|
+
* (BLI-3580, core slice on branch `edward/bli-3580-agent-memory-core`).
|
|
4
|
+
*
|
|
5
|
+
* That package does not exist on `main` yet, and the two pull requests must be
|
|
6
|
+
* able to land in either order — so the contract lives HERE, in one small
|
|
7
|
+
* module, and is stated twice on purpose:
|
|
8
|
+
*
|
|
9
|
+
* 1. `bli-memory-mcp --print-config --claude` prints the JSON below, and
|
|
10
|
+
* `--print-config --codex` prints the TOML table below. When the bin
|
|
11
|
+
* resolves, what it prints WINS: the server knows its own shape.
|
|
12
|
+
* 2. `builtinMemoryInstallConfig()` is the same shape, built locally, for a
|
|
13
|
+
* server that resolved but does not answer `--print-config` yet.
|
|
14
|
+
*
|
|
15
|
+
* Both require a RESOLVED bin. A machine that does not have the server gets
|
|
16
|
+
* nothing written at all and a `skipped bin_missing` receipt — the template is
|
|
17
|
+
* a fallback for an older server, never a substitute for an absent one
|
|
18
|
+
* (`no_bin_no_write`, memory-install.ts).
|
|
19
|
+
*
|
|
20
|
+
* The printed JSON contract (`--print-config --claude`):
|
|
21
|
+
*
|
|
22
|
+
* ```json
|
|
23
|
+
* {
|
|
24
|
+
* "schema": "bli-memory-install-config.v1",
|
|
25
|
+
* "server_id": "bli-memory",
|
|
26
|
+
* "mcp_server": { "command": "bli-memory-mcp", "args": [], "env": {} },
|
|
27
|
+
* "hooks": [
|
|
28
|
+
* { "event": "SessionStart", "command": "bli-memory-mcp hook session-start", "timeout_seconds": 30 },
|
|
29
|
+
* { "event": "UserPromptSubmit", "command": "bli-memory-mcp hook prompt", "timeout_seconds": 5 },
|
|
30
|
+
* { "event": "Stop", "command": "bli-memory-mcp hook stop", "timeout_seconds": 30 }
|
|
31
|
+
* ],
|
|
32
|
+
* "permissions_allow": ["mcp__bli-memory__search_memory"]
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* The printed TOML contract (`--print-config --codex`) is exactly one table:
|
|
37
|
+
*
|
|
38
|
+
* ```toml
|
|
39
|
+
* [mcp_servers.bli-memory]
|
|
40
|
+
* command = "bli-memory-mcp"
|
|
41
|
+
* args = []
|
|
42
|
+
* env = { COCKPIT_DASHBOARD_URL = "https://…" }
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* Nothing here touches the filesystem. The halves that do are
|
|
46
|
+
* `memory-install-claude.ts` and `memory-install-codex.ts`.
|
|
47
|
+
*/
|
|
48
|
+
/** The published bin name. Both hosts spawn this. */
|
|
49
|
+
export const MEMORY_MCP_BIN = "bli-memory-mcp";
|
|
50
|
+
/** The MCP server id, as it appears in `mcp__<server>__<tool>`. */
|
|
51
|
+
export const MEMORY_MCP_SERVER_ID = "bli-memory";
|
|
52
|
+
/** The one env var the server is handed. A URL, never a token. */
|
|
53
|
+
export const MEMORY_DASHBOARD_URL_ENV = "COCKPIT_DASHBOARD_URL";
|
|
54
|
+
/** Claude Code hook events this installer registers, in the order it writes them. */
|
|
55
|
+
export const MEMORY_HOOK_EVENTS = [
|
|
56
|
+
"SessionStart",
|
|
57
|
+
"UserPromptSubmit",
|
|
58
|
+
"Stop",
|
|
59
|
+
];
|
|
60
|
+
/**
|
|
61
|
+
* The bin's own hook subcommands. `session-start` recalls at session open,
|
|
62
|
+
* `prompt` recalls per turn, `stop` saves the turn — the same three jobs the
|
|
63
|
+
* Supermemory plugin's `session-start.js` / `recall-directive.js` / `capture.js`
|
|
64
|
+
* did, collapsed onto one bin.
|
|
65
|
+
*/
|
|
66
|
+
export const MEMORY_HOOK_SUBCOMMAND = {
|
|
67
|
+
SessionStart: "hook session-start",
|
|
68
|
+
UserPromptSubmit: "hook prompt",
|
|
69
|
+
Stop: "hook stop",
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Injection hooks must stay fast — Claude Code discards a hook that outlives
|
|
73
|
+
* its timeout, and a recall that arrives late is a recall that did not happen.
|
|
74
|
+
* These mirror the vendor plugin's own budgets (30 s / 5 s / 30 s).
|
|
75
|
+
*/
|
|
76
|
+
export const MEMORY_HOOK_TIMEOUT_SECONDS = {
|
|
77
|
+
SessionStart: 30,
|
|
78
|
+
UserPromptSubmit: 5,
|
|
79
|
+
Stop: 30,
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Auto-approved tools. READ ONLY, deliberately: the vendor's
|
|
83
|
+
* `recall-approve.js` allowed its read set and left every write prompting, and
|
|
84
|
+
* a memory the agent can silently overwrite is worse than one it has to ask
|
|
85
|
+
* about. `save_memory` / `update_memory` / `forget_memory` are absent on purpose.
|
|
86
|
+
*/
|
|
87
|
+
export const MEMORY_AUTO_APPROVE_TOOLS = [
|
|
88
|
+
`mcp__${MEMORY_MCP_SERVER_ID}__search_memory`,
|
|
89
|
+
];
|
|
90
|
+
/** Characters that would let a resolved path change the meaning of a hook command string. */
|
|
91
|
+
const UNSAFE_PATH_CHARACTERS = /["'`$;&|<>\r\n]/u;
|
|
92
|
+
export function isUnsafeBinPath(binPath) {
|
|
93
|
+
return UNSAFE_PATH_CHARACTERS.test(binPath);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A Claude Code hook `command` is a shell string by the platform's design, so
|
|
97
|
+
* the only defence is quoting — and a path that could not be quoted safely is
|
|
98
|
+
* refused upstream (see `resolveMemoryMcpBin`) rather than escaped cleverly.
|
|
99
|
+
*/
|
|
100
|
+
export function shellQuoteBinPath(binPath) {
|
|
101
|
+
return /\s/u.test(binPath) ? `"${binPath}"` : binPath;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Windows cannot spawn an npm `.cmd` shim directly — Node refuses it without a
|
|
105
|
+
* shell, which is why `commands/editor.ts` REFUSES a `.cmd` editor rather than
|
|
106
|
+
* running it through cmd.exe with a person's path interpolated in. Here the
|
|
107
|
+
* path is ours, not a person's, and both hosts spawn the MCP server themselves
|
|
108
|
+
* with an argument array, so `cmd /c <shim>` is the correct and safe shape: the
|
|
109
|
+
* arguments never become one string we built.
|
|
110
|
+
*/
|
|
111
|
+
export function memoryMcpServerEntry(options) {
|
|
112
|
+
const env = options.dashboardUrl
|
|
113
|
+
? { [MEMORY_DASHBOARD_URL_ENV]: options.dashboardUrl }
|
|
114
|
+
: {};
|
|
115
|
+
if (options.platform === "win32") {
|
|
116
|
+
return { command: "cmd", args: ["/c", options.binPath], env };
|
|
117
|
+
}
|
|
118
|
+
return { command: options.binPath, args: [], env };
|
|
119
|
+
}
|
|
120
|
+
export function builtinMemoryInstallConfig(options) {
|
|
121
|
+
const quoted = shellQuoteBinPath(options.binPath);
|
|
122
|
+
return {
|
|
123
|
+
server_id: MEMORY_MCP_SERVER_ID,
|
|
124
|
+
mcp_server: memoryMcpServerEntry(options),
|
|
125
|
+
hooks: MEMORY_HOOK_EVENTS.map((event) => ({
|
|
126
|
+
event,
|
|
127
|
+
command: `${quoted} ${MEMORY_HOOK_SUBCOMMAND[event]}`,
|
|
128
|
+
timeout_seconds: MEMORY_HOOK_TIMEOUT_SECONDS[event],
|
|
129
|
+
})),
|
|
130
|
+
permissions_allow: [...MEMORY_AUTO_APPROVE_TOOLS],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Recognises a hook entry as OURS, wherever the bin ended up and however it was
|
|
135
|
+
* quoted. Identity is the bin name followed by `hook`, so an absolute path, a
|
|
136
|
+
* `.cmd` shim and a bare name all match — and somebody else's hook never does.
|
|
137
|
+
* This is what makes a re-run replace our three entries instead of appending a
|
|
138
|
+
* fourth copy.
|
|
139
|
+
*/
|
|
140
|
+
export function isMemoryHookCommand(command) {
|
|
141
|
+
return new RegExp(`${escapeRegExp(MEMORY_MCP_BIN)}(?:\\.cmd|\\.exe|\\.bat)?["']?\\s+hook\\b`, "u").test(command);
|
|
142
|
+
}
|
|
143
|
+
function escapeRegExp(value) {
|
|
144
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Parses what `bli-memory-mcp --print-config --claude` printed. Anything that
|
|
148
|
+
* is not the documented shape returns null and the caller falls back to the
|
|
149
|
+
* built-in template with a named reason — a half-understood config is worse
|
|
150
|
+
* than the one we can write ourselves.
|
|
151
|
+
*/
|
|
152
|
+
export function parsePrintedMemoryInstallConfig(stdout) {
|
|
153
|
+
let parsed;
|
|
154
|
+
try {
|
|
155
|
+
parsed = JSON.parse(stdout.trim());
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
if (!parsed || typeof parsed !== "object")
|
|
161
|
+
return null;
|
|
162
|
+
const record = parsed;
|
|
163
|
+
const server = record["mcp_server"];
|
|
164
|
+
if (!server || typeof server !== "object")
|
|
165
|
+
return null;
|
|
166
|
+
const serverRecord = server;
|
|
167
|
+
const command = serverRecord["command"];
|
|
168
|
+
if (typeof command !== "string" || !command.trim())
|
|
169
|
+
return null;
|
|
170
|
+
const args = normalizeStringArray(serverRecord["args"]);
|
|
171
|
+
const env = normalizeStringRecord(serverRecord["env"]);
|
|
172
|
+
if (args === null || env === null)
|
|
173
|
+
return null;
|
|
174
|
+
const rawHooks = record["hooks"];
|
|
175
|
+
if (!Array.isArray(rawHooks))
|
|
176
|
+
return null;
|
|
177
|
+
const hooks = [];
|
|
178
|
+
for (const rawHook of rawHooks) {
|
|
179
|
+
if (!rawHook || typeof rawHook !== "object")
|
|
180
|
+
return null;
|
|
181
|
+
const hookRecord = rawHook;
|
|
182
|
+
const event = hookRecord["event"];
|
|
183
|
+
const hookCommand = hookRecord["command"];
|
|
184
|
+
if (typeof event !== "string" || typeof hookCommand !== "string")
|
|
185
|
+
return null;
|
|
186
|
+
if (!MEMORY_HOOK_EVENTS.includes(event))
|
|
187
|
+
return null;
|
|
188
|
+
const timeout = hookRecord["timeout_seconds"];
|
|
189
|
+
hooks.push({
|
|
190
|
+
event: event,
|
|
191
|
+
command: hookCommand,
|
|
192
|
+
timeout_seconds: typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0
|
|
193
|
+
? timeout
|
|
194
|
+
: MEMORY_HOOK_TIMEOUT_SECONDS[event],
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (hooks.length === 0)
|
|
198
|
+
return null;
|
|
199
|
+
const allow = normalizeStringArray(record["permissions_allow"]);
|
|
200
|
+
const serverId = record["server_id"];
|
|
201
|
+
return {
|
|
202
|
+
server_id: typeof serverId === "string" && serverId.trim()
|
|
203
|
+
? serverId.trim()
|
|
204
|
+
: MEMORY_MCP_SERVER_ID,
|
|
205
|
+
mcp_server: { command, args, env },
|
|
206
|
+
hooks,
|
|
207
|
+
permissions_allow: allow ?? [...MEMORY_AUTO_APPROVE_TOOLS],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function normalizeStringArray(value) {
|
|
211
|
+
if (value === undefined)
|
|
212
|
+
return [];
|
|
213
|
+
if (!Array.isArray(value))
|
|
214
|
+
return null;
|
|
215
|
+
if (!value.every((entry) => typeof entry === "string"))
|
|
216
|
+
return null;
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
function normalizeStringRecord(value) {
|
|
220
|
+
if (value === undefined)
|
|
221
|
+
return {};
|
|
222
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
223
|
+
return null;
|
|
224
|
+
const out = {};
|
|
225
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
226
|
+
if (typeof entry !== "string")
|
|
227
|
+
return null;
|
|
228
|
+
out[key] = entry;
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one filesystem seam `cockpit memory install` writes through.
|
|
3
|
+
*
|
|
4
|
+
* It exists for two reasons. The first is production: every file this installer
|
|
5
|
+
* touches belongs to a host that may be RUNNING while we write — Claude Code
|
|
6
|
+
* rewrites `~/.claude.json` on its own schedule — so the write is staged beside
|
|
7
|
+
* the target and renamed over it, which is the closest thing to atomic either
|
|
8
|
+
* host family offers. A reader never sees a half-written config.
|
|
9
|
+
*
|
|
10
|
+
* The second is proof. The read-back check (BLI-2541) is only worth something if
|
|
11
|
+
* a test can make the stored bytes differ from the written ones; injecting this
|
|
12
|
+
* interface is how `memory-install.test.ts` proves `read_back_mismatch` fires
|
|
13
|
+
* instead of asserting that it would.
|
|
14
|
+
*/
|
|
15
|
+
import { chmod, mkdir, readFile, rename, stat, unlink, writeFile, } from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { randomBytes } from "node:crypto";
|
|
18
|
+
import { describeError, isMissingFileFailure } from "../health-detail.js";
|
|
19
|
+
export function defaultMemoryFileIo() {
|
|
20
|
+
return {
|
|
21
|
+
async readText(file) {
|
|
22
|
+
try {
|
|
23
|
+
return await readFile(file, "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (isMissingFileFailure(error))
|
|
27
|
+
return null;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
async writeText(file, contents) {
|
|
32
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
33
|
+
const staged = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
|
34
|
+
// A staged file is created with the process umask and the rename carries
|
|
35
|
+
// that mode onto the target — so a `~/.claude.json` or `~/.codex/config.toml`
|
|
36
|
+
// the person keeps at 0600 would come out 0644, quietly widened by an
|
|
37
|
+
// installer that was only meant to add one key. Carry the existing mode
|
|
38
|
+
// across; a file that does not exist yet keeps the umask default.
|
|
39
|
+
const existingMode = await stat(file)
|
|
40
|
+
.then((info) => info.mode & 0o777)
|
|
41
|
+
.catch(() => null);
|
|
42
|
+
try {
|
|
43
|
+
await writeFile(staged, contents, "utf8");
|
|
44
|
+
if (existingMode !== null)
|
|
45
|
+
await chmod(staged, existingMode);
|
|
46
|
+
await rename(staged, file);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
// A rename that will not land (a locked destination on Windows, a
|
|
50
|
+
// cross-device staging directory) must not lose the write — and it must
|
|
51
|
+
// say which of the two happened, because "install failed" on a config
|
|
52
|
+
// file is exactly the silent failure the fleet contract forbids.
|
|
53
|
+
console.error("[memory-install] staged write could not be renamed; writing in place", JSON.stringify({
|
|
54
|
+
reason: "staged_rename_failed",
|
|
55
|
+
byte_size: contents.length,
|
|
56
|
+
...describeError(error),
|
|
57
|
+
}));
|
|
58
|
+
await unlink(staged).catch(() => undefined);
|
|
59
|
+
await writeFile(file, contents, "utf8");
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Codex skill `cockpit memory install` writes to
|
|
3
|
+
* `~/.codex/skills/bli-memory/` (BLI-3580).
|
|
4
|
+
*
|
|
5
|
+
* It replaces the role of the four vendor skills a Codex machine carries today
|
|
6
|
+
* (`supermemory-search`, `-save`, `-forget`, `-login`): one discoverable skill
|
|
7
|
+
* directory with `SKILL.md` as the index and one reference per operation —
|
|
8
|
+
* search, save, update, forget. There is no login reference because there is no
|
|
9
|
+
* fourth credential: the MCP server authenticates with the collector's own
|
|
10
|
+
* device token, the one `cockpit login` already put on the machine.
|
|
11
|
+
*
|
|
12
|
+
* Content is static prose. It is written whole and compared whole, so an
|
|
13
|
+
* install is idempotent to the byte and a drifted copy is replaced rather than
|
|
14
|
+
* merged — the same deal `cockpit agent-rules` offers for its managed block.
|
|
15
|
+
*/
|
|
16
|
+
/** Relative path → exact file contents. The map IS the install. */
|
|
17
|
+
export function memoryCodexSkillFiles() {
|
|
18
|
+
return {
|
|
19
|
+
"SKILL.md": SKILL_MD,
|
|
20
|
+
"references/search.md": SEARCH_MD,
|
|
21
|
+
"references/save.md": SAVE_MD,
|
|
22
|
+
"references/update.md": UPDATE_MD,
|
|
23
|
+
"references/forget.md": FORGET_MD,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const SKILL_MD = `---
|
|
27
|
+
name: bli-memory
|
|
28
|
+
description: BLI Memory — durable memory for this machine. Use when you need to recall what was decided before, or when a session produced a durable decision, preference or correction worth keeping.
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
# BLI Memory
|
|
32
|
+
|
|
33
|
+
Durable memory lives in BLI's own store, reached through the \`bli-memory\` MCP
|
|
34
|
+
server. Four tools, and nothing else:
|
|
35
|
+
|
|
36
|
+
| Tool | When |
|
|
37
|
+
|---|---|
|
|
38
|
+
| \`search_memory\` | before answering anything that depends on earlier work |
|
|
39
|
+
| \`save_memory\` | a durable decision, preference, correction or constraint appeared |
|
|
40
|
+
| \`update_memory\` | a saved memory is now wrong and has a successor |
|
|
41
|
+
| \`forget_memory\` | a saved memory should never surface again |
|
|
42
|
+
|
|
43
|
+
Rules that do not bend:
|
|
44
|
+
|
|
45
|
+
- **Recall before you assert.** An empty result means the record is silent, not
|
|
46
|
+
that nothing happened. Say which one you are looking at.
|
|
47
|
+
- **Save the decision, not the transcript.** The nightly rollups already
|
|
48
|
+
distil sessions; this store is for the durable line a person would repeat.
|
|
49
|
+
- **Never fuzzy-match when forgetting.** Exact id, or exact text inside one
|
|
50
|
+
container. Deleting the wrong memory is worse than keeping a stale one.
|
|
51
|
+
- **Say what you did.** A save that produced an id says so; a search that
|
|
52
|
+
answered from memory credits it.
|
|
53
|
+
|
|
54
|
+
References: \`references/search.md\`, \`references/save.md\`,
|
|
55
|
+
\`references/update.md\`, \`references/forget.md\`.
|
|
56
|
+
|
|
57
|
+
Installed and kept current by \`cockpit memory install\`. Do not edit by hand —
|
|
58
|
+
the next sync tick replaces it.
|
|
59
|
+
`;
|
|
60
|
+
const SEARCH_MD = `# search_memory
|
|
61
|
+
|
|
62
|
+
\`\`\`
|
|
63
|
+
in : { query: string (1-1000, required),
|
|
64
|
+
containerTag?: string, // omitted -> this machine's default container
|
|
65
|
+
limit?: 1-20 = 5,
|
|
66
|
+
includeProfile?: boolean = true }
|
|
67
|
+
\`\`\`
|
|
68
|
+
|
|
69
|
+
Returns relevance-ranked memories plus, when \`includeProfile\`, the container's
|
|
70
|
+
most recent context. Rows that were forgotten or superseded are unreachable by
|
|
71
|
+
construction and never come back.
|
|
72
|
+
|
|
73
|
+
An empty answer is a finding: report "the record is silent on this", never
|
|
74
|
+
"there is nothing".
|
|
75
|
+
`;
|
|
76
|
+
const SAVE_MD = `# save_memory
|
|
77
|
+
|
|
78
|
+
\`\`\`
|
|
79
|
+
in : { content: string (1-200000, required),
|
|
80
|
+
containerTag?: string,
|
|
81
|
+
customId?: string, // e.g. the session id — the upsert key
|
|
82
|
+
metadata?: object }
|
|
83
|
+
out: { id, containerTag, created|updated, chars }
|
|
84
|
+
\`\`\`
|
|
85
|
+
|
|
86
|
+
Stores what it is given; it does not summarise. Pass \`customId\` when a session
|
|
87
|
+
may save more than once, so the second save updates the first row instead of
|
|
88
|
+
adding a duplicate.
|
|
89
|
+
|
|
90
|
+
Save the durable thing: a decision and its reason, a preference, a correction, a
|
|
91
|
+
constraint discovered the hard way. Do not save the whole transcript.
|
|
92
|
+
`;
|
|
93
|
+
const UPDATE_MD = `# update_memory
|
|
94
|
+
|
|
95
|
+
\`\`\`
|
|
96
|
+
in : { id: uuid (required), content: string (required), metadata?: object }
|
|
97
|
+
out: { newId, supersededId, containerTag, reEmbedded: true }
|
|
98
|
+
\`\`\`
|
|
99
|
+
|
|
100
|
+
An update is an insert plus a link: the new text becomes a new row, the old one
|
|
101
|
+
is marked superseded and stops being reachable from recall while staying
|
|
102
|
+
readable for audit.
|
|
103
|
+
|
|
104
|
+
It refuses — loudly, never silently — when the id is unknown, already forgotten,
|
|
105
|
+
or already superseded. The last case names the successor so you can retry
|
|
106
|
+
against it.
|
|
107
|
+
`;
|
|
108
|
+
const FORGET_MD = `# forget_memory
|
|
109
|
+
|
|
110
|
+
\`\`\`
|
|
111
|
+
in : { id?: uuid, content?: string, containerTag?: string, reason?: string }
|
|
112
|
+
exactly one of id | content; containerTag is REQUIRED with content
|
|
113
|
+
out: { forgotten: [{ id, containerTag, textPreview, created_at }], count }
|
|
114
|
+
\`\`\`
|
|
115
|
+
|
|
116
|
+
Soft delete: the row is marked forgotten and never surfaces through recall
|
|
117
|
+
again. Matching by content is exact after whitespace normalisation — never
|
|
118
|
+
fuzzy, and never widened to every container.
|
|
119
|
+
|
|
120
|
+
It returns what it removed. A bare "done" is not an answer.
|
|
121
|
+
`;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The smallest TOML surface that can put ONE table into `~/.codex/config.toml`
|
|
3
|
+
* and read it back — and nothing else.
|
|
4
|
+
*
|
|
5
|
+
* This is deliberately not a TOML parser. That file is a person's own Codex
|
|
6
|
+
* configuration: model, approval policy, sandbox, their other MCP servers. The
|
|
7
|
+
* rule is that everything outside `[mcp_servers.bli-memory]` comes out of an
|
|
8
|
+
* install byte-identical, which a parse-and-reserialise round trip cannot
|
|
9
|
+
* promise (it would reorder keys, drop comments and normalise strings). So the
|
|
10
|
+
* writer works on LINE SPANS: find our table's header, find where it ends, swap
|
|
11
|
+
* those lines, leave every other byte alone.
|
|
12
|
+
*
|
|
13
|
+
* The reader is the other half of BLI-2541's rule — verify against what the
|
|
14
|
+
* platform stored, not against what you wrote. It understands exactly the three
|
|
15
|
+
* value forms we emit (a quoted string, a single-line array of strings, a
|
|
16
|
+
* single-line inline table of strings). Anything else in our table reads back as
|
|
17
|
+
* unparsed, which surfaces as `read_back_mismatch` rather than as a shrug.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Finds `[a.b]` (or `["a"."b"]`, or any mix) and the span it owns: everything up
|
|
21
|
+
* to the next table header of any kind, or end of file.
|
|
22
|
+
*/
|
|
23
|
+
export function findTomlTableSpan(text, tablePath) {
|
|
24
|
+
const lines = text.split("\n");
|
|
25
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
26
|
+
const header = parseTableHeader(lines[index] ?? "");
|
|
27
|
+
if (!header || !samePath(header, tablePath))
|
|
28
|
+
continue;
|
|
29
|
+
let end = lines.length;
|
|
30
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
31
|
+
if (parseTableHeader(lines[cursor] ?? "") || isArrayTableHeader(lines[cursor] ?? "")) {
|
|
32
|
+
end = cursor;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { start_line: index, end_line: end };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/** Reads our table's simple key/values back off stored text. Null = no such table. */
|
|
41
|
+
export function readTomlTable(text, tablePath) {
|
|
42
|
+
const span = findTomlTableSpan(text, tablePath);
|
|
43
|
+
if (!span)
|
|
44
|
+
return null;
|
|
45
|
+
const lines = text.split("\n").slice(span.start_line + 1, span.end_line);
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
50
|
+
continue;
|
|
51
|
+
const equals = trimmed.indexOf("=");
|
|
52
|
+
if (equals <= 0)
|
|
53
|
+
continue;
|
|
54
|
+
const key = unquoteKey(trimmed.slice(0, equals).trim());
|
|
55
|
+
if (!key)
|
|
56
|
+
continue;
|
|
57
|
+
// `null` means "present but in a form this reader does not model" — the
|
|
58
|
+
// caller reports a mismatch instead of pretending the field matched.
|
|
59
|
+
out[key] = parseTomlValue(trimmed.slice(equals + 1).trim());
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Replaces our table's lines, or appends the table at the end when it is
|
|
65
|
+
* absent. Everything outside the span is untouched, including trailing
|
|
66
|
+
* whitespace and comments.
|
|
67
|
+
*/
|
|
68
|
+
export function upsertTomlTable(text, tablePath, renderedTable) {
|
|
69
|
+
const block = renderedTable.replace(/\n+$/u, "");
|
|
70
|
+
const span = findTomlTableSpan(text, tablePath);
|
|
71
|
+
if (!span) {
|
|
72
|
+
if (!text.trim())
|
|
73
|
+
return `${block}\n`;
|
|
74
|
+
const separator = text.endsWith("\n") ? "\n" : "\n\n";
|
|
75
|
+
return `${text}${separator}${block}\n`;
|
|
76
|
+
}
|
|
77
|
+
const lines = text.split("\n");
|
|
78
|
+
const before = lines.slice(0, span.start_line);
|
|
79
|
+
const after = lines.slice(span.end_line);
|
|
80
|
+
return [...before, ...block.split("\n"), ...after].join("\n");
|
|
81
|
+
}
|
|
82
|
+
/** Removes our table's lines. Used by `--uninstall`-shaped callers and tests. */
|
|
83
|
+
export function removeTomlTable(text, tablePath) {
|
|
84
|
+
const span = findTomlTableSpan(text, tablePath);
|
|
85
|
+
if (!span)
|
|
86
|
+
return text;
|
|
87
|
+
const lines = text.split("\n");
|
|
88
|
+
return [...lines.slice(0, span.start_line), ...lines.slice(span.end_line)].join("\n");
|
|
89
|
+
}
|
|
90
|
+
export function renderTomlTable(tablePath, entries) {
|
|
91
|
+
const header = `[${tablePath.map(renderKey).join(".")}]`;
|
|
92
|
+
const body = entries.map(([key, value]) => `${renderKey(key)} = ${renderValue(value)}`);
|
|
93
|
+
return [header, ...body].join("\n");
|
|
94
|
+
}
|
|
95
|
+
function renderKey(key) {
|
|
96
|
+
return /^[A-Za-z0-9_-]+$/u.test(key) ? key : `"${key.replace(/"/gu, '\\"')}"`;
|
|
97
|
+
}
|
|
98
|
+
function renderValue(value) {
|
|
99
|
+
if (typeof value === "string")
|
|
100
|
+
return renderString(value);
|
|
101
|
+
if (Array.isArray(value))
|
|
102
|
+
return `[${value.map(renderString).join(", ")}]`;
|
|
103
|
+
const pairs = Object.entries(value).map(([key, entry]) => `${renderKey(key)} = ${renderString(entry)}`);
|
|
104
|
+
return pairs.length === 0 ? "{}" : `{ ${pairs.join(", ")} }`;
|
|
105
|
+
}
|
|
106
|
+
function renderString(value) {
|
|
107
|
+
return `"${value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"')}"`;
|
|
108
|
+
}
|
|
109
|
+
function parseTableHeader(line) {
|
|
110
|
+
const trimmed = line.trim();
|
|
111
|
+
if (!trimmed.startsWith("[") || trimmed.startsWith("[["))
|
|
112
|
+
return null;
|
|
113
|
+
const close = trimmed.indexOf("]");
|
|
114
|
+
if (close < 0)
|
|
115
|
+
return null;
|
|
116
|
+
// Anything after the closing bracket other than a comment is not a header.
|
|
117
|
+
const rest = trimmed.slice(close + 1).trim();
|
|
118
|
+
if (rest && !rest.startsWith("#"))
|
|
119
|
+
return null;
|
|
120
|
+
return splitKeyPath(trimmed.slice(1, close));
|
|
121
|
+
}
|
|
122
|
+
function isArrayTableHeader(line) {
|
|
123
|
+
return line.trim().startsWith("[[");
|
|
124
|
+
}
|
|
125
|
+
function splitKeyPath(raw) {
|
|
126
|
+
const parts = [];
|
|
127
|
+
let current = "";
|
|
128
|
+
let quote = null;
|
|
129
|
+
for (const character of raw) {
|
|
130
|
+
if (quote) {
|
|
131
|
+
if (character === quote)
|
|
132
|
+
quote = null;
|
|
133
|
+
else
|
|
134
|
+
current += character;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (character === '"' || character === "'") {
|
|
138
|
+
quote = character;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (character === ".") {
|
|
142
|
+
parts.push(current.trim());
|
|
143
|
+
current = "";
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
current += character;
|
|
147
|
+
}
|
|
148
|
+
parts.push(current.trim());
|
|
149
|
+
return parts.filter((part) => part.length > 0);
|
|
150
|
+
}
|
|
151
|
+
function samePath(left, right) {
|
|
152
|
+
return left.length === right.length && left.every((part, index) => part === right[index]);
|
|
153
|
+
}
|
|
154
|
+
function unquoteKey(raw) {
|
|
155
|
+
const trimmed = raw.trim();
|
|
156
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) ||
|
|
157
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)) {
|
|
158
|
+
return trimmed.slice(1, -1);
|
|
159
|
+
}
|
|
160
|
+
return trimmed;
|
|
161
|
+
}
|
|
162
|
+
function parseTomlValue(raw) {
|
|
163
|
+
const value = stripTrailingComment(raw).trim();
|
|
164
|
+
if (!value)
|
|
165
|
+
return null;
|
|
166
|
+
if (value.startsWith('"') || value.startsWith("'")) {
|
|
167
|
+
return parseTomlString(value);
|
|
168
|
+
}
|
|
169
|
+
if (value.startsWith("[")) {
|
|
170
|
+
if (!value.endsWith("]"))
|
|
171
|
+
return null;
|
|
172
|
+
const inner = value.slice(1, -1).trim();
|
|
173
|
+
if (!inner)
|
|
174
|
+
return [];
|
|
175
|
+
const items = splitTopLevel(inner);
|
|
176
|
+
const parsed = items.map(parseTomlString);
|
|
177
|
+
return parsed.every((item) => item !== null) ? parsed : null;
|
|
178
|
+
}
|
|
179
|
+
if (value.startsWith("{")) {
|
|
180
|
+
if (!value.endsWith("}"))
|
|
181
|
+
return null;
|
|
182
|
+
const inner = value.slice(1, -1).trim();
|
|
183
|
+
const out = {};
|
|
184
|
+
if (!inner)
|
|
185
|
+
return out;
|
|
186
|
+
for (const pair of splitTopLevel(inner)) {
|
|
187
|
+
const equals = pair.indexOf("=");
|
|
188
|
+
if (equals <= 0)
|
|
189
|
+
return null;
|
|
190
|
+
const key = unquoteKey(pair.slice(0, equals).trim());
|
|
191
|
+
const parsed = parseTomlString(pair.slice(equals + 1).trim());
|
|
192
|
+
if (!key || parsed === null)
|
|
193
|
+
return null;
|
|
194
|
+
out[key] = parsed;
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
function parseTomlString(raw) {
|
|
201
|
+
const value = raw.trim();
|
|
202
|
+
if (value.length < 2)
|
|
203
|
+
return null;
|
|
204
|
+
const quote = value[0];
|
|
205
|
+
if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote)
|
|
206
|
+
return null;
|
|
207
|
+
const inner = value.slice(1, -1);
|
|
208
|
+
if (quote === "'")
|
|
209
|
+
return inner;
|
|
210
|
+
return inner.replace(/\\(["\\nrt])/gu, (_match, escaped) => {
|
|
211
|
+
if (escaped === "n")
|
|
212
|
+
return "\n";
|
|
213
|
+
if (escaped === "r")
|
|
214
|
+
return "\r";
|
|
215
|
+
if (escaped === "t")
|
|
216
|
+
return "\t";
|
|
217
|
+
return escaped;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/** Splits on commas that are not inside a quoted string. */
|
|
221
|
+
function splitTopLevel(raw) {
|
|
222
|
+
const parts = [];
|
|
223
|
+
let current = "";
|
|
224
|
+
let quote = null;
|
|
225
|
+
for (const character of raw) {
|
|
226
|
+
if (quote) {
|
|
227
|
+
current += character;
|
|
228
|
+
if (character === quote)
|
|
229
|
+
quote = null;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (character === '"' || character === "'") {
|
|
233
|
+
quote = character;
|
|
234
|
+
current += character;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (character === ",") {
|
|
238
|
+
parts.push(current.trim());
|
|
239
|
+
current = "";
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
current += character;
|
|
243
|
+
}
|
|
244
|
+
if (current.trim())
|
|
245
|
+
parts.push(current.trim());
|
|
246
|
+
return parts;
|
|
247
|
+
}
|
|
248
|
+
function stripTrailingComment(raw) {
|
|
249
|
+
let quote = null;
|
|
250
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
251
|
+
const character = raw[index];
|
|
252
|
+
if (quote) {
|
|
253
|
+
if (character === quote)
|
|
254
|
+
quote = null;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (character === '"' || character === "'") {
|
|
258
|
+
quote = character;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (character === "#")
|
|
262
|
+
return raw.slice(0, index);
|
|
263
|
+
}
|
|
264
|
+
return raw;
|
|
265
|
+
}
|