From dbcae5b78d9fcbae7479d0fddd1a7bb0a034e923 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 11 Jun 2026 00:12:15 +0900 Subject: [PATCH] fix(memory): keep ignored-name QMD roots watchable Fix QMD watcher ignore handling for explicitly configured roots whose directory names are normally ignored, and prefer the most-specific configured watch root for overlapping collections. Validated with focused QMD/tooling tests, full core support boundary tests, green CI, and ClawSweeper re-review. --- .../src/memory/qmd-manager.test.ts | 94 +++++++++++++++++++ .../memory-core/src/memory/qmd-manager.ts | 36 ++++++- .../check-deadcode-unused-files.test.ts | 1 + 3 files changed, 126 insertions(+), 5 deletions(-) diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index c5ba76211c16..156ac8023f3d 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -123,6 +123,14 @@ function firstWatchOptions(): WatchOptions { return call[1]; } +function firstWatchPaths(): string[] { + const call = watchMock.mock.calls[0] as unknown as [string[], WatchOptions] | undefined; + if (!call) { + throw new Error("Expected watch call"); + } + return call[0]; +} + function firstEmbedLockCall(): EmbedLockCall { const call = withFileLockMock.mock.calls.find((entry) => entry[0].endsWith(path.join("qmd", "embed.lock")), @@ -579,6 +587,92 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("keeps explicit qmd collection roots watchable when their directory name is ignored", async () => { + const rootNames = ["build", "dist", "vendor", ".cache"]; + const roots = rootNames.map((name) => path.join(workspaceDir, name)); + 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 }, + }, + }, + list: [{ id: agentId, default: true, workspace: workspaceDir }], + }, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 0, onBoot: false }, + paths: roots.map((root) => ({ + path: root, + pattern: "**/*.md", + name: path.basename(root), + })), + }, + }, + } as OpenClawConfig; + + const { manager } = await createManager({ mode: "full" }); + expect(watchMock).toHaveBeenCalledTimes(1); + expect(firstWatchPaths().toSorted()).toEqual( + roots.map((root) => path.join(root, "**/*.md")).toSorted(), + ); + const ignored = firstWatchOptions().ignored; + for (const root of roots) { + expect(ignored?.(root)).toBe(false); + expect(ignored?.(path.join(root, "note.md"))).toBe(false); + expect(ignored?.(path.join(root, "..notes", "daily.md"))).toBe(false); + expect(ignored?.(path.join(root, "notes", "daily.md"))).toBe(false); + expect(ignored?.(path.join(root, "node_modules", "pkg", "note.md"))).toBe(true); + expect(ignored?.(path.join(root, "build", "artifact.md"))).toBe(true); + } + + await manager.close(); + }); + + it("prefers a nested explicit qmd collection root over a broader watched root", async () => { + const nestedRoot = path.join(workspaceDir, "build"); + 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 }, + }, + }, + list: [{ id: agentId, default: true, workspace: workspaceDir }], + }, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 0, onBoot: false }, + paths: [ + { path: workspaceDir, pattern: "**/*.md", name: "workspace" }, + { path: nestedRoot, pattern: "**/*.md", name: "build" }, + ], + }, + }, + } as OpenClawConfig; + + const { manager } = await createManager({ mode: "full" }); + const ignored = firstWatchOptions().ignored; + expect(ignored?.(path.join(nestedRoot, "note.md"))).toBe(false); + expect(ignored?.(path.join(nestedRoot, "..notes", "daily.md"))).toBe(false); + expect(ignored?.(path.join(nestedRoot, "node_modules", "pkg", "note.md"))).toBe(true); + expect(ignored?.(path.join(workspaceDir, "node_modules", "pkg", "note.md"))).toBe(true); + + await manager.close(); + }); + it("delays qmd watch sync until changed file stats settle", async () => { vi.useFakeTimers(); cfg = { diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 994b1c8056d3..69e59b75ac98 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -232,14 +232,37 @@ function resolveQmdStoreWriteLockOptions(updateTimeoutMs: number, embedTimeoutMs ); } -function shouldIgnoreMemoryWatchPath(watchPath: string): boolean { - const normalized = path.normalize(watchPath); - const parts = normalized +function hasIgnoredMemoryWatchSegment(relativePath: string): boolean { + const parts = relativePath .split(path.sep) - .map((segment) => normalizeLowercaseStringOrEmpty(segment)); + .map((segment) => normalizeLowercaseStringOrEmpty(segment)) + .filter(Boolean); return parts.some((segment) => IGNORED_MEMORY_WATCH_DIR_NAMES.has(segment)); } +function shouldIgnoreMemoryWatchPath(watchPath: string, roots: readonly string[]): boolean { + const normalized = path.normalize(watchPath); + let matchedRelative: string | null = null; + let matchedRootLength = -1; + for (const watchRoot of roots) { + const normalizedRoot = path.normalize(watchRoot); + const relative = path.relative(normalizedRoot, normalized); + if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) { + if (normalizedRoot.length > matchedRootLength) { + matchedRelative = relative; + matchedRootLength = normalizedRoot.length; + } + } + } + if (matchedRelative !== null) { + if (matchedRelative === "") { + return false; + } + return hasIgnoredMemoryWatchSegment(matchedRelative); + } + return hasIgnoredMemoryWatchSegment(normalized); +} + type CollectionRoot = { path: string; kind: MemorySource; @@ -1653,10 +1676,12 @@ export class QmdMemoryManager implements MemorySearchManager { return; } const watchPaths = new Set(); + const watchRoots = new Set(); for (const collection of this.qmd.collections) { if (collection.kind === "sessions") { continue; } + watchRoots.add(path.normalize(collection.path)); watchPaths.add(this.resolveCollectionWatchPath(collection)); } if (watchPaths.size === 0) { @@ -1665,9 +1690,10 @@ export class QmdMemoryManager implements MemorySearchManager { const watchPathList = Array.from(watchPaths); const startTime = Date.now(); log.info(`qmd watcher starting for agent "${this.agentId}" paths=${watchPathList.length}`); + const watchRootList = Array.from(watchRoots); const watcher = chokidar.watch(watchPathList, { ignoreInitial: true, - ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath), + ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath, watchRootList), }); this.watcher = watcher; const markDirty = (watchPath?: string, stats?: MemoryWatchEventStats) => { diff --git a/test/scripts/check-deadcode-unused-files.test.ts b/test/scripts/check-deadcode-unused-files.test.ts index 504a67155d51..f9afc9f5a05a 100644 --- a/test/scripts/check-deadcode-unused-files.test.ts +++ b/test/scripts/check-deadcode-unused-files.test.ts @@ -198,6 +198,7 @@ src/a.ts: src/a.ts const resultPromise = runKnipUnusedFiles({ env: { PATH: "" }, npmExecPath: "", + platform: "linux", spawnCommand(command: string, args: string[], options: unknown) { calls.push({ args, command, options }); const child = new FakeKnipProcess();