wicked-brain 0.12.1 → 0.14.0
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/package.json +4 -3
- package/server/bin/wicked-brain-call.mjs +575 -0
- package/server/bin/wicked-brain-server.mjs +36 -1
- package/server/lib/bus.mjs +86 -0
- package/server/lib/memory-subscriber.mjs +1 -1
- package/server/package.json +4 -3
- package/skills/wicked-brain-agent/SKILL.md +7 -1
- package/skills/wicked-brain-compile/SKILL.md +11 -19
- package/skills/wicked-brain-configure/SKILL.md +12 -16
- package/skills/wicked-brain-confirm/SKILL.md +8 -14
- package/skills/wicked-brain-dlq/SKILL.md +102 -0
- package/skills/wicked-brain-enhance/SKILL.md +7 -14
- package/skills/wicked-brain-forget/SKILL.md +9 -13
- package/skills/wicked-brain-ingest/SKILL.md +16 -25
- package/skills/wicked-brain-init/SKILL.md +20 -17
- package/skills/wicked-brain-lint/SKILL.md +9 -20
- package/skills/wicked-brain-lsp/SKILL.md +22 -28
- package/skills/wicked-brain-memory/SKILL.md +9 -13
- package/skills/wicked-brain-migrate/SKILL.md +10 -14
- package/skills/wicked-brain-query/SKILL.md +8 -19
- package/skills/wicked-brain-review/SKILL.md +10 -16
- package/skills/wicked-brain-search/SKILL.md +12 -19
- package/skills/wicked-brain-server/SKILL.md +43 -65
- package/skills/wicked-brain-status/SKILL.md +13 -32
- package/skills/wicked-brain-synonyms/SKILL.md +6 -15
- package/skills/wicked-brain-ui/SKILL.md +16 -10
- package/skills/wicked-brain-update/SKILL.md +1 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-brain",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Digital brain as skills for AI coding CLIs — no vector DB, no embeddings, no infrastructure",
|
|
6
6
|
"keywords": [
|
|
@@ -28,11 +28,12 @@
|
|
|
28
28
|
"bugs": "https://github.com/mikeparcewski/wicked-brain/issues",
|
|
29
29
|
"bin": {
|
|
30
30
|
"wicked-brain": "./install.mjs",
|
|
31
|
-
"wicked-brain-server": "./server/bin/wicked-brain-server.mjs"
|
|
31
|
+
"wicked-brain-server": "./server/bin/wicked-brain-server.mjs",
|
|
32
|
+
"wicked-brain-call": "./server/bin/wicked-brain-call.mjs"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"better-sqlite3": "^12.0.0",
|
|
35
|
-
"wicked-bus": "^
|
|
36
|
+
"wicked-bus": "^2.0.0"
|
|
36
37
|
},
|
|
37
38
|
"files": [
|
|
38
39
|
"install.mjs",
|
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// wicked-brain-call — thin CLI wrapper around the wicked-brain HTTP API.
|
|
3
|
+
// Auto-starts the server (lock-guarded, detached) when no live process answers
|
|
4
|
+
// the configured port, then forwards a single action call and prints the
|
|
5
|
+
// JSON response. Skills can drop the "is the server up?" dance and just shell
|
|
6
|
+
// out to this binary.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
readFileSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { join, resolve, basename } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
20
|
+
|
|
21
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
22
|
+
const SERVER_BIN = join(__dirname, "wicked-brain-server.mjs");
|
|
23
|
+
|
|
24
|
+
// ---------- arg parsing ----------
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
const out = { flags: {}, params: {}, positional: [] };
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
switch (a) {
|
|
31
|
+
case "--help":
|
|
32
|
+
case "-h": out.flags.help = true; break;
|
|
33
|
+
case "--version":
|
|
34
|
+
case "-v": out.flags.version = true; break;
|
|
35
|
+
case "--pretty": out.flags.pretty = true; break;
|
|
36
|
+
case "--no-spawn": out.flags.noSpawn = true; break;
|
|
37
|
+
case "--no-audit": out.flags.noAudit = true; break;
|
|
38
|
+
case "--start": out.flags.start = true; break;
|
|
39
|
+
case "--stop": out.flags.stop = true; break;
|
|
40
|
+
case "--status": out.flags.status = true; break;
|
|
41
|
+
case "--brain":
|
|
42
|
+
case "-b": out.flags.brain = argv[++i]; break;
|
|
43
|
+
case "--port":
|
|
44
|
+
case "-p": out.flags.port = parseInt(argv[++i], 10); break;
|
|
45
|
+
case "--source": out.flags.source = argv[++i]; break;
|
|
46
|
+
case "--spawn-timeout": out.flags.spawnTimeoutMs = parseInt(argv[++i], 10); break;
|
|
47
|
+
case "--param": {
|
|
48
|
+
const kv = argv[++i] || "";
|
|
49
|
+
const idx = kv.indexOf("=");
|
|
50
|
+
if (idx === -1) die(`--param requires key=value, got: ${kv}`);
|
|
51
|
+
out.params[kv.slice(0, idx)] = coerce(kv.slice(idx + 1));
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
default:
|
|
55
|
+
if (a.startsWith("--")) die(`Unknown flag: ${a}`);
|
|
56
|
+
out.positional.push(a);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Coerce --param values: numbers, booleans, JSON. Plain strings stay strings.
|
|
63
|
+
// Skills can pass primitives without quoting; complex shapes go via positional
|
|
64
|
+
// JSON or stdin.
|
|
65
|
+
function coerce(s) {
|
|
66
|
+
if (s === "true") return true;
|
|
67
|
+
if (s === "false") return false;
|
|
68
|
+
if (s === "null") return null;
|
|
69
|
+
if (/^-?\d+$/.test(s)) return parseInt(s, 10);
|
|
70
|
+
if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s);
|
|
71
|
+
if (s.startsWith("{") || s.startsWith("[")) {
|
|
72
|
+
try { return JSON.parse(s); } catch { /* fall through to string */ }
|
|
73
|
+
}
|
|
74
|
+
return s;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------- brain path discovery ----------
|
|
78
|
+
// Mirrors the resolution skills use: explicit flag → env → per-project →
|
|
79
|
+
// legacy flat. Returning the per-project path even when nothing exists yet
|
|
80
|
+
// lets `--start` create the directory cleanly.
|
|
81
|
+
|
|
82
|
+
function resolveBrainPath(explicit) {
|
|
83
|
+
if (explicit) return resolve(explicit);
|
|
84
|
+
if (process.env.WICKED_BRAIN_PATH) return resolve(process.env.WICKED_BRAIN_PATH);
|
|
85
|
+
const cwdBase = basename(process.cwd());
|
|
86
|
+
const perProject = join(homedir(), ".wicked-brain", "projects", cwdBase);
|
|
87
|
+
if (
|
|
88
|
+
existsSync(join(perProject, "_meta", "config.json")) ||
|
|
89
|
+
existsSync(join(perProject, "brain.json"))
|
|
90
|
+
) {
|
|
91
|
+
return perProject;
|
|
92
|
+
}
|
|
93
|
+
const flat = join(homedir(), ".wicked-brain");
|
|
94
|
+
if (existsSync(join(flat, "_meta", "config.json"))) return flat;
|
|
95
|
+
return perProject;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readMetaConfig(brainPath) {
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(readFileSync(join(brainPath, "_meta", "config.json"), "utf-8"));
|
|
101
|
+
} catch {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------- HTTP ----------
|
|
107
|
+
|
|
108
|
+
async function callApi(port, action, params, { timeoutMs = 30000, auditFile } = {}) {
|
|
109
|
+
const ctrl = new AbortController();
|
|
110
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
111
|
+
const headers = { "Content-Type": "application/json" };
|
|
112
|
+
if (auditFile) headers["x-wicked-audit-file"] = auditFile;
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetch(`http://127.0.0.1:${port}/api`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers,
|
|
117
|
+
body: JSON.stringify({ action, params: params || {} }),
|
|
118
|
+
signal: ctrl.signal,
|
|
119
|
+
});
|
|
120
|
+
return await res.json();
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function healthCheck(port, { timeoutMs = 800 } = {}) {
|
|
127
|
+
try {
|
|
128
|
+
const r = await callApi(port, "health", {}, { timeoutMs });
|
|
129
|
+
return !!(r && r.status === "ok");
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------- spawn lock ----------
|
|
136
|
+
// Cross-platform exclusive-create lock via { flag: "wx" }. Stale entries
|
|
137
|
+
// (older than STALE_LOCK_MS) are reaped on contention so a crashed CLI
|
|
138
|
+
// doesn't permanently block future spawns.
|
|
139
|
+
|
|
140
|
+
const STALE_LOCK_MS = 30_000;
|
|
141
|
+
|
|
142
|
+
function tryLock(lockPath) {
|
|
143
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
144
|
+
try {
|
|
145
|
+
writeFileSync(
|
|
146
|
+
lockPath,
|
|
147
|
+
JSON.stringify({ pid: process.pid, t: Date.now() }),
|
|
148
|
+
{ flag: "wx" },
|
|
149
|
+
);
|
|
150
|
+
return true;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (err.code !== "EEXIST") throw err;
|
|
153
|
+
if (attempt === 0) {
|
|
154
|
+
let stale = false;
|
|
155
|
+
try {
|
|
156
|
+
const lock = JSON.parse(readFileSync(lockPath, "utf-8"));
|
|
157
|
+
stale = !lock?.t || Date.now() - lock.t > STALE_LOCK_MS;
|
|
158
|
+
} catch {
|
|
159
|
+
stale = true;
|
|
160
|
+
}
|
|
161
|
+
if (stale) {
|
|
162
|
+
try { unlinkSync(lockPath); } catch {}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function releaseLock(lockPath) {
|
|
173
|
+
try { unlinkSync(lockPath); } catch {}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function pidAlive(pid) {
|
|
177
|
+
if (!pid || Number.isNaN(pid)) return false;
|
|
178
|
+
try {
|
|
179
|
+
process.kill(pid, 0);
|
|
180
|
+
return true;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
// EPERM = exists but we lack permission to signal — still alive.
|
|
183
|
+
return err.code === "EPERM";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------- server lifecycle ----------
|
|
188
|
+
|
|
189
|
+
async function ensureServer(brainPath, opts) {
|
|
190
|
+
const { explicitPort, sourceOverride, noSpawn, log, spawnTimeoutMs = 10_000 } = opts;
|
|
191
|
+
const meta = readMetaConfig(brainPath);
|
|
192
|
+
const port = explicitPort || meta.server_port || 4242;
|
|
193
|
+
|
|
194
|
+
if (await healthCheck(port)) return port;
|
|
195
|
+
if (noSpawn) {
|
|
196
|
+
throw new Error(`server not reachable on port ${port} and --no-spawn was set`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
mkdirSync(join(brainPath, "_meta"), { recursive: true });
|
|
200
|
+
const lockPath = join(brainPath, "_meta", "spawn.lock");
|
|
201
|
+
|
|
202
|
+
if (!tryLock(lockPath)) {
|
|
203
|
+
log(`another process is starting the server; waiting...`);
|
|
204
|
+
if (await waitForHealth(port, spawnTimeoutMs)) return port;
|
|
205
|
+
throw new Error(`concurrent spawn timed out on port ${port}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
// Re-check after acquiring the lock — another process might have started
|
|
210
|
+
// and finished while we were contending.
|
|
211
|
+
if (await healthCheck(port)) return port;
|
|
212
|
+
|
|
213
|
+
const pidPath = join(brainPath, "_meta", "server.pid");
|
|
214
|
+
if (existsSync(pidPath)) {
|
|
215
|
+
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
216
|
+
if (pidAlive(pid)) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`server PID ${pid} is alive but not answering health on port ${port}. ` +
|
|
219
|
+
`Stop it manually or remove ${pidPath}.`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
try { unlinkSync(pidPath); } catch {}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
log(`starting wicked-brain-server (brain=${brainPath} port=${port})`);
|
|
226
|
+
const sourcePath = sourceOverride || meta.source_path;
|
|
227
|
+
const argv = [SERVER_BIN, "--brain", brainPath, "--port", String(port)];
|
|
228
|
+
if (sourcePath) argv.push("--source", sourcePath);
|
|
229
|
+
|
|
230
|
+
const child = spawn(process.execPath, argv, {
|
|
231
|
+
detached: true,
|
|
232
|
+
stdio: "ignore",
|
|
233
|
+
windowsHide: true,
|
|
234
|
+
});
|
|
235
|
+
child.unref();
|
|
236
|
+
|
|
237
|
+
if (await waitForHealth(port, spawnTimeoutMs)) return port;
|
|
238
|
+
throw new Error(`server did not become ready within ${spawnTimeoutMs}ms on port ${port}`);
|
|
239
|
+
} finally {
|
|
240
|
+
releaseLock(lockPath);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function waitForHealth(port, timeoutMs) {
|
|
245
|
+
const deadline = Date.now() + timeoutMs;
|
|
246
|
+
while (Date.now() < deadline) {
|
|
247
|
+
if (await healthCheck(port, { timeoutMs: 500 })) return true;
|
|
248
|
+
await sleep(150);
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function sleep(ms) {
|
|
254
|
+
return new Promise(r => setTimeout(r, ms));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------- audit log ----------
|
|
258
|
+
// Every action call gets a markdown breadcrumb at
|
|
259
|
+
// {brain}/calls/YYYY-MM-DD/HHMMSS-<action>-<id>.md
|
|
260
|
+
// The file is opened with the request body and finalized after the response
|
|
261
|
+
// returns (or after a failure) so a partial record still exists if the CLI
|
|
262
|
+
// crashes mid-call. Lifecycle commands (--start/--stop/--status) are NOT
|
|
263
|
+
// audited — they're operator commands, not data-plane traffic.
|
|
264
|
+
//
|
|
265
|
+
// Disabled with WICKED_BRAIN_AUDIT=0 or --no-audit.
|
|
266
|
+
|
|
267
|
+
function isoStamp(d = new Date()) {
|
|
268
|
+
return d.toISOString();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function auditPaths(brainPath, action) {
|
|
272
|
+
const now = new Date();
|
|
273
|
+
const datePart = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
274
|
+
const timePart = now.toISOString().slice(11, 19).replace(/:/g, ""); // HHMMSS
|
|
275
|
+
const id = randomBytes(3).toString("hex");
|
|
276
|
+
const safeAction = String(action || "unknown").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40);
|
|
277
|
+
const dir = join(brainPath, "calls", datePart);
|
|
278
|
+
const file = join(dir, `${timePart}-${safeAction}-${id}.md`);
|
|
279
|
+
return { dir, file, id, ts: now.toISOString() };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function fmtFrontmatter(obj) {
|
|
283
|
+
const lines = ["---"];
|
|
284
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
285
|
+
if (v === undefined) continue;
|
|
286
|
+
if (v === null) { lines.push(`${k}: null`); continue; }
|
|
287
|
+
if (typeof v === "string") { lines.push(`${k}: "${v.replace(/"/g, '\\"')}"`); continue; }
|
|
288
|
+
lines.push(`${k}: ${v}`);
|
|
289
|
+
}
|
|
290
|
+
lines.push("---");
|
|
291
|
+
return lines.join("\n");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function fmtJsonBlock(label, value) {
|
|
295
|
+
return `## ${label}\n\n\`\`\`json\n${JSON.stringify(value ?? null, null, 2)}\n\`\`\``;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function writeAuditOpen(brainPath, action, params, port) {
|
|
299
|
+
try {
|
|
300
|
+
const a = auditPaths(brainPath, action);
|
|
301
|
+
mkdirSync(a.dir, { recursive: true });
|
|
302
|
+
const front = fmtFrontmatter({
|
|
303
|
+
action,
|
|
304
|
+
call_id: a.id,
|
|
305
|
+
timestamp: a.ts,
|
|
306
|
+
brain_path: brainPath,
|
|
307
|
+
port,
|
|
308
|
+
cwd: process.cwd(),
|
|
309
|
+
pid: process.pid,
|
|
310
|
+
status: "in_progress",
|
|
311
|
+
});
|
|
312
|
+
const body = [
|
|
313
|
+
front,
|
|
314
|
+
"",
|
|
315
|
+
`# wicked-brain-call \`${action}\``,
|
|
316
|
+
"",
|
|
317
|
+
fmtJsonBlock("Request params", params),
|
|
318
|
+
"",
|
|
319
|
+
].join("\n");
|
|
320
|
+
writeFileSync(a.file, body, "utf-8");
|
|
321
|
+
return a;
|
|
322
|
+
} catch {
|
|
323
|
+
// Audit is best-effort. Never fail the call because we couldn't write a record.
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function writeAuditClose(audit, { exitCode, durationMs, response, error }) {
|
|
329
|
+
if (!audit) return;
|
|
330
|
+
try {
|
|
331
|
+
const head = readFileSync(audit.file, "utf-8");
|
|
332
|
+
const closing = fmtFrontmatter({
|
|
333
|
+
finalized_at: isoStamp(),
|
|
334
|
+
exit_code: exitCode,
|
|
335
|
+
duration_ms: durationMs,
|
|
336
|
+
status: error ? "error" : "ok",
|
|
337
|
+
});
|
|
338
|
+
const responseSection = response !== undefined
|
|
339
|
+
? fmtJsonBlock("Response", response)
|
|
340
|
+
: "";
|
|
341
|
+
const errorSection = error
|
|
342
|
+
? `## Error\n\n\`\`\`\n${String(error)}\n\`\`\``
|
|
343
|
+
: "";
|
|
344
|
+
const tail = [closing, "", responseSection, errorSection]
|
|
345
|
+
.filter(Boolean)
|
|
346
|
+
.join("\n\n");
|
|
347
|
+
writeFileSync(audit.file, `${head}\n${tail}\n`, "utf-8");
|
|
348
|
+
} catch {
|
|
349
|
+
// Best-effort.
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function auditEnabled(flagOff) {
|
|
354
|
+
if (flagOff) return false;
|
|
355
|
+
if (process.env.WICKED_BRAIN_AUDIT === "0") return false;
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------- stdin ----------
|
|
360
|
+
// Stdin is only read when the caller writes `-` as the payload positional —
|
|
361
|
+
// matches `kubectl apply -f -`. The implicit "no payload? try stdin" pattern
|
|
362
|
+
// hangs whenever a parent forgets to close the child's stdin pipe (common in
|
|
363
|
+
// supervisors, CI runners, the node spawn() default). Explicit opt-in keeps
|
|
364
|
+
// the wrapper safe to drop into any execution context.
|
|
365
|
+
|
|
366
|
+
async function readStdin() {
|
|
367
|
+
const chunks = [];
|
|
368
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
369
|
+
const buf = Buffer.concat(chunks).toString("utf-8").trim();
|
|
370
|
+
return buf || null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---------- main ----------
|
|
374
|
+
// Exits go through process.exitCode + a sentinel throw, NOT process.exit().
|
|
375
|
+
// On Windows, calling process.exit() after a fetch() resolves can fire
|
|
376
|
+
// `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)` in libuv async.c
|
|
377
|
+
// because undici handles are still mid-close. Setting exitCode + letting the
|
|
378
|
+
// IIFE return lets the event loop drain naturally before exit.
|
|
379
|
+
|
|
380
|
+
class ExitSignal extends Error {
|
|
381
|
+
constructor(code, msg) {
|
|
382
|
+
super(msg || `exit ${code}`);
|
|
383
|
+
this.name = "ExitSignal";
|
|
384
|
+
this.code = code;
|
|
385
|
+
this.silent = !msg;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function die(msg, code = 2) {
|
|
390
|
+
throw new ExitSignal(code, msg);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const HELP = `wicked-brain-call — invoke the wicked-brain server, auto-starting if needed.
|
|
394
|
+
|
|
395
|
+
Usage:
|
|
396
|
+
wicked-brain-call <action> [json-payload]
|
|
397
|
+
wicked-brain-call <action> --param key=value [--param key2=value2 ...]
|
|
398
|
+
echo '{"query":"foo"}' | wicked-brain-call <action> -
|
|
399
|
+
|
|
400
|
+
Lifecycle:
|
|
401
|
+
wicked-brain-call --start start the server (no call)
|
|
402
|
+
wicked-brain-call --stop stop the server
|
|
403
|
+
wicked-brain-call --status print server state
|
|
404
|
+
|
|
405
|
+
Options:
|
|
406
|
+
--brain <path> brain directory (default: discover)
|
|
407
|
+
--port <n> override port from _meta/config.json
|
|
408
|
+
--source <path> LSP source root passed to spawned server
|
|
409
|
+
--no-spawn fail if server is not running (don't auto-start)
|
|
410
|
+
--no-audit skip writing audit markdown (also: WICKED_BRAIN_AUDIT=0)
|
|
411
|
+
--spawn-timeout <ms> how long to wait for spawn readiness (default 10000)
|
|
412
|
+
--pretty pretty-print JSON output
|
|
413
|
+
--param key=value add an individual param (repeatable)
|
|
414
|
+
--version, -v print version
|
|
415
|
+
--help, -h print this help
|
|
416
|
+
|
|
417
|
+
Exit codes:
|
|
418
|
+
0 success
|
|
419
|
+
1 API returned an error field
|
|
420
|
+
2 CLI / infrastructure failure (bad args, can't reach server, etc.)
|
|
421
|
+
|
|
422
|
+
Examples:
|
|
423
|
+
wicked-brain-call health
|
|
424
|
+
wicked-brain-call search '{"query":"sqlite","topK":5}'
|
|
425
|
+
wicked-brain-call search --param query=sqlite --param topK=5
|
|
426
|
+
`;
|
|
427
|
+
|
|
428
|
+
(async () => {
|
|
429
|
+
const args = parseArgs(process.argv.slice(2));
|
|
430
|
+
|
|
431
|
+
if (args.flags.version) {
|
|
432
|
+
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8"));
|
|
433
|
+
process.stdout.write(pkg.version + "\n");
|
|
434
|
+
process.exit(0);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (args.flags.help) {
|
|
438
|
+
process.stdout.write(HELP);
|
|
439
|
+
process.exit(0);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const noModeFlag = !args.flags.start && !args.flags.stop && !args.flags.status;
|
|
443
|
+
if (noModeFlag && args.positional.length === 0) {
|
|
444
|
+
process.stderr.write(HELP);
|
|
445
|
+
process.exit(1);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const log = (msg) => process.stderr.write(`[wicked-brain-call] ${msg}\n`);
|
|
449
|
+
const brainPath = resolveBrainPath(args.flags.brain);
|
|
450
|
+
|
|
451
|
+
// ---- --status ----
|
|
452
|
+
if (args.flags.status) {
|
|
453
|
+
const meta = readMetaConfig(brainPath);
|
|
454
|
+
const port = args.flags.port || meta.server_port || 4242;
|
|
455
|
+
const running = await healthCheck(port);
|
|
456
|
+
let pid = null;
|
|
457
|
+
try { pid = parseInt(readFileSync(join(brainPath, "_meta", "server.pid"), "utf-8").trim(), 10); } catch {}
|
|
458
|
+
const payload = {
|
|
459
|
+
brain_path: brainPath,
|
|
460
|
+
port,
|
|
461
|
+
running,
|
|
462
|
+
pid: running && pidAlive(pid) ? pid : null,
|
|
463
|
+
};
|
|
464
|
+
process.stdout.write(
|
|
465
|
+
(args.flags.pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload)) + "\n",
|
|
466
|
+
);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---- --stop ----
|
|
471
|
+
if (args.flags.stop) {
|
|
472
|
+
const meta = readMetaConfig(brainPath);
|
|
473
|
+
const port = args.flags.port || meta.server_port || 4242;
|
|
474
|
+
const pidPath = join(brainPath, "_meta", "server.pid");
|
|
475
|
+
let pid = null;
|
|
476
|
+
try { pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10); } catch {}
|
|
477
|
+
if (!pid || !pidAlive(pid)) {
|
|
478
|
+
process.stdout.write(JSON.stringify({ stopped: false, reason: "not running", port }) + "\n");
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
try { process.kill(pid, "SIGTERM"); } catch (err) { die(`kill ${pid} failed: ${err.message}`); }
|
|
482
|
+
const deadline = Date.now() + 5000;
|
|
483
|
+
while (Date.now() < deadline) {
|
|
484
|
+
if (!pidAlive(pid)) break;
|
|
485
|
+
await sleep(100);
|
|
486
|
+
}
|
|
487
|
+
process.stdout.write(JSON.stringify({ stopped: !pidAlive(pid), pid, port }) + "\n");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ---- --start ----
|
|
492
|
+
if (args.flags.start) {
|
|
493
|
+
const port = await ensureServer(brainPath, {
|
|
494
|
+
explicitPort: args.flags.port,
|
|
495
|
+
sourceOverride: args.flags.source,
|
|
496
|
+
noSpawn: false,
|
|
497
|
+
spawnTimeoutMs: args.flags.spawnTimeoutMs,
|
|
498
|
+
log,
|
|
499
|
+
}).catch(err => die(err.message));
|
|
500
|
+
process.stdout.write(JSON.stringify({ started: true, port, brain_path: brainPath }) + "\n");
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ---- default: forward an action call ----
|
|
505
|
+
const action = args.positional[0];
|
|
506
|
+
let params = Object.keys(args.params).length > 0 ? args.params : null;
|
|
507
|
+
|
|
508
|
+
if (args.positional.length > 1) {
|
|
509
|
+
const raw = args.positional.slice(1).join(" ");
|
|
510
|
+
if (raw === "-") {
|
|
511
|
+
const piped = await readStdin();
|
|
512
|
+
if (piped) {
|
|
513
|
+
try { params = { ...(params || {}), ...JSON.parse(piped) }; } catch (err) {
|
|
514
|
+
die(`stdin payload is not valid JSON: ${err.message}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
let parsed;
|
|
519
|
+
try { parsed = JSON.parse(raw); } catch (err) {
|
|
520
|
+
die(`positional payload is not valid JSON: ${err.message}`);
|
|
521
|
+
}
|
|
522
|
+
params = { ...(params || {}), ...parsed };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const port = await ensureServer(brainPath, {
|
|
527
|
+
explicitPort: args.flags.port,
|
|
528
|
+
sourceOverride: args.flags.source,
|
|
529
|
+
noSpawn: args.flags.noSpawn,
|
|
530
|
+
spawnTimeoutMs: args.flags.spawnTimeoutMs,
|
|
531
|
+
log,
|
|
532
|
+
}).catch(err => die(err.message));
|
|
533
|
+
|
|
534
|
+
// Open audit BEFORE the call so a crash mid-flight still leaves a partial
|
|
535
|
+
// record. Audit is best-effort — write failures never block the request.
|
|
536
|
+
const audit = auditEnabled(args.flags.noAudit)
|
|
537
|
+
? writeAuditOpen(brainPath, action, params, port)
|
|
538
|
+
: null;
|
|
539
|
+
|
|
540
|
+
const startedAt = Date.now();
|
|
541
|
+
let response;
|
|
542
|
+
let callError;
|
|
543
|
+
try {
|
|
544
|
+
response = await callApi(port, action, params, { auditFile: audit?.file });
|
|
545
|
+
} catch (err) {
|
|
546
|
+
callError = err;
|
|
547
|
+
}
|
|
548
|
+
const durationMs = Date.now() - startedAt;
|
|
549
|
+
|
|
550
|
+
if (callError) {
|
|
551
|
+
writeAuditClose(audit, { exitCode: 2, durationMs, error: callError.message });
|
|
552
|
+
die(`request failed: ${callError.message}`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const exitCode = response && response.error ? 1 : 0;
|
|
556
|
+
writeAuditClose(audit, {
|
|
557
|
+
exitCode,
|
|
558
|
+
durationMs,
|
|
559
|
+
response,
|
|
560
|
+
error: response?.error,
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
process.stdout.write(
|
|
564
|
+
(args.flags.pretty ? JSON.stringify(response, null, 2) : JSON.stringify(response)) + "\n",
|
|
565
|
+
);
|
|
566
|
+
process.exitCode = exitCode;
|
|
567
|
+
})().catch(err => {
|
|
568
|
+
if (err instanceof ExitSignal) {
|
|
569
|
+
if (!err.silent) process.stderr.write(`wicked-brain-call: ${err.message}\n`);
|
|
570
|
+
process.exitCode = err.code;
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
process.stderr.write(`wicked-brain-call: ${err.message ?? String(err)}\n`);
|
|
574
|
+
process.exitCode = 2;
|
|
575
|
+
});
|
|
@@ -6,7 +6,13 @@ import { argv, pid, exit } from "node:process";
|
|
|
6
6
|
import { FileWatcher } from "../lib/file-watcher.mjs";
|
|
7
7
|
import { SqliteSearch } from "../lib/sqlite-search.mjs";
|
|
8
8
|
import { LspClient } from "../lib/lsp-client.mjs";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
emitEvent,
|
|
11
|
+
waitForBus,
|
|
12
|
+
listBusDeadLetters,
|
|
13
|
+
replayBusDeadLetter,
|
|
14
|
+
dropBusDeadLetter,
|
|
15
|
+
} from "../lib/bus.mjs";
|
|
10
16
|
import { startMemorySubscriber } from "../lib/memory-subscriber.mjs";
|
|
11
17
|
import { renderViewerHtml } from "../lib/viewer-page.mjs";
|
|
12
18
|
import { walkBrainContent, purgeBrainContent } from "../lib/brain-walker.mjs";
|
|
@@ -201,6 +207,17 @@ const actions = {
|
|
|
201
207
|
emitEvent("wicked.link.confirmed", "brain.link", {
|
|
202
208
|
source_id: p.source_id, target_path: p.target_path, verdict: p.verdict, brain_id: brainId,
|
|
203
209
|
});
|
|
210
|
+
// Surface contradictions on a dedicated event stream so downstream
|
|
211
|
+
// consumers don't have to filter by verdict on the generic confirmed event.
|
|
212
|
+
if (p.verdict === "contradict") {
|
|
213
|
+
emitEvent("wicked.link.contradicted", "brain.link", {
|
|
214
|
+
source_id: p.source_id,
|
|
215
|
+
target_path: p.target_path,
|
|
216
|
+
confidence: result?.confidence ?? null,
|
|
217
|
+
evidence_count: result?.evidence_count ?? null,
|
|
218
|
+
brain_id: brainId,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
204
221
|
return result;
|
|
205
222
|
},
|
|
206
223
|
link_health: () => db.linkHealth(),
|
|
@@ -259,6 +276,21 @@ const actions = {
|
|
|
259
276
|
: { skipped: true },
|
|
260
277
|
};
|
|
261
278
|
},
|
|
279
|
+
// DLQ inspection — read-only listing, scoped to wicked-brain's plugin.
|
|
280
|
+
// Surfaces dead-lettered fact events from the auto-memorize subscriber
|
|
281
|
+
// so operators can decide whether to replay or drop them.
|
|
282
|
+
dlq_list: (p = {}) => ({
|
|
283
|
+
dead_letters: listBusDeadLetters({
|
|
284
|
+
cursor_id: p.cursor_id,
|
|
285
|
+
limit: p.limit,
|
|
286
|
+
}),
|
|
287
|
+
}),
|
|
288
|
+
// Mark one DLQ entry for replay. The managed subscriber drains pending
|
|
289
|
+
// replays before each poll cycle — success here means queued, not delivered.
|
|
290
|
+
// dl_id alone identifies the row (it's the bus's primary key).
|
|
291
|
+
dlq_replay: (p = {}) => replayBusDeadLetter({ dl_id: p.dl_id }),
|
|
292
|
+
// Permanently delete a DLQ row. Use when replay would just dead-letter again.
|
|
293
|
+
dlq_drop: (p = {}) => dropBusDeadLetter({ dl_id: p.dl_id }),
|
|
262
294
|
purge_brain: async (p = {}) => {
|
|
263
295
|
// Destructive. Wipes chunks/, wiki/, and memory/ content and clears the
|
|
264
296
|
// SQLite index. Requires p.confirm === "DELETE" to execute — typed
|
|
@@ -283,6 +315,9 @@ const WRITE_ACTIONS = new Set([
|
|
|
283
315
|
"confirm_link",
|
|
284
316
|
"reonboard",
|
|
285
317
|
"purge_brain",
|
|
318
|
+
// DLQ replay/drop mutate the bus DB; list is read-only.
|
|
319
|
+
"dlq_replay",
|
|
320
|
+
"dlq_drop",
|
|
286
321
|
]);
|
|
287
322
|
|
|
288
323
|
// HTTP server
|