/** * 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; 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; } export interface ApplyMemfsFlagsOptions { pullOnExistingRepo?: boolean; agentTags?: string[]; /** Skip the system prompt update (when the agent was created with the correct mode). */ skipPromptUpdate?: boolean; } /** * 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 toggling: * 1. Validate Letta Cloud requirement (for explicit enable) * 2. Reconcile system prompt to the target memory mode * 3. Persist memfs setting locally * 4. Detach old API-based memory tools (when enabling) * 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?: ApplyMemfsFlagsOptions, ): Promise { const { settingsManager } = await import("../settings-manager"); // Validate explicit enable on supported backend. if (memfsFlag && !(await isLettaCloud())) { throw new Error( "--memfs is only available on Letta Cloud (api.letta.com).", ); } const hasExplicitToggle = Boolean(memfsFlag || noMemfsFlag); const localMemfsEnabled = settingsManager.isMemfsEnabled(agentId); const { GIT_MEMORY_ENABLED_TAG } = await import("./memoryGit"); const shouldAutoEnableFromTag = !hasExplicitToggle && !localMemfsEnabled && Boolean(options?.agentTags?.includes(GIT_MEMORY_ENABLED_TAG)); const targetEnabled = memfsFlag ? true : noMemfsFlag ? false : shouldAutoEnableFromTag ? true : localMemfsEnabled; // 2. Reconcile system prompt first, then persist local memfs setting. if (hasExplicitToggle || shouldAutoEnableFromTag) { if (!options?.skipPromptUpdate) { const { updateAgentSystemPromptMemfs } = await import("./modify"); const promptUpdate = await updateAgentSystemPromptMemfs( agentId, targetEnabled, ); if (!promptUpdate.success) { throw new Error(promptUpdate.message); } } settingsManager.setMemfsEnabled(agentId, targetEnabled); } const isEnabled = hasExplicitToggle || shouldAutoEnableFromTag ? targetEnabled : settingsManager.isMemfsEnabled(agentId); // 3. Detach old API-based memory tools when enabling. if (isEnabled && (memfsFlag || shouldAutoEnableFromTag)) { const { detachMemoryTools } = await import("../tools/toolset"); await detachMemoryTools(agentId); // Migration (LET-7353): Remove legacy skills/loaded_skills blocks. // These blocks are no longer used — skills are now injected via system reminders. const { getClient } = await import("./client"); const client = await getClient(); for (const label of ["skills", "loaded_skills"]) { try { const block = await client.agents.blocks.retrieve(label, { agent_id: agentId, }); if (block) { await client.agents.blocks.detach(block.id, { agent_id: agentId, }); await client.blocks.delete(block.id); } } catch { // Block doesn't exist or already removed, skip } } } // Keep server-side state aligned with explicit disable. if (noMemfsFlag) { const { removeGitMemoryTag } = await import("./memoryGit"); await removeGitMemoryTag(agentId); } // 4. Add git tag + clone/pull repo. let pullSummary: string | undefined; if (isEnabled) { const { addGitMemoryTag, isGitRepo, cloneMemoryRepo, pullMemory } = await import("./memoryGit"); await addGitMemoryTag( agentId, options?.agentTags ? { tags: options.agentTags } : undefined, ); if (!isGitRepo(agentId)) { await cloneMemoryRepo(agentId); } else if (options?.pullOnExistingRepo) { const result = await pullMemory(agentId); pullSummary = result.summary; } } const action = memfsFlag || shouldAutoEnableFromTag ? "enabled" : noMemfsFlag ? "disabled" : "unchanged"; return { action, memoryDir: isEnabled ? getMemoryFilesystemRoot(agentId) : undefined, pullSummary, }; } /** * Whether the current server is Letta Cloud (or local memfs testing is enabled). */ export async function isLettaCloud(): Promise { const { getServerUrl } = await import("./client"); const serverUrl = getServerUrl(); return ( serverUrl.includes("api.letta.com") || process.env.LETTA_MEMFS_LOCAL === "1" ); } /** * Enable memfs for a newly created agent if on Letta Cloud. * Non-fatal: logs a warning on failure. Skips on self-hosted. * * Skips the system prompt update since callers are expected to create * the agent with the correct memory mode upfront. */ export async function enableMemfsIfCloud(agentId: string): Promise { if (!(await isLettaCloud())) return; try { await applyMemfsFlags(agentId, true, undefined, { skipPromptUpdate: true, }); } catch (error) { console.warn( `Warning: Could not enable memfs for new agent: ${error instanceof Error ? error.message : String(error)}`, ); } }