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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain",
3
- "version": "0.12.1",
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": "^1.1.0"
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 { emitEvent, waitForBus } from "../lib/bus.mjs";
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