wicked-brain 0.13.0 → 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.13.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": [
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "better-sqlite3": "^12.0.0",
36
- "wicked-bus": "^1.1.0"
36
+ "wicked-bus": "^2.0.0"
37
37
  },
38
38
  "files": [
39
39
  "install.mjs",
@@ -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
@@ -9,8 +9,12 @@
9
9
  */
10
10
 
11
11
  const DOMAIN = "wicked-brain";
12
+ const PLUGIN = "wicked-brain";
12
13
 
13
14
  let busEmit = null;
15
+ let busListDeadLetters = null;
16
+ let busReplayDeadLetter = null;
17
+ let busDropDeadLetter = null;
14
18
  let busDb = null;
15
19
  let busConfig = null;
16
20
  let available = false;
@@ -25,6 +29,9 @@ async function init() {
25
29
  const dbPath = bus.resolveDbPath();
26
30
  busDb = bus.openDb(dbPath);
27
31
  busEmit = bus.emit;
32
+ busListDeadLetters = bus.listDeadLetters;
33
+ busReplayDeadLetter = bus.replayDeadLetter;
34
+ busDropDeadLetter = bus.dropDeadLetter;
28
35
  available = true;
29
36
  } catch {
30
37
  // wicked-bus not installed or not initialized — degrade silently
@@ -83,3 +90,82 @@ export async function waitForBus() {
83
90
  await ready;
84
91
  return available;
85
92
  }
93
+
94
+ /**
95
+ * List dead-lettered events scoped to wicked-brain's plugin.
96
+ * Returns [] when the bus is unavailable so callers don't have to branch.
97
+ *
98
+ * Upstream takes camelCase `cursorId`; we keep snake_case at this layer
99
+ * for consistency with the rest of wicked-brain's API and translate here.
100
+ * `limit` is parsed to an integer because params arriving from the HTTP
101
+ * dispatch layer can be strings (and upstream rejects non-integers by
102
+ * silently falling back to its default).
103
+ *
104
+ * @param {object} [opts]
105
+ * @param {string} [opts.cursor_id] filter to one cursor
106
+ * @param {number|string} [opts.limit=100]
107
+ * @returns {Array}
108
+ */
109
+ export function listBusDeadLetters(opts = {}) {
110
+ if (!available || !busListDeadLetters) return [];
111
+ const limit = parseInt(opts.limit ?? 100, 10) || 100;
112
+ const upstreamOpts = { plugin: PLUGIN, limit };
113
+ if (opts.cursor_id) upstreamOpts.cursorId = opts.cursor_id;
114
+ try {
115
+ return busListDeadLetters(busDb, upstreamOpts);
116
+ } catch {
117
+ return [];
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Mark a dead letter for replay. The managed subscriber drains pending
123
+ * replays before each poll cycle, so a successful return means the request
124
+ * is queued, not that the event has re-delivered yet.
125
+ *
126
+ * Upstream signature is positional `(db, dlId)`. dl_id is globally unique
127
+ * (the bus's own primary key) so plugin/cursor scoping is implicit.
128
+ *
129
+ * @param {object} args
130
+ * @param {string} args.dl_id
131
+ * @returns {{ ok: boolean, error?: string }}
132
+ */
133
+ export function replayBusDeadLetter({ dl_id } = {}) {
134
+ if (!available || !busReplayDeadLetter) {
135
+ return { ok: false, error: "bus unavailable" };
136
+ }
137
+ if (!dl_id) {
138
+ return { ok: false, error: "dl_id required" };
139
+ }
140
+ try {
141
+ busReplayDeadLetter(busDb, dl_id);
142
+ return { ok: true };
143
+ } catch (err) {
144
+ return { ok: false, error: err.message };
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Drop (delete) a dead letter row. Use when an event is no longer relevant
150
+ * — replay would just dead-letter again.
151
+ *
152
+ * Upstream signature is positional `(db, dlId)`. dl_id is globally unique.
153
+ *
154
+ * @param {object} args
155
+ * @param {string} args.dl_id
156
+ * @returns {{ ok: boolean, error?: string }}
157
+ */
158
+ export function dropBusDeadLetter({ dl_id } = {}) {
159
+ if (!available || !busDropDeadLetter) {
160
+ return { ok: false, error: "bus unavailable" };
161
+ }
162
+ if (!dl_id) {
163
+ return { ok: false, error: "dl_id required" };
164
+ }
165
+ try {
166
+ busDropDeadLetter(busDb, dl_id);
167
+ return { ok: true };
168
+ } catch (err) {
169
+ return { ok: false, error: err.message };
170
+ }
171
+ }
@@ -43,7 +43,7 @@ export async function startMemorySubscriber({ brainPath, brainId, db }) {
43
43
  plugin: "wicked-brain",
44
44
  filter: "wicked.fact.extracted",
45
45
  cursor_init: "latest",
46
- pollIntervalMs: 15000,
46
+ pollIntervalMs: 5000,
47
47
  maxRetries: 3,
48
48
  backoffMs: [1000, 5000, 30000],
49
49
  handler: async (event) => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain-server",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "description": "SQLite FTS5 search server for wicked-brain digital knowledge bases",
6
6
  "keywords": [
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "better-sqlite3": "^12.0.0",
40
- "wicked-bus": "^1.1.0"
40
+ "wicked-bus": "^2.0.0"
41
41
  },
42
42
  "engines": {
43
43
  "node": ">=18.0.0"
@@ -0,0 +1,102 @@
1
+ ---
2
+ name: wicked-brain:dlq
3
+ description: |
4
+ Inspect, replay, or drop dead-lettered events from wicked-brain's bus
5
+ subscriber. The auto-memorize subscriber consumes `wicked.fact.extracted`
6
+ and dead-letters events that exhaust their retry budget. Without this
7
+ skill, those events sit untouched forever — a fixable handler bug
8
+ silently loses every fact.
9
+
10
+ Use when: "show DLQ", "replay dead letters", "what events failed",
11
+ "drop dead letter", "memory subscriber DLQ", "stuck fact events".
12
+ ---
13
+
14
+ # wicked-brain:dlq
15
+
16
+ List, replay, and drop dead-lettered events held by wicked-bus on behalf of
17
+ the wicked-brain auto-memorize subscriber.
18
+
19
+ ## Cross-Platform Notes
20
+
21
+ Uses `npx wicked-brain-call` for all server interaction. Cross-platform on
22
+ macOS, Linux, and Windows.
23
+
24
+ - Paths use forward slashes
25
+ - No Unix-only shell features
26
+
27
+ ## Config
28
+
29
+ Brain discovery + server lifecycle are handled by `wicked-brain-call`. The
30
+ server bridges to the wicked-bus DB it opened at startup, so DLQ rows are
31
+ always scoped to `plugin: "wicked-brain"`.
32
+
33
+ If the bus is unavailable (not installed, or the DB is unreachable), `list`
34
+ returns an empty array and `replay` / `drop` return `{ ok: false, error: "bus unavailable" }`.
35
+
36
+ ## Parameters
37
+
38
+ - **mode** (required): `list`, `replay`, or `drop`
39
+ - **cursor_id** (list, optional): filter to one subscriber cursor
40
+ - **dl_id** (replay/drop, required): dead-letter row id, returned by `list`. Globally unique — alone identifies the row.
41
+ - **limit** (list, optional, default 100): max rows to return
42
+
43
+ ## List mode
44
+
45
+ ```bash
46
+ npx wicked-brain-call dlq_list --param limit=50
47
+ ```
48
+
49
+ Optional cursor scoping:
50
+ ```bash
51
+ npx wicked-brain-call dlq_list --param cursor_id={cursor_id}
52
+ ```
53
+
54
+ Returns `{ dead_letters: [...] }`. Each row has `dl_id`, `cursor_id`,
55
+ `event_type`, `domain`, `subdomain`, `payload`, `dead_lettered_at`,
56
+ `attempts`, `last_error`.
57
+
58
+ The `payload` field is denormalized at DLQ time — the originating row in
59
+ `events` may have been swept by the 24h `dedup_expires_at`, so it reflects
60
+ the event as it failed, not current state.
61
+
62
+ ## Replay mode
63
+
64
+ ```bash
65
+ npx wicked-brain-call dlq_replay --param dl_id={dl_id}
66
+ ```
67
+
68
+ Marks the row for replay. The managed subscriber drains pending replays
69
+ before each poll cycle (5s by default), so a successful return means
70
+ *queued*, not *delivered*. If the handler still rejects the event, it
71
+ will dead-letter again with an incremented attempt count.
72
+
73
+ Replay is for recovery — not transparent retry. The original
74
+ `idempotency_key` may already have been swept (24h TTL), so a re-emission
75
+ inside the handler will not be deduped against the original event.
76
+
77
+ ## Drop mode
78
+
79
+ ```bash
80
+ npx wicked-brain-call dlq_drop --param dl_id={dl_id}
81
+ ```
82
+
83
+ Permanently deletes the DLQ row. Use when replay would just dead-letter
84
+ again — for example, when the source event was malformed and there's no
85
+ fix path.
86
+
87
+ ## Workflow
88
+
89
+ A typical recovery loop:
90
+
91
+ 1. `list` to see what's pending and inspect `last_error`.
92
+ 2. Fix the handler (`server/lib/memory-promoter.mjs` or `memory-subscriber.mjs`).
93
+ 3. Restart the server so the fix takes effect.
94
+ 4. `replay` each row.
95
+ 5. `list` again to confirm the queue is empty.
96
+ 6. `drop` any rows that can't be fixed.
97
+
98
+ ## Reporting
99
+
100
+ Always surface `dl_id`, `cursor_id`, `event_type`, `attempts`, and
101
+ `last_error` for each row so the operator has enough context to choose
102
+ between replay and drop.