diff --git a/src/memory/manager-sync-ops.ts b/src/memory/manager-sync-ops.ts index e04deeefa..ac2969333 100644 --- a/src/memory/manager-sync-ops.ts +++ b/src/memory/manager-sync-ops.ts @@ -55,9 +55,24 @@ const EMBEDDING_CACHE_TABLE = "embedding_cache"; const SESSION_DIRTY_DEBOUNCE_MS = 5000; const SESSION_DELTA_READ_CHUNK_BYTES = 64 * 1024; const VECTOR_LOAD_TIMEOUT_MS = 30_000; +const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([ + ".git", + "node_modules", + ".pnpm-store", + ".venv", + "venv", + ".tox", + "__pycache__", +]); const log = createSubsystemLogger("memory"); +function shouldIgnoreMemoryWatchPath(watchPath: string): boolean { + const normalized = path.normalize(watchPath); + const parts = normalized.split(path.sep).map((segment) => segment.trim().toLowerCase()); + return parts.some((segment) => IGNORED_MEMORY_WATCH_DIR_NAMES.has(segment)); +} + class MemoryManagerSyncOps { [key: string]: any; private async ensureVectorReady(dimensions?: number): Promise { @@ -263,24 +278,32 @@ class MemoryManagerSyncOps { if (!this.sources.has("memory") || !this.settings.sync.watch || this.watcher) { return; } - const additionalPaths = normalizeExtraMemoryPaths(this.workspaceDir, this.settings.extraPaths) - .map((entry) => { - try { - const stat = fsSync.lstatSync(entry); - return stat.isSymbolicLink() ? null : entry; - } catch { - return null; - } - }) - .filter((entry): entry is string => Boolean(entry)); const watchPaths = new Set([ path.join(this.workspaceDir, "MEMORY.md"), path.join(this.workspaceDir, "memory.md"), - path.join(this.workspaceDir, "memory"), - ...additionalPaths, + path.join(this.workspaceDir, "memory", "**", "*.md"), ]); + const additionalPaths = normalizeExtraMemoryPaths(this.workspaceDir, this.settings.extraPaths); + for (const entry of additionalPaths) { + try { + const stat = fsSync.lstatSync(entry); + if (stat.isSymbolicLink()) { + continue; + } + if (stat.isDirectory()) { + watchPaths.add(path.join(entry, "**", "*.md")); + continue; + } + if (stat.isFile() && entry.toLowerCase().endsWith(".md")) { + watchPaths.add(entry); + } + } catch { + // Skip missing/unreadable additional paths. + } + } this.watcher = chokidar.watch(Array.from(watchPaths), { ignoreInitial: true, + ignored: (watchPath) => shouldIgnoreMemoryWatchPath(String(watchPath)), awaitWriteFinish: { stabilityThreshold: this.settings.sync.watchDebounceMs, pollInterval: 100, diff --git a/src/memory/manager.watcher-config.test.ts b/src/memory/manager.watcher-config.test.ts new file mode 100644 index 000000000..8f45f256d --- /dev/null +++ b/src/memory/manager.watcher-config.test.ts @@ -0,0 +1,105 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; + +const { watchMock } = vi.hoisted(() => ({ + watchMock: vi.fn(() => ({ + on: vi.fn(), + close: vi.fn(async () => undefined), + })), +})); + +vi.mock("chokidar", () => ({ + default: { watch: watchMock }, + watch: watchMock, +})); + +vi.mock("./sqlite-vec.js", () => ({ + loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }), +})); + +vi.mock("./embeddings.js", () => ({ + createEmbeddingProvider: async () => ({ + requestedProvider: "openai", + provider: { + id: "mock", + model: "mock-embed", + embedQuery: async () => [1, 0], + embedBatch: async (texts: string[]) => texts.map(() => [1, 0]), + }, + }), +})); + +describe("memory watcher config", () => { + let manager: MemoryIndexManager | null = null; + let workspaceDir = ""; + let extraDir = ""; + + afterEach(async () => { + watchMock.mockClear(); + if (manager) { + await manager.close(); + manager = null; + } + if (workspaceDir) { + await fs.rm(workspaceDir, { recursive: true, force: true }); + workspaceDir = ""; + extraDir = ""; + } + }); + + it("watches markdown globs and ignores dependency directories", async () => { + workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-watch-")); + extraDir = path.join(workspaceDir, "extra"); + await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); + await fs.mkdir(extraDir, { recursive: true }); + await fs.writeFile(path.join(extraDir, "notes.md"), "hello"); + + const cfg = { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: path.join(workspaceDir, "index.sqlite"), vector: { enabled: false } }, + sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false }, + query: { minScore: 0, hybrid: { enabled: false } }, + extraPaths: [extraDir], + }, + }, + list: [{ id: "main", default: true }], + }, + }; + + const result = await getMemorySearchManager({ cfg, agentId: "main" }); + expect(result.manager).not.toBeNull(); + if (!result.manager) { + throw new Error("manager missing"); + } + manager = result.manager; + + expect(watchMock).toHaveBeenCalledTimes(1); + const [watchedPaths, options] = watchMock.mock.calls[0] as [string[], Record]; + expect(watchedPaths).toEqual( + expect.arrayContaining([ + path.join(workspaceDir, "MEMORY.md"), + path.join(workspaceDir, "memory.md"), + path.join(workspaceDir, "memory", "**", "*.md"), + path.join(extraDir, "**", "*.md"), + ]), + ); + expect(options.ignoreInitial).toBe(true); + expect(options.awaitWriteFinish).toEqual({ stabilityThreshold: 25, pollInterval: 100 }); + + const ignored = options.ignored as ((watchPath: string) => boolean) | undefined; + expect(ignored).toBeTypeOf("function"); + expect(ignored?.(path.join(workspaceDir, "memory", "node_modules", "pkg", "index.md"))).toBe( + true, + ); + expect(ignored?.(path.join(workspaceDir, "memory", ".venv", "lib", "python.md"))).toBe(true); + expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.md"))).toBe(false); + }); +});