From 048e7494bd8ed0ac920de03377f2ea6a969c6b32 Mon Sep 17 00:00:00 2001 From: Kira Date: Tue, 27 Jan 2026 16:17:46 -0500 Subject: [PATCH] fix(memory): support symlinks in memory file discovery walkDir now follows symbolic links using fs.stat() to determine if the target is a file or directory. This enables: - Symlinked markdown files inside memory/ to be indexed - Symlinked directories inside memory/ to be traversed - Graceful handling of dangling symlinks (silently skipped) Fixes an issue where dirent.isFile() returned false for symlinks, causing them to be excluded from memory search indexing. Cross-platform notes: - Works on Linux/macOS/Windows (symlink support varies by platform) - Windows may require elevated permissions to create symlinks, but reading existing symlinks works without special privileges --- src/memory/internal.test.ts | 69 ++++++++++++++++++++++++++++++++++++- src/memory/internal.ts | 20 +++++++++-- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/memory/internal.test.ts b/src/memory/internal.test.ts index 29c698779..a974a3769 100644 --- a/src/memory/internal.test.ts +++ b/src/memory/internal.test.ts @@ -1,6 +1,73 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; -import { chunkMarkdown } from "./internal.js"; +import { chunkMarkdown, listMemoryFiles } from "./internal.js"; + +async function makeTempDir(prefix = "moltbot-memory-test-"): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +describe("listMemoryFiles", () => { + it("includes symlinked markdown files", async () => { + const tempDir = await makeTempDir(); + const memoryDir = path.join(tempDir, "memory"); + await fs.mkdir(memoryDir); + + // Create a real file outside memory/ + const realFile = path.join(tempDir, "notes.md"); + await fs.writeFile(realFile, "# Notes\nSome content"); + + // Create a symlink inside memory/ pointing to the real file + const symlinkPath = path.join(memoryDir, "linked-notes.md"); + await fs.symlink(realFile, symlinkPath); + + const files = await listMemoryFiles(tempDir); + expect(files).toContain(symlinkPath); + + // Cleanup + await fs.rm(tempDir, { recursive: true }); + }); + + it("includes files inside symlinked directories", async () => { + const tempDir = await makeTempDir(); + const memoryDir = path.join(tempDir, "memory"); + await fs.mkdir(memoryDir); + + // Create a real directory with a markdown file outside memory/ + const realDir = path.join(tempDir, "external-notes"); + await fs.mkdir(realDir); + await fs.writeFile(path.join(realDir, "doc.md"), "# Doc"); + + // Create a symlinked directory inside memory/ + const symlinkDir = path.join(memoryDir, "linked-dir"); + await fs.symlink(realDir, symlinkDir); + + const files = await listMemoryFiles(tempDir); + expect(files).toContain(path.join(symlinkDir, "doc.md")); + + // Cleanup + await fs.rm(tempDir, { recursive: true }); + }); + + it("skips dangling symlinks gracefully", async () => { + const tempDir = await makeTempDir(); + const memoryDir = path.join(tempDir, "memory"); + await fs.mkdir(memoryDir); + + // Create a symlink pointing to a non-existent file + const danglingSymlink = path.join(memoryDir, "dangling.md"); + await fs.symlink("/non/existent/path.md", danglingSymlink); + + // Should not throw and should return empty (no valid files) + const files = await listMemoryFiles(tempDir); + expect(files).not.toContain(danglingSymlink); + + // Cleanup + await fs.rm(tempDir, { recursive: true }); + }); +}); describe("chunkMarkdown", () => { it("splits overly long lines into max-sized chunks", () => { diff --git a/src/memory/internal.ts b/src/memory/internal.ts index b68570c35..dcc10832b 100644 --- a/src/memory/internal.ts +++ b/src/memory/internal.ts @@ -50,11 +50,27 @@ async function walkDir(dir: string, files: string[]) { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); - if (entry.isDirectory()) { + + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + + // For symlinks, resolve the target type via fs.stat (follows symlink) + if (entry.isSymbolicLink()) { + try { + const stat = await fs.stat(full); + isDir = stat.isDirectory(); + isFile = stat.isFile(); + } catch { + // Dangling symlink or inaccessible target - skip silently + continue; + } + } + + if (isDir) { await walkDir(full, files); continue; } - if (!entry.isFile()) continue; + if (!isFile) continue; if (!entry.name.endsWith(".md")) continue; files.push(full); }