wicked-brain 0.12.1 → 0.13.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 +3 -2
- package/server/bin/wicked-brain-call.mjs +575 -0
- package/server/package.json +3 -2
- 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-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.13.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,7 +28,8 @@
|
|
|
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",
|
|
@@ -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
|
+
});
|
package/server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-brain-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SQLite FTS5 search server for wicked-brain digital knowledge bases",
|
|
6
6
|
"keywords": [
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"bin": {
|
|
21
21
|
"wicked-brain-server": "./bin/wicked-brain-server.mjs",
|
|
22
|
-
"wicked-brain-onboard-wiki": "./bin/onboard-wiki.mjs"
|
|
22
|
+
"wicked-brain-onboard-wiki": "./bin/onboard-wiki.mjs",
|
|
23
|
+
"wicked-brain-call": "./bin/wicked-brain-call.mjs"
|
|
23
24
|
},
|
|
24
25
|
"files": [
|
|
25
26
|
"bin/",
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: wicked-brain:agent
|
|
3
|
-
description:
|
|
3
|
+
description: |
|
|
4
|
+
This skill should be used when the user says "list brain agents",
|
|
5
|
+
"dispatch the consolidation agent", "run brain context assembly",
|
|
6
|
+
"onboard a new project", or "session teardown". Factory skill for listing
|
|
7
|
+
and dispatching wicked-brain agents. Agents enforce multi-step pipelines
|
|
8
|
+
for consolidation, context assembly, session teardown, and project
|
|
9
|
+
onboarding.
|
|
4
10
|
---
|
|
5
11
|
|
|
6
12
|
# wicked-brain:agent
|
|
@@ -11,7 +11,7 @@ description: |
|
|
|
11
11
|
|
|
12
12
|
# wicked-brain:compile
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Compiles wiki articles from the brain's chunks by dispatching a compile subagent.
|
|
15
15
|
|
|
16
16
|
## Cross-Platform Notes
|
|
17
17
|
|
|
@@ -25,15 +25,10 @@ For the brain path default:
|
|
|
25
25
|
|
|
26
26
|
## Config
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
to
|
|
32
|
-
wicked-brain:init. Read the resolved file for brain path and server port.
|
|
33
|
-
|
|
34
|
-
Do NOT read a bare relative `_meta/config.json` — the model will resolve it
|
|
35
|
-
against the current working directory and brain files will end up in the
|
|
36
|
-
project root.
|
|
28
|
+
Brain discovery + server lifecycle are handled by `wicked-brain-call`. Pass
|
|
29
|
+
`--brain <path>` to override the auto-detected brain, or set
|
|
30
|
+
`WICKED_BRAIN_PATH`. The CLI starts the server on first call (no manual
|
|
31
|
+
init required) and writes an audit record to `{brain}/calls/` per call.
|
|
37
32
|
|
|
38
33
|
## Process
|
|
39
34
|
|
|
@@ -41,7 +36,7 @@ Dispatch a compile subagent with these instructions:
|
|
|
41
36
|
|
|
42
37
|
```
|
|
43
38
|
You are a compile agent for the digital brain at {brain_path}.
|
|
44
|
-
Server:
|
|
39
|
+
Server interactions: use `npx wicked-brain-call <action> [--param k=v ...]`.
|
|
45
40
|
|
|
46
41
|
## Your task
|
|
47
42
|
|
|
@@ -51,9 +46,7 @@ Read chunks and synthesize wiki articles that capture key concepts.
|
|
|
51
46
|
|
|
52
47
|
Get brain stats:
|
|
53
48
|
```bash
|
|
54
|
-
|
|
55
|
-
-H "Content-Type: application/json" \
|
|
56
|
-
-d '{"action":"stats","params":{}}'
|
|
49
|
+
npx wicked-brain-call stats
|
|
57
50
|
```
|
|
58
51
|
|
|
59
52
|
List existing wiki articles using your Glob tool on `{brain_path}/wiki/**/*.md`.
|
|
@@ -121,7 +114,7 @@ Focus on chunks NOT referenced by any wiki article.
|
|
|
121
114
|
**Chunk prioritization:** When there are many uncovered chunks, process them in
|
|
122
115
|
this order:
|
|
123
116
|
1. Most recently modified (check file mtime or `authored_at` frontmatter field)
|
|
124
|
-
2. Highest backlink count (use `
|
|
117
|
+
2. Highest backlink count (use `npx wicked-brain-call backlinks --param id={chunk-path}` — more backlinks = more referenced by other content)
|
|
125
118
|
|
|
126
119
|
**Existing wiki articles:** For each existing wiki article, compare the
|
|
127
120
|
`source_hashes` in its frontmatter against the current content hash of each
|
|
@@ -243,11 +236,10 @@ supersedes, supports, caused-by, extends, depends-on).
|
|
|
243
236
|
|
|
244
237
|
## Step 5: Index new articles
|
|
245
238
|
|
|
246
|
-
For each article written
|
|
239
|
+
For each article written, pass the full payload as positional JSON because
|
|
240
|
+
`content` may contain quotes, braces, and newlines:
|
|
247
241
|
```bash
|
|
248
|
-
|
|
249
|
-
-H "Content-Type: application/json" \
|
|
250
|
-
-d '{"action":"index","params":{"id":"{path}","path":"{path}","content":"{content}","brain_id":"{brain_id}"}}'
|
|
242
|
+
npx wicked-brain-call index '{"id":"{path}","path":"{path}","content":"{content}","brain_id":"{brain_id}"}'
|
|
251
243
|
```
|
|
252
244
|
|
|
253
245
|
## Step 6: Log
|