wicked-brain 0.14.1 → 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.1",
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": [
@@ -11,6 +11,9 @@ import {
11
11
  unlinkSync,
12
12
  existsSync,
13
13
  mkdirSync,
14
+ openSync,
15
+ closeSync,
16
+ statSync,
14
17
  } from "node:fs";
15
18
  import { join, resolve, basename } from "node:path";
16
19
  import { homedir } from "node:os";
@@ -186,6 +189,24 @@ function pidAlive(pid) {
186
189
 
187
190
  // ---------- server lifecycle ----------
188
191
 
192
+ // Open {brain}/_meta/server.log for the detached daemon's stdout+stderr. Without
193
+ // this the server is spawned with stdio:"ignore", so a boot crash (e.g. an
194
+ // EMFILE watch error) vanishes and the only symptom is the "did not become
195
+ // ready" timeout below. Caps growth: starts fresh if the existing log exceeds
196
+ // 5 MB so a long-lived daemon stays diagnosable without growing unbounded.
197
+ function openServerLog(brainPath) {
198
+ const logPath = join(brainPath, "_meta", "server.log");
199
+ let flag = "a";
200
+ try {
201
+ if (statSync(logPath).size > 5 * 1024 * 1024) flag = "w";
202
+ } catch {}
203
+ try {
204
+ return { fd: openSync(logPath, flag), logPath };
205
+ } catch {
206
+ return { fd: null, logPath };
207
+ }
208
+ }
209
+
189
210
  async function ensureServer(brainPath, opts) {
190
211
  const { explicitPort, sourceOverride, noSpawn, log, spawnTimeoutMs = 10_000 } = opts;
191
212
  const meta = readMetaConfig(brainPath);
@@ -227,15 +248,28 @@ async function ensureServer(brainPath, opts) {
227
248
  const argv = [SERVER_BIN, "--brain", brainPath, "--port", String(port)];
228
249
  if (sourcePath) argv.push("--source", sourcePath);
229
250
 
230
- const child = spawn(process.execPath, argv, {
231
- detached: true,
232
- stdio: "ignore",
233
- windowsHide: true,
234
- });
235
- child.unref();
251
+ const { fd: logFd, logPath } = openServerLog(brainPath);
252
+ try {
253
+ const child = spawn(process.execPath, argv, {
254
+ detached: true,
255
+ stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
256
+ windowsHide: true,
257
+ });
258
+ child.unref();
259
+ } finally {
260
+ // Close our copy of the fd in all cases — the child keeps its own. Without
261
+ // the finally a synchronous spawn() throw would leak the descriptor.
262
+ if (logFd !== null) {
263
+ try { closeSync(logFd); } catch {}
264
+ }
265
+ }
266
+ if (logFd !== null) log(`server logs -> ${logPath}`);
236
267
 
237
268
  if (await waitForHealth(port, spawnTimeoutMs)) return port;
238
- throw new Error(`server did not become ready within ${spawnTimeoutMs}ms on port ${port}`);
269
+ throw new Error(
270
+ `server did not become ready within ${spawnTimeoutMs}ms on port ${port}` +
271
+ (logFd !== null ? ` — see ${logPath} for the cause` : ""),
272
+ );
239
273
  } finally {
240
274
  releaseLock(lockPath);
241
275
  }
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Recursive fs.watch over brain content with a polling fallback.
3
+ *
4
+ * Watches chunks/, wiki/, memory/ and registered project dirs; on any watch
5
+ * error (EMFILE/ENOSPC, or recursive watch unsupported) it degrades to polling
6
+ * instead of crashing the server.
7
+ *
8
+ * @module lib/file-watcher
9
+ */
1
10
  import { watch, readFileSync, existsSync, readdirSync, statSync, realpathSync } from "node:fs";
2
11
  import { join, relative } from "node:path";
3
12
  import { createHash } from "node:crypto";
@@ -48,12 +57,26 @@ export class FileWatcher {
48
57
  #pollInterval = null;
49
58
  #onChangeCallbacks = [];
50
59
  #projects = [];
60
+ #watch;
51
61
 
52
- constructor(brainPath, db, brainId, projects = []) {
62
+ constructor(brainPath, db, brainId, projects = [], watchFn = watch) {
53
63
  this.#brainPath = brainPath;
54
64
  this.#db = db;
55
65
  this.#brainId = brainId;
56
66
  this.#projects = projects;
67
+ // Injectable so tests can drive the FSWatcher 'error' path deterministically
68
+ // without exhausting real file descriptors; defaults to node:fs watch.
69
+ this.#watch = watchFn;
70
+ }
71
+
72
+ /** Active fs watcher count (0 in polling mode). Exposed for tests/diagnostics. */
73
+ get watcherCount() {
74
+ return this.#watchers.length;
75
+ }
76
+
77
+ /** True once the watcher has fallen back to polling. For tests/diagnostics. */
78
+ get polling() {
79
+ return this.#pollInterval !== null;
57
80
  }
58
81
 
59
82
  onFileChange(callback) {
@@ -94,18 +117,14 @@ export class FileWatcher {
94
117
  for (const project of this.#projects) {
95
118
  if (!existsSync(project.path)) continue;
96
119
  this.#scanAndHashProject(project);
97
- try {
98
- const watcher = watch(canonicalDir(project.path), { recursive: true }, (eventType, filename) => {
99
- if (!filename) return;
100
- const parts = filename.split(/[/\\]/);
101
- if (parts.some(p => IGNORE_DIRS.has(p))) return;
102
- const relPath = normalizePath(`projects/${project.name}/${filename}`);
103
- this.#debounce(relPath, () => this.#handleProjectChange(project, filename));
104
- });
105
- this.#watchers.push(watcher);
106
- } catch {
107
- // recursive watch not supported — polling fallback already handles this
108
- }
120
+ const watcher = this.#safeWatch(project.path, (eventType, filename) => {
121
+ if (!filename) return;
122
+ const parts = filename.split(/[/\\]/);
123
+ if (parts.some(p => IGNORE_DIRS.has(p))) return;
124
+ const relPath = normalizePath(`projects/${project.name}/${filename}`);
125
+ this.#debounce(relPath, () => this.#handleProjectChange(project, filename));
126
+ });
127
+ if (watcher) this.#watchers.push(watcher);
109
128
  }
110
129
 
111
130
  // If no watchers were set up (Linux), use polling fallback
@@ -120,18 +139,67 @@ export class FileWatcher {
120
139
  #tryWatch(dir) {
121
140
  const absDir = join(this.#brainPath, dir);
122
141
  if (!existsSync(absDir)) return false;
142
+ const watcher = this.#safeWatch(absDir, (eventType, filename) => {
143
+ if (!filename || !filename.endsWith(".md")) return;
144
+ const relPath = normalizePath(`${dir}/${filename}`);
145
+ this.#debounce(relPath, () => this.#handleChange(relPath));
146
+ });
147
+ if (!watcher) return false;
148
+ this.#watchers.push(watcher);
149
+ return true;
150
+ }
151
+
152
+ /**
153
+ * Create a recursive fs.watch with an 'error' handler attached. An unhandled
154
+ * 'error' event on an FSWatcher is rethrown by Node and crashes the entire
155
+ * server — that turned a common, recoverable condition (EMFILE when the
156
+ * open-file limit is low on macOS, ENOSPC/inotify exhaustion on Linux) into a
157
+ * fatal boot crash that left the brain permanently stale. On any watch error
158
+ * we degrade to polling, which holds no persistent watch descriptors.
159
+ *
160
+ * Returns the watcher, or null if watching could not be started (caller then
161
+ * relies on the polling fallback). The path is canonicalized first — see
162
+ * canonicalDir; required on Windows to avoid a libuv abort on 8.3 paths.
163
+ */
164
+ #safeWatch(absPath, listener) {
165
+ if (this.#pollInterval) return null; // already degraded — don't reopen fs watches
166
+ let watcher;
123
167
  try {
124
- const watcher = watch(canonicalDir(absDir), { recursive: true }, (eventType, filename) => {
125
- if (!filename || !filename.endsWith(".md")) return;
126
- const relPath = normalizePath(`${dir}/${filename}`);
127
- this.#debounce(relPath, () => this.#handleChange(relPath));
128
- });
129
- this.#watchers.push(watcher);
130
- return true;
131
- } catch {
132
- // recursive watch not supported (Linux) — polling fallback handles it
133
- return false;
168
+ watcher = this.#watch(canonicalDir(absPath), { recursive: true }, listener);
169
+ } catch (err) {
170
+ // Resource exhaustion (EMFILE/ENFILE/ENOSPC) can throw synchronously, not
171
+ // only as an async 'error' event. Don't leave already-watched dirs in a
172
+ // half-watched state — degrade the whole watcher to polling. Other sync
173
+ // failures (e.g. recursive watch unsupported on older Linux) fall through
174
+ // to the polling fallback in start() when no watcher attaches.
175
+ if (err && (err.code === "EMFILE" || err.code === "ENFILE" || err.code === "ENOSPC")) {
176
+ this.#degradeToPolling();
177
+ }
178
+ return null;
179
+ }
180
+ if (watcher && typeof watcher.on === "function") {
181
+ watcher.on("error", (err) => this.#onWatchError(err));
182
+ }
183
+ return watcher;
184
+ }
185
+
186
+ /** A watch backend errored. Log and fall back to polling rather than crash. */
187
+ #onWatchError(err) {
188
+ const code = (err && err.code) || "UNKNOWN";
189
+ console.error(
190
+ `[watcher] fs.watch error (${code}) — degrading to polling: ${(err && err.message) || err}`,
191
+ );
192
+ this.#degradeToPolling();
193
+ }
194
+
195
+ /** Tear down fs watchers and switch to polling. Idempotent. */
196
+ #degradeToPolling() {
197
+ if (this.#pollInterval) return; // already polling
198
+ for (const w of this.#watchers) {
199
+ try { w.close(); } catch {}
134
200
  }
201
+ this.#watchers = [];
202
+ this.#startPolling();
135
203
  }
136
204
 
137
205
  stop() {
@@ -310,6 +378,7 @@ export class FileWatcher {
310
378
  }
311
379
 
312
380
  #startPolling() {
381
+ if (this.#pollInterval) return; // already polling — keep a single interval
313
382
  console.log("File watcher using polling mode (recursive watch not available)");
314
383
  this.#pollInterval = setInterval(() => {
315
384
  for (const dir of ["chunks", "wiki", "memory"]) {
@@ -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.1",
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": [