clawmem 0.2.3 → 0.2.4

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/AGENTS.md CHANGED
@@ -636,6 +636,15 @@ Symptom: "UserPromptSubmit hook error" on context-surfacing hook (intermittent)
636
636
  → Default hook timeout is 8s (since v0.1.1). If you have an older install, re-run
637
637
  `clawmem setup hooks`. If persistent, restart the watcher: `systemctl --user restart
638
638
  clawmem-watcher.service`. Healthy memory is under 100MB — if 400MB+, restart clears it.
639
+
640
+ Symptom: WSL hangs or becomes unresponsive during long sessions / watcher has 100K+ FDs
641
+ → Pre-v0.2.3: fs.watch(recursive: true) registered inotify watches on EVERY subdirectory,
642
+ including excluded dirs (gits/, node_modules/, .git/). Broad collection paths like
643
+ ~/Projects with 67K subdirs exhausted inotify limits.
644
+ → v0.2.3 fix: watcher walks dir trees at startup, skips excluded subtrees, watches
645
+ non-excluded dirs individually. 500-dir cap per collection path.
646
+ → Diagnosis: `ls /proc/$(pgrep -f "clawmem.*watch")/fd | wc -l` — healthy < 15K.
647
+ → If still high: narrow broad collection paths. See docs/troubleshooting.md for details.
639
648
  ```
640
649
 
641
650
  ## CLI Reference
package/CLAUDE.md CHANGED
@@ -636,6 +636,15 @@ Symptom: "UserPromptSubmit hook error" on context-surfacing hook (intermittent)
636
636
  → Default hook timeout is 8s (since v0.1.1). If you have an older install, re-run
637
637
  `clawmem setup hooks`. If persistent, restart the watcher: `systemctl --user restart
638
638
  clawmem-watcher.service`. Healthy memory is under 100MB — if 400MB+, restart clears it.
639
+
640
+ Symptom: WSL hangs or becomes unresponsive during long sessions / watcher has 100K+ FDs
641
+ → Pre-v0.2.3: fs.watch(recursive: true) registered inotify watches on EVERY subdirectory,
642
+ including excluded dirs (gits/, node_modules/, .git/). Broad collection paths like
643
+ ~/Projects with 67K subdirs exhausted inotify limits.
644
+ → v0.2.3 fix: watcher walks dir trees at startup, skips excluded subtrees, watches
645
+ non-excluded dirs individually. 500-dir cap per collection path.
646
+ → Diagnosis: `ls /proc/$(pgrep -f "clawmem.*watch")/fd | wc -l` — healthy < 15K.
647
+ → If still high: narrow broad collection paths. See docs/troubleshooting.md for details.
639
648
  ```
640
649
 
641
650
  ## CLI Reference
package/SKILL.md CHANGED
@@ -642,6 +642,15 @@ Symptom: "UserPromptSubmit hook error" on context-surfacing hook (intermittent)
642
642
  -> Default hook timeout is 8s (since v0.1.1). If you have an older install, re-run
643
643
  `clawmem setup hooks`. If persistent, restart the watcher: `systemctl --user restart
644
644
  clawmem-watcher.service`. Healthy memory is under 100MB — if 400MB+, restart clears it.
