feat(web): add Memory Palace static viewer (#1061)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
108
src/agent/memoryScanner.ts
Normal file
108
src/agent/memoryScanner.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Shared memory filesystem scanner.
|
||||
*
|
||||
* Recursively scans the on-disk memory directory and returns a flat list of
|
||||
* TreeNode objects that represent files and directories. Used by both the
|
||||
* TUI MemfsTreeViewer and the web-based memory viewer generator.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
export interface TreeNode {
|
||||
name: string; // Display name (e.g., "git.md" or "dev_workflow/")
|
||||
relativePath: string; // Relative path from memory root
|
||||
fullPath: string; // Full filesystem path
|
||||
isDirectory: boolean;
|
||||
depth: number;
|
||||
isLast: boolean;
|
||||
parentIsLast: boolean[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the memory filesystem directory and build tree nodes.
|
||||
*/
|
||||
export function scanMemoryFilesystem(memoryRoot: string): TreeNode[] {
|
||||
const nodes: TreeNode[] = [];
|
||||
|
||||
const scanDir = (dir: string, depth: number, parentIsLast: boolean[]) => {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out hidden files and state file
|
||||
const filtered = entries.filter((name) => !name.startsWith("."));
|
||||
|
||||
// Sort: directories first, "system" always first among dirs, then alphabetically
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
const aPath = join(dir, a);
|
||||
const bPath = join(dir, b);
|
||||
let aIsDir = false;
|
||||
let bIsDir = false;
|
||||
try {
|
||||
aIsDir = statSync(aPath).isDirectory();
|
||||
} catch {}
|
||||
try {
|
||||
bIsDir = statSync(bPath).isDirectory();
|
||||
} catch {}
|
||||
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
|
||||
// "system" directory comes first (only at root level, depth 0)
|
||||
if (aIsDir && bIsDir && depth === 0) {
|
||||
if (a === "system") return -1;
|
||||
if (b === "system") return 1;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
sorted.forEach((name, index) => {
|
||||
const fullPath = join(dir, name);
|
||||
let isDir = false;
|
||||
try {
|
||||
isDir = statSync(fullPath).isDirectory();
|
||||
} catch {
|
||||
return; // Skip if we can't stat
|
||||
}
|
||||
|
||||
const relativePath = relative(memoryRoot, fullPath);
|
||||
const isLast = index === sorted.length - 1;
|
||||
|
||||
nodes.push({
|
||||
name: isDir ? `${name}/` : name,
|
||||
relativePath,
|
||||
fullPath,
|
||||
isDirectory: isDir,
|
||||
depth,
|
||||
isLast,
|
||||
parentIsLast: [...parentIsLast],
|
||||
});
|
||||
|
||||
if (isDir) {
|
||||
scanDir(fullPath, depth + 1, [...parentIsLast, isLast]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
scanDir(memoryRoot, 0, []);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only file nodes (for navigation).
|
||||
*/
|
||||
export function getFileNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
return nodes.filter((n) => !n.isDirectory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content safely, returning empty string on failure.
|
||||
*/
|
||||
export function readFileContent(fullPath: string): string {
|
||||
try {
|
||||
return readFileSync(fullPath, "utf-8");
|
||||
} catch {
|
||||
return "(unable to read file)";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user