wicked-brain 0.14.2 → 0.14.3

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.14.2",
3
+ "version": "0.14.3",
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": [
@@ -13,6 +13,12 @@ import { join } from "node:path";
13
13
  import { getBusDb, isBusAvailable, emitEvent } from "./bus.mjs";
14
14
  import { promoteFact } from "./memory-promoter.mjs";
15
15
 
16
+ // Subscriber identity on the bus. Used both to register the subscription and to
17
+ // locate its cursor for the TTL self-heal — keep them in one place so the two
18
+ // can't drift.
19
+ const PLUGIN = "wicked-brain";
20
+ const FACT_FILTER = "wicked.fact.extracted";
21
+
16
22
  /**
17
23
  * Start the auto-memorize subscriber.
18
24
  * Returns the subscription handle (with .stop()) or null if the bus is unavailable.
@@ -38,10 +44,22 @@ export async function startMemorySubscriber({ brainPath, brainId, db }) {
38
44
 
39
45
  const memoryDir = join(brainPath, "memory");
40
46
 
47
+ // Self-heal a cursor stranded behind the bus TTL window (e.g. after a long
48
+ // server outage). The subscriber RESUMES its existing cursor, so cursor_init
49
+ // "latest" does not recover a stale one — poll() would throw WB-003 every
50
+ // cycle and auto-memorize would stall until manually reset. Advance it to the
51
+ // latest event before subscribing.
52
+ const healed = fastForwardStaleCursor(busDb, PLUGIN, FACT_FILTER);
53
+ if (healed) {
54
+ console.error(
55
+ `[memory-subscriber] cursor was behind the TTL window; repositioned ${healed.from} -> ${healed.to} to replay survivors`,
56
+ );
57
+ }
58
+
41
59
  const sub = subscribe({
42
60
  db: busDb,
43
- plugin: "wicked-brain",
44
- filter: "wicked.fact.extracted",
61
+ plugin: PLUGIN,
62
+ filter: FACT_FILTER,
45
63
  cursor_init: "latest",
46
64
  pollIntervalMs: 5000,
47
65
  maxRetries: 3,
@@ -84,6 +102,63 @@ export async function startMemorySubscriber({ brainPath, brainId, db }) {
84
102
  return sub;
85
103
  }
86
104
 
105
+ /**
106
+ * Fast-forward a subscriber cursor that has fallen behind the bus TTL window.
107
+ *
108
+ * After a long server outage the durable cursor can sit below the oldest
109
+ * surviving event; wicked-bus poll() then throws WB-003 ("cursor behind the TTL
110
+ * window") every cycle. The subscriber resumes its existing cursor (cursor_init
111
+ * only applies on first registration), so it never recovers on its own. This
112
+ * mirrors poll()'s WB-003 check and, when behind, repositions the cursor to just
113
+ * before the oldest surviving event so the subscriber still replays everything
114
+ * left in the bus (at-least-once) instead of discarding the survivors.
115
+ *
116
+ * No-op when the cursor is current, when there are no events, or when no cursor
117
+ * exists yet (a fresh subscriber initializes at "latest" anyway). Never throws —
118
+ * a self-heal failure must not block server startup.
119
+ *
120
+ * @param {import('better-sqlite3').Database} busDb
121
+ * @param {string} plugin
122
+ * @param {string} filter event_type_filter the subscriber registered with
123
+ * @returns {{from:number,to:number}|null} the adjustment made, or null for no-op
124
+ */
125
+ export function fastForwardStaleCursor(busDb, plugin, filter) {
126
+ try {
127
+ const bounds = busDb
128
+ .prepare("SELECT MIN(event_id) AS min_id FROM events")
129
+ .get();
130
+ if (!bounds || bounds.min_id == null) return null; // no events to be behind of
131
+
132
+ const row = busDb
133
+ .prepare(
134
+ `SELECT c.cursor_id AS cursor_id, c.last_event_id AS last_event_id
135
+ FROM subscriptions s
136
+ INNER JOIN cursors c ON c.subscription_id = s.subscription_id
137
+ WHERE s.plugin = ? AND s.role = 'subscriber'
138
+ AND s.event_type_filter = ?
139
+ AND s.deregistered_at IS NULL AND c.deregistered_at IS NULL
140
+ ORDER BY s.registered_at DESC
141
+ LIMIT 1`,
142
+ )
143
+ .get(plugin, filter);
144
+ if (!row) return null; // no existing cursor — fresh subscribe inits at "latest"
145
+
146
+ // Mirror wicked-bus poll(): WB-003 fires when last_event_id < oldest - 1.
147
+ // Reposition to oldest-1 (not latest) so the subscriber replays every event
148
+ // that survived the sweep instead of discarding the backlog.
149
+ const target = bounds.min_id - 1;
150
+ if (row.last_event_id < target) {
151
+ busDb
152
+ .prepare("UPDATE cursors SET last_event_id = ? WHERE cursor_id = ?")
153
+ .run(target, row.cursor_id);
154
+ return { from: row.last_event_id, to: target };
155
+ }
156
+ return null;
157
+ } catch {
158
+ return null; // never block startup on the self-heal
159
+ }
160
+ }
161
+
87
162
  /**
88
163
  * Render a memory descriptor as a markdown file with YAML-ish frontmatter.
89
164
  * Minimal serializer — no YAML lib. Matches the format used by wicked-brain:memory.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain-server",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "type": "module",
5
5
  "description": "SQLite FTS5 search server for wicked-brain digital knowledge bases",
6
6
  "keywords": [