645
+
646
+ Symptom: WSL hangs or becomes unresponsive during long sessions / watcher has 100K+ FDs
647
+ -> Pre-v0.2.3: fs.watch(recursive: true) registered inotify watches on EVERY subdirectory,
648
+ including excluded dirs (gits/, node_modules/, .git/). Broad collection paths like
649
+ ~/Projects with 67K subdirs exhausted inotify limits.
650
+ -> v0.2.3 fix: watcher walks dir trees at startup, skips excluded subtrees, watches
651
+ non-excluded dirs individually. 500-dir cap per collection path.
652
+ -> Diagnosis: `ls /proc/$(pgrep -f "clawmem.*watch")/fd | wc -l` — healthy < 15K.
653
+ -> If still high: narrow broad collection paths. See docs/troubleshooting.md.
645
654
  ```
646
655
 
647
656
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmem",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "On-device context engine and memory for AI agents. Claude Code and OpenClaw. Hooks + MCP server + hybrid RAG search.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/indexer.ts CHANGED
@@ -37,7 +37,7 @@ export interface IndexStats {
37
37
  // Exclusion Rules
38
38
  // =============================================================================
39
39
 
40
- const EXCLUDED_DIRS = new Set([
40
+ export const EXCLUDED_DIRS = new Set([
41
41
  "_PRIVATE",
42
42
  ".clawmem",
43
43
  ".git",
package/src/watcher.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * ClawMem File Watcher - fs.watch with debounce for incremental reindex
3
+ *
4
+ * Walks each directory tree at startup, skipping excluded dirs (gits/,
5
+ * node_modules/, .git/, etc.), and watches only non-excluded directories.
6
+ * This prevents inotify FD exhaustion on trees with large cloned repos.
3
7
  */
4
8
 
5
- import { watch, type WatchEventType } from "fs";
6
- import { shouldExclude } from "./indexer.ts";
9
+ import { watch, readdirSync, statSync, type WatchEventType } from "fs";
10
+ import { join, relative } from "path";
11
+ import { shouldExclude, EXCLUDED_DIRS } from "./indexer.ts";
7
12
 
8
13
  export type WatcherOptions = {
9
14
  debounceMs?: number;
@@ -11,6 +16,42 @@ export type WatcherOptions = {
11
16
  onError?: (error: Error) => void;
12
17
  };
13
18
 
19
+ /**
20
+ * Walk a directory tree, returning only directories that are NOT excluded.
21
+ * Stops recursion into excluded subtrees (gits/, node_modules/, .git/, etc.).
22
+ */
23
+ function walkNonExcludedDirs(root: string): string[] {
24
+ const dirs: string[] = [root];
25
+ const queue: string[] = [root];
26
+
27
+ while (queue.length > 0) {
28
+ const current = queue.pop()!;
29
+ let entries: string[];
30
+ try {
31
+ entries = readdirSync(current);
32
+ } catch {
33
+ continue; // Permission denied or deleted
34
+ }
35
+
36
+ for (const entry of entries) {
37
+ // Skip excluded directory names before stat
38
+ if (EXCLUDED_DIRS.has(entry) || (entry.startsWith(".") && entry !== ".")) continue;
39
+
40
+ const fullPath = join(current, entry);
41
+ try {
42
+ if (statSync(fullPath).isDirectory()) {
43
+ dirs.push(fullPath);
44
+ queue.push(fullPath);
45
+ }
46
+ } catch {
47
+ // stat failed — skip
48
+ }
49
+ }
50
+ }
51
+
52
+ return dirs;
53
+ }
54
+
14
55
  export function startWatcher(
15
56
  directories: string[],
16
57
  options: WatcherOptions
@@ -20,34 +61,54 @@ export function startWatcher(
20
61
  const watchers: ReturnType<typeof watch>[] = [];
21
62
 
22
63
  for (const dir of directories) {
23
- try {
24
- const watcher = watch(dir, { recursive: true }, (event, filename) => {
25
- if (!filename) return;
26
- // Accept .md files (indexing) and .jsonl only within .beads/ (Dolt backend)
27
- const isMd = filename.endsWith(".md");
28
- const isBeadsJsonl = filename.endsWith(".jsonl") && filename.includes(".beads/");
29
- if (!isMd && !isBeadsJsonl) return;
30
- if (shouldExclude(filename)) return;
31
-
32
- const fullPath = `${dir}/${filename}`;
33
- const existing = pending.get(fullPath);
34
- if (existing) clearTimeout(existing);
35
-
36
- pending.set(fullPath, setTimeout(async () => {
37
- pending.delete(fullPath);
38
- try {
39
- await onChanged(fullPath, event);
40
- } catch (err) {
41
- onError?.(err instanceof Error ? err : new Error(String(err)));
42
- }
43
- }, debounceMs));
44
- });
45
- watcher.on("error", (err) => {
46
- onError?.(err instanceof Error ? err : new Error(String(err)));
47
- });
48
- watchers.push(watcher);
49
- } catch (err) {
50
- onError?.(err instanceof Error ? err : new Error(`Failed to watch ${dir}: ${err}`));
64
+ // Walk the tree, skipping excluded dirs — watch each non-excluded dir individually
65
+ const watchableDirs = walkNonExcludedDirs(dir);
66
+
67
+ // Safety: warn and cap if a single collection path produces too many dirs
68
+ const MAX_WATCH_DIRS = 500;
69
+ if (watchableDirs.length > MAX_WATCH_DIRS) {
70
+ console.log(`[watcher] WARNING: ${dir} has ${watchableDirs.length} dirs — capping at ${MAX_WATCH_DIRS} to prevent FD exhaustion. Consider narrowing the collection path.`);
71
+ watchableDirs.length = MAX_WATCH_DIRS;
72
+ } else {
73
+ console.log(`[watcher] ${dir}: watching ${watchableDirs.length} dirs`);
74
+ }
75
+
76
+ for (const watchDir of watchableDirs) {
77
+ try {
78
+ // Non-recursive watch — each dir watched individually
79
+ const watcher = watch(watchDir, (event, filename) => {
80
+ if (!filename) return;
81
+ // Accept .md files (indexing) and .jsonl only within .beads/ (Dolt backend)
82
+ const isMd = filename.endsWith(".md");
83
+ const isBeadsJsonl = filename.endsWith(".jsonl") && filename.includes(".beads/");
84
+ if (!isMd && !isBeadsJsonl) return;
85
+
86
+ const relativeToDirRoot = relative(dir, join(watchDir, filename));
87
+ if (shouldExclude(relativeToDirRoot)) return;
88
+
89
+ const fullPath = join(watchDir, filename);
90
+ const existing = pending.get(fullPath);
91
+ if (existing) clearTimeout(existing);
92
+
93
+ pending.set(fullPath, setTimeout(async () => {
94
+ pending.delete(fullPath);
95
+ try {
96
+ await onChanged(fullPath, event);
97
+ } catch (err) {
98
+ onError?.(err instanceof Error ? err : new Error(String(err)));
99
+ }
100
+ }, debounceMs));
101
+ });
102
+ watcher.on("error", (err) => {
103
+ onError?.(err instanceof Error ? err : new Error(String(err)));
104
+ });
105
+ watchers.push(watcher);
106
+ } catch (err) {
107
+ // Individual dir watch failure is non-fatal — skip it
108
+ if (onError) {
109
+ onError(err instanceof Error ? err : new Error(`Failed to watch ${watchDir}: ${err}`));
110
+ }
111
+ }
51
112
  }
52
113
  }
53
114