From 2204753b62148f1b666ce788c5923db5d9de0174 Mon Sep 17 00:00:00 2001 From: jasonxargs-boop Date: Mon, 13 Apr 2026 01:53:20 +0800 Subject: [PATCH] fix(memory-core): fix macOS chokidar glob issue by watching memory dir directly (#64711) * fix(memory-core): fix macOS chokidar glob issue by watching memory dir directly * fix(memory-core): ignore non-markdown memory watch churn * fix(memory-core): allow multimodal watch events * test(memory-core): type watcher ignore callback --------- Co-authored-by: Vincent Koc --- .../src/memory/manager-sync-ops.ts | 26 ++++++++++++++++--- .../src/memory/manager.watcher-config.test.ts | 21 ++++++++++++--- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/extensions/memory-core/src/memory/manager-sync-ops.ts b/extensions/memory-core/src/memory/manager-sync-ops.ts index 6d496327509..23d1df6f47b 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.ts @@ -90,12 +90,29 @@ const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([ const log = createSubsystemLogger("memory"); -function shouldIgnoreMemoryWatchPath(watchPath: string): boolean { +function shouldIgnoreMemoryWatchPath( + watchPath: string, + stats?: { isDirectory?: () => boolean }, + multimodalSettings?: ResolvedMemorySearchConfig["multimodal"], +): boolean { const normalized = path.normalize(watchPath); const parts = normalized .split(path.sep) .map((segment) => normalizeLowercaseStringOrEmpty(segment)); - return parts.some((segment) => IGNORED_MEMORY_WATCH_DIR_NAMES.has(segment)); + if (parts.some((segment) => IGNORED_MEMORY_WATCH_DIR_NAMES.has(segment))) { + return true; + } + if (stats?.isDirectory?.()) { + return false; + } + const extension = normalizeLowercaseStringOrEmpty(path.extname(normalized)); + if (extension.length === 0 || extension === ".md") { + return false; + } + if (!multimodalSettings) { + return true; + } + return classifyMemoryMultimodalPath(normalized, multimodalSettings) === null; } export function runDetachedMemorySync(sync: () => Promise, reason: "interval" | "watch") { @@ -345,7 +362,7 @@ export abstract class MemoryManagerSyncOps { const watchPaths = new Set([ path.join(this.workspaceDir, "MEMORY.md"), path.join(this.workspaceDir, "memory.md"), - path.join(this.workspaceDir, "memory", "**", "*.md"), + path.join(this.workspaceDir, "memory"), ]); const additionalPaths = normalizeExtraMemoryPaths(this.workspaceDir, this.settings.extraPaths); for (const entry of additionalPaths) { @@ -380,7 +397,8 @@ export abstract class MemoryManagerSyncOps { } this.watcher = chokidar.watch(Array.from(watchPaths), { ignoreInitial: true, - ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath), + ignored: (watchPath, stats) => + shouldIgnoreMemoryWatchPath(watchPath, stats, this.settings.multimodal), awaitWriteFinish: { stabilityThreshold: this.settings.sync.watchDebounceMs, pollInterval: 100, diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index b46a5f4159c..9381bb9f865 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -9,6 +9,8 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import type { MemoryIndexManager } from "./index.js"; import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js"; +type WatchIgnoredFn = (watchPath: string, stats?: { isDirectory?: () => boolean }) => boolean; + const { watchMock } = vi.hoisted(() => ({ watchMock: vi.fn(() => ({ on: vi.fn(), @@ -123,7 +125,7 @@ describe("memory watcher config", () => { manager = result.manager as unknown as MemoryIndexManager; } - it("watches markdown globs and ignores dependency directories", async () => { + it("watches the memory directory and ignores non-markdown churn", async () => { await setupWatcherWorkspace({ name: "notes.md", contents: "hello" }); const cfg = createWatcherConfig(); @@ -138,20 +140,25 @@ describe("memory watcher config", () => { expect.arrayContaining([ path.join(workspaceDir, "MEMORY.md"), path.join(workspaceDir, "memory.md"), - path.join(workspaceDir, "memory", "**", "*.md"), + path.join(workspaceDir, "memory"), 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; + const ignored = options.ignored as WatchIgnoredFn | 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.tmp"))).toBe(true); + expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"))).toBe(true); expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.md"))).toBe(false); + expect( + ignored?.(path.join(workspaceDir, "memory", "project"), { isDirectory: () => true }), + ).toBe(false); }); it("watches multimodal extensions with case-insensitive globs", async () => { @@ -166,7 +173,7 @@ describe("memory watcher config", () => { await expectWatcherManager(cfg); expect(watchMock).toHaveBeenCalledTimes(1); - const [watchedPaths] = watchMock.mock.calls[0] as unknown as [ + const [watchedPaths, options] = watchMock.mock.calls[0] as unknown as [ string[], Record, ]; @@ -176,5 +183,11 @@ describe("memory watcher config", () => { path.join(extraDir, "**", "*.[wW][aA][vV]"), ]), ); + + const ignored = options.ignored as WatchIgnoredFn | undefined; + expect(ignored).toBeTypeOf("function"); + expect(ignored?.(path.join(extraDir, "nested", "PHOTO.PNG"))).toBe(false); + expect(ignored?.(path.join(extraDir, "nested", "voice.WAV"))).toBe(false); + expect(ignored?.(path.join(extraDir, "nested", "metadata.json"))).toBe(true); }); });