wicked-brain 0.14.0 → 0.14.2

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.0",
3
+ "version": "0.14.2",
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,4 +1,13 @@
1
- import { watch, readFileSync, existsSync, readdirSync, statSync } from "node:fs";
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
+ */
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";
4
13
 
@@ -6,6 +15,31 @@ function normalizePath(p) {
6
15
  return p.replace(/\\/g, "/");
7
16
  }
8
17
 
18
+ // Canonicalize to the real (long) path before watching. On Windows, calling
19
+ // fs.watch on an 8.3 short-name path (e.g. C:\Users\RUNNER~1\AppData\Local\Temp)
20
+ // makes libuv abort the process with "Assertion failed: !_wcsnicmp(...)" in
21
+ // fs-event.c — change events report the long filename, which no longer shares
22
+ // the watched (short) directory prefix. That abort is a native crash, so it
23
+ // bypasses the try/catch → polling fallback below. filename in the callbacks
24
+ // stays relative to the watched dir, so the logical relPath construction is
25
+ // unaffected.
26
+ //
27
+ // realpathSync.native (OS GetFinalPathNameByHandle) is required to expand 8.3
28
+ // short names to their long form; the JS realpathSync only resolves symlinks
29
+ // and leaves short-name components like RUNNER~1 intact. Fall back to the JS
30
+ // implementation, then to the original path.
31
+ function canonicalDir(p) {
32
+ try {
33
+ return realpathSync.native(p);
34
+ } catch {
35
+ try {
36
+ return realpathSync(p);
37
+ } catch {
38
+ return p;
39
+ }
40
+ }
41
+ }
42
+
9
43
  const IGNORE_DIRS = new Set([
10
44
  "node_modules", ".git", "__pycache__", ".venv", "venv",
11
45
  "target", "dist", "build", ".next", ".nuxt", "coverage",
@@ -23,12 +57,26 @@ export class FileWatcher {
23
57
  #pollInterval = null;
24
58
  #onChangeCallbacks = [];
25
59
  #projects = [];
60
+ #watch;
26
61
 
27
- constructor(brainPath, db, brainId, projects = []) {
62
+ constructor(brainPath, db, brainId, projects = [], watchFn = watch) {
28
63
  this.#brainPath = brainPath;
29
64
  this.#db = db;
30
65
  this.#brainId = brainId;
31
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;
32
80
  }
33
81
 
34
82
  onFileChange(callback) {
@@ -69,18 +117,14 @@ export class FileWatcher {
69
117
  for (const project of this.#projects) {
70
118
  if (!existsSync(project.path)) continue;
71
119
  this.#scanAndHashProject(project);
72
- try {
73
- const watcher = watch(project.path, { recursive: true }, (eventType, filename) => {
74
- if (!filename) return;
75
- const parts = filename.split(/[/\\]/);
76
- if (parts.some(p => IGNORE_DIRS.has(p))) return;
77
- const relPath = normalizePath(`projects/${project.name}/${filename}`);
78
- this.#debounce(relPath, () => this.#handleProjectChange(project, filename));
79
- });
80
- this.#watchers.push(watcher);
81
- } catch {
82
- // recursive watch not supported — polling fallback already handles this
83
- }
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);
84
128
  }
85
129
 
86
130
  // If no watchers were set up (Linux), use polling fallback
@@ -95,18 +139,67 @@ export class FileWatcher {
95
139
  #tryWatch(dir) {
96
140
  const absDir = join(this.#brainPath, dir);
97
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;
98
167
  try {
99
- const watcher = watch(absDir, { recursive: true }, (eventType, filename) => {
100
- if (!filename || !filename.endsWith(".md")) return;
101
- const relPath = normalizePath(`${dir}/${filename}`);
102
- this.#debounce(relPath, () => this.#handleChange(relPath));
103
- });
104
- this.#watchers.push(watcher);
105
- return true;
106
- } catch {
107
- // recursive watch not supported (Linux) — polling fallback handles it
108
- 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));
109
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 {}
200
+ }
201
+ this.#watchers = [];
202
+ this.#startPolling();
110
203
  }
111
204
 
112
205
  stop() {
@@ -285,6 +378,7 @@ export class FileWatcher {
285
378
  }
286
379
 
287
380
  #startPolling() {
381
+ if (this.#pollInterval) return; // already polling — keep a single interval
288
382
  console.log("File watcher using polling mode (recursive watch not available)");
289
383
  this.#pollInterval = setInterval(() => {
290
384
  for (const dir of ["chunks", "wiki", "memory"]) {
@@ -795,10 +795,10 @@ export class SqliteSearch {
795
795
  d.id,
796
796
  d.path,
797
797
  d.brain_id,
798
- snippet(${attached}.documents_fts, 2, '<b>', '</b>', '…', 32) AS snippet
798
+ snippet(documents_fts, 2, '<b>', '</b>', '…', 32) AS snippet
799
799
  FROM ${attached}.documents_fts f
800
800
  JOIN ${attached}.documents d ON d.id = f.id
801
- WHERE ${attached}.documents_fts MATCH ?
801
+ WHERE documents_fts MATCH ?
802
802
  ORDER BY rank
803
803
  LIMIT ?
804
804
  `)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain-server",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "type": "module",
5
5
  "description": "SQLite FTS5 search server for wicked-brain digital knowledge bases",
6
6
  "keywords": [