Files
letta-code/src/agent/memoryFilesystem.ts
2026-02-12 17:33:51 -08:00

218 lines
6.4 KiB
TypeScript

/**
* Memory filesystem helpers.
*
* With git-backed memory, most sync/hash logic is removed.
* This module retains: directory helpers, tree rendering, and
* the shared memfs initialization logic used by both interactive
* and headless code paths.
*/
import { existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export const MEMORY_FS_ROOT = ".letta";
export const MEMORY_FS_AGENTS_DIR = "agents";
export const MEMORY_FS_MEMORY_DIR = "memory";
export const MEMORY_SYSTEM_DIR = "system";
// ----- Directory helpers -----
export function getMemoryFilesystemRoot(
agentId: string,
homeDir: string = homedir(),
): string {
return join(
homeDir,
MEMORY_FS_ROOT,
MEMORY_FS_AGENTS_DIR,
agentId,
MEMORY_FS_MEMORY_DIR,
);
}
export function getMemorySystemDir(
agentId: string,
homeDir: string = homedir(),
): string {
return join(getMemoryFilesystemRoot(agentId, homeDir), MEMORY_SYSTEM_DIR);
}
export function ensureMemoryFilesystemDirs(
agentId: string,
homeDir: string = homedir(),
): void {
const root = getMemoryFilesystemRoot(agentId, homeDir);
const systemDir = getMemorySystemDir(agentId, homeDir);
if (!existsSync(root)) {
mkdirSync(root, { recursive: true });
}
if (!existsSync(systemDir)) {
mkdirSync(systemDir, { recursive: true });
}
}
// ----- Path helpers -----
export function labelFromRelativePath(relativePath: string): string {
const normalized = relativePath.replace(/\\/g, "/");
return normalized.replace(/\.md$/, "");
}
// ----- Tree rendering -----
/**
* Render a tree visualization of the memory filesystem.
* Takes system labels (under system/) and detached labels (at root).
*/
export function renderMemoryFilesystemTree(
systemLabels: string[],
detachedLabels: string[],
): string {
type TreeNode = { children: Map<string, TreeNode>; isFile: boolean };
const makeNode = (): TreeNode => ({ children: new Map(), isFile: false });
const root = makeNode();
const insertPath = (base: string | null, label: string) => {
const parts = base ? [base, ...label.split("/")] : label.split("/");
let current = root;
for (const [i, partName] of parts.entries()) {
const part = i === parts.length - 1 ? `${partName}.md` : partName;
if (!current.children.has(part)) {
current.children.set(part, makeNode());
}
current = current.children.get(part) as TreeNode;
if (i === parts.length - 1) {
current.isFile = true;
}
}
};
for (const label of systemLabels) {
insertPath(MEMORY_SYSTEM_DIR, label);
}
for (const label of detachedLabels) {
insertPath(null, label);
}
// Always show system/ directory even if empty
if (!root.children.has(MEMORY_SYSTEM_DIR)) {
root.children.set(MEMORY_SYSTEM_DIR, makeNode());
}
const sortedEntries = (node: TreeNode) => {
const entries = Array.from(node.children.entries());
return entries.sort(([nameA, nodeA], [nameB, nodeB]) => {
if (nodeA.isFile !== nodeB.isFile) {
return nodeA.isFile ? 1 : -1;
}
return nameA.localeCompare(nameB);
});
};
const lines: string[] = ["/memory/"];
const render = (node: TreeNode, prefix: string) => {
const entries = sortedEntries(node);
entries.forEach(([name, child], index) => {
const isLast = index === entries.length - 1;
const branch = isLast ? "└──" : "├──";
lines.push(`${prefix}${branch} ${name}${child.isFile ? "" : "/"}`);
if (child.children.size > 0) {
const nextPrefix = `${prefix}${isLast ? " " : "│ "}`;
render(child, nextPrefix);
}
});
};
render(root, "");
return lines.join("\n");
}
// ----- Shared memfs initialization -----
export interface ApplyMemfsFlagsResult {
/** Whether memfs was enabled, disabled, or unchanged */
action: "enabled" | "disabled" | "unchanged";
/** Path to the memory directory (when enabled) */
memoryDir?: string;
/** Summary from git pull (when pullOnExistingRepo is true and repo already existed) */
pullSummary?: string;
}
/**
* Apply --memfs / --no-memfs CLI flags (or /memfs enable) to an agent.
*
* Shared between interactive (index.ts), headless (headless.ts), and
* the /memfs enable command (App.tsx) to avoid duplicating the setup logic.
*
* Steps when enabling:
* 1. Validate Letta Cloud requirement
* 2. Persist memfs setting
* 3. Detach old API-based memory tools
* 4. Update system prompt to include memfs section
* 5. Add git-memory-enabled tag + clone/pull repo
*
* @throws {Error} if Letta Cloud validation fails or git setup fails
*/
export async function applyMemfsFlags(
agentId: string,
memfsFlag: boolean | undefined,
noMemfsFlag: boolean | undefined,
options?: { pullOnExistingRepo?: boolean },
): Promise<ApplyMemfsFlagsResult> {
const { getServerUrl } = await import("./client");
const { settingsManager } = await import("../settings-manager");
// 1. Validate + persist setting
if (memfsFlag) {
const serverUrl = getServerUrl();
if (!serverUrl.includes("api.letta.com")) {
throw new Error(
"--memfs is only available on Letta Cloud (api.letta.com).",
);
}
settingsManager.setMemfsEnabled(agentId, true);
} else if (noMemfsFlag) {
settingsManager.setMemfsEnabled(agentId, false);
}
const isEnabled = settingsManager.isMemfsEnabled(agentId);
// 2. Detach old API-based memory tools when enabling
if (isEnabled && memfsFlag) {
const { detachMemoryTools } = await import("../tools/toolset");
await detachMemoryTools(agentId);
}
// 3. Update system prompt to include/exclude memfs section
if (memfsFlag || noMemfsFlag) {
const { updateAgentSystemPromptMemfs } = await import("./modify");
await updateAgentSystemPromptMemfs(agentId, isEnabled);
}
// 4. Add git tag + clone/pull repo
let pullSummary: string | undefined;
if (isEnabled) {
const { addGitMemoryTag, isGitRepo, cloneMemoryRepo, pullMemory } =
await import("./memoryGit");
await addGitMemoryTag(agentId);
if (!isGitRepo(agentId)) {
await cloneMemoryRepo(agentId);
} else if (options?.pullOnExistingRepo) {
const result = await pullMemory(agentId);
pullSummary = result.summary;
}
}
const action = memfsFlag ? "enabled" : noMemfsFlag ? "disabled" : "unchanged";
return {
action,
memoryDir: isEnabled ? getMemoryFilesystemRoot(agentId) : undefined,
pullSummary,
};
}