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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain",
3
- "version": "0.12.1",
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
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain-server",
3
- "version": "0.12.1",
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: Factory skill for listing and dispatching wicked-brain agents. Agents enforce multi-step pipelines for consolidation, context assembly, session teardown, and project onboarding.
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
- You compile wiki articles from the brain's chunks by dispatching a compile subagent.
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
- Resolve the brain config via the shared resolution in
29
- wicked-brain:init § "Resolving the brain config". In short: try
30
- `~/.wicked-brain/projects/{cwd_basename}/_meta/config.json` first, fall back
31
- to `~/.wicked-brain/_meta/config.json` (legacy flat), else trigger
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: http://localhost:{port}/api
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
- curl -s -X POST http://localhost:{port}/api \
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 `{"action":"backlinks","params":{"id":"{chunk-path}"}}` — more backlinks = more referenced by other content)
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
- curl -s -X POST http://localhost:{port}/api \
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