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
This commit is contained in:
Kira 2026-01-27 16:17:46 -05:00
parent 2930ebfd43
commit 048e7494bd
2 changed files with 86 additions and 3 deletions

View File

@ -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<string> {
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", () => {

View File

@ -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);
}