From 7ab97e404d2dfdd01755ff4552386f2329e2d204 Mon Sep 17 00:00:00 2001 From: Kevin Lin Date: Mon, 26 Jan 2026 21:48:57 -0800 Subject: [PATCH] feat: sync memory with filesystem tree (#685) Co-authored-by: Letta Co-authored-by: cpacker --- src/agent/memory.ts | 8 +- src/agent/memoryFilesystem.ts | 753 ++++++++++++++++++ src/agent/modify.ts | 46 ++ src/agent/promptAssets.ts | 4 + src/agent/prompts/memory_filesystem.mdx | 7 + src/agent/prompts/system_prompt.txt | 2 +- src/agent/prompts/system_prompt_memfs.txt | 34 + src/cli/App.tsx | 505 +++++++++++- src/cli/commands/registry.ts | 17 + src/cli/components/InlineQuestionApproval.tsx | 10 +- src/cli/components/ModelSelector.tsx | 5 +- src/cli/components/QuestionDialog.tsx | 236 ------ src/headless.ts | 36 + src/settings-manager.ts | 156 +++- src/tests/settings-manager.test.ts | 162 ++++ src/tools/toolset.ts | 83 ++ 16 files changed, 1815 insertions(+), 249 deletions(-) create mode 100644 src/agent/memoryFilesystem.ts create mode 100644 src/agent/prompts/memory_filesystem.mdx create mode 100644 src/agent/prompts/system_prompt_memfs.txt delete mode 100644 src/cli/components/QuestionDialog.tsx diff --git a/src/agent/memory.ts b/src/agent/memory.ts index f469f18..6fcf4b8 100644 --- a/src/agent/memory.ts +++ b/src/agent/memory.ts @@ -15,9 +15,9 @@ export const GLOBAL_BLOCK_LABELS = ["persona", "human"] as const; * Block labels that are stored per-project (local to the current directory). */ export const PROJECT_BLOCK_LABELS = [ - "project", "skills", "loaded_skills", + "memory_filesystem", ] as const; /** @@ -37,7 +37,11 @@ export type MemoryBlockLabel = (typeof MEMORY_BLOCK_LABELS)[number]; * Block labels that should be read-only (agent cannot modify via memory tools). * These blocks are managed by specific tools (e.g., Skill tool for skills/loaded_skills). */ -export const READ_ONLY_BLOCK_LABELS = ["skills", "loaded_skills"] as const; +export const READ_ONLY_BLOCK_LABELS = [ + "skills", + "loaded_skills", + "memory_filesystem", +] as const; /** * Block labels that should be isolated per-conversation. diff --git a/src/agent/memoryFilesystem.ts b/src/agent/memoryFilesystem.ts new file mode 100644 index 0000000..39881ff --- /dev/null +++ b/src/agent/memoryFilesystem.ts @@ -0,0 +1,753 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { readdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, relative } from "node:path"; + +import type { Block } from "@letta-ai/letta-client/resources/agents/blocks"; +import { getClient } from "./client"; + +export const MEMORY_FILESYSTEM_BLOCK_LABEL = "memory_filesystem"; +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"; +export const MEMORY_USER_DIR = "user"; +export const MEMORY_FS_STATE_FILE = ".sync-state.json"; + +const MANAGED_BLOCK_LABELS = new Set([MEMORY_FILESYSTEM_BLOCK_LABEL]); + +type SyncState = { + systemBlocks: Record; + systemFiles: Record; + userBlocks: Record; + userFiles: Record; + userBlockIds: Record; + lastSync: string | null; +}; + +export type MemorySyncConflict = { + label: string; + blockValue: string | null; + fileValue: string | null; +}; + +export type MemorySyncResult = { + updatedBlocks: string[]; + createdBlocks: string[]; + deletedBlocks: string[]; + updatedFiles: string[]; + createdFiles: string[]; + deletedFiles: string[]; + conflicts: MemorySyncConflict[]; +}; + +export type MemorySyncResolution = { + label: string; + resolution: "file" | "block"; +}; + +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 getMemoryUserDir( + agentId: string, + homeDir: string = homedir(), +): string { + return join(getMemoryFilesystemRoot(agentId, homeDir), MEMORY_USER_DIR); +} + +function getMemoryStatePath( + agentId: string, + homeDir: string = homedir(), +): string { + return join(getMemoryFilesystemRoot(agentId, homeDir), MEMORY_FS_STATE_FILE); +} + +export function ensureMemoryFilesystemDirs( + agentId: string, + homeDir: string = homedir(), +): void { + const root = getMemoryFilesystemRoot(agentId, homeDir); + const systemDir = getMemorySystemDir(agentId, homeDir); + const userDir = getMemoryUserDir(agentId, homeDir); + + if (!existsSync(root)) { + mkdirSync(root, { recursive: true }); + } + if (!existsSync(systemDir)) { + mkdirSync(systemDir, { recursive: true }); + } + if (!existsSync(userDir)) { + mkdirSync(userDir, { recursive: true }); + } +} + +function hashContent(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +function loadSyncState( + agentId: string, + homeDir: string = homedir(), +): SyncState { + const statePath = getMemoryStatePath(agentId, homeDir); + if (!existsSync(statePath)) { + return { + systemBlocks: {}, + systemFiles: {}, + userBlocks: {}, + userFiles: {}, + userBlockIds: {}, + lastSync: null, + }; + } + + try { + const raw = readFileSync(statePath, "utf-8"); + const parsed = JSON.parse(raw) as Partial & { + blocks?: Record; + files?: Record; + }; + return { + systemBlocks: parsed.systemBlocks || parsed.blocks || {}, + systemFiles: parsed.systemFiles || parsed.files || {}, + userBlocks: parsed.userBlocks || {}, + userFiles: parsed.userFiles || {}, + userBlockIds: parsed.userBlockIds || {}, + lastSync: parsed.lastSync || null, + }; + } catch { + return { + systemBlocks: {}, + systemFiles: {}, + userBlocks: {}, + userFiles: {}, + userBlockIds: {}, + lastSync: null, + }; + } +} + +async function saveSyncState( + state: SyncState, + agentId: string, + homeDir: string = homedir(), +) { + const statePath = getMemoryStatePath(agentId, homeDir); + await writeFile(statePath, JSON.stringify(state, null, 2)); +} + +async function scanMdFiles(dir: string, baseDir = dir): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const results: string[] = []; + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...(await scanMdFiles(fullPath, baseDir))); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + results.push(relative(baseDir, fullPath)); + } + } + + return results; +} + +function labelFromRelativePath(relativePath: string): string { + const normalized = relativePath.replace(/\\/g, "/"); + return normalized.replace(/\.md$/, ""); +} + +async function readMemoryFiles( + dir: string, +): Promise> { + const files = await scanMdFiles(dir); + const entries = new Map(); + + for (const relativePath of files) { + const label = labelFromRelativePath(relativePath); + const fullPath = join(dir, relativePath); + const content = await readFile(fullPath, "utf-8"); + entries.set(label, { content, path: fullPath }); + } + + return entries; +} + +async function ensureFilePath(filePath: string) { + const parent = dirname(filePath); + if (!existsSync(parent)) { + mkdirSync(parent, { recursive: true }); + } +} + +async function writeMemoryFile(dir: string, label: string, content: string) { + const filePath = join(dir, `${label}.md`); + await ensureFilePath(filePath); + await writeFile(filePath, content, "utf-8"); +} + +async function deleteMemoryFile(dir: string, label: string) { + const filePath = join(dir, `${label}.md`); + if (existsSync(filePath)) { + await unlink(filePath); + } +} + +async function fetchAgentBlocks(agentId: string): Promise { + const client = await getClient(); + const blocksResponse = await client.agents.blocks.list(agentId); + const blocks = Array.isArray(blocksResponse) + ? blocksResponse + : (blocksResponse as { items?: Block[] }).items || + (blocksResponse as { blocks?: Block[] }).blocks || + []; + + return blocks; +} + +export function renderMemoryFilesystemTree( + systemLabels: string[], + userLabels: string[], +): string { + type TreeNode = { children: Map; isFile: boolean }; + + const makeNode = (): TreeNode => ({ children: new Map(), isFile: false }); + const root = makeNode(); + + const insertPath = (base: string, label: string) => { + const parts = [base, ...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 userLabels) { + insertPath(MEMORY_USER_DIR, label); + } + + if (!root.children.has(MEMORY_SYSTEM_DIR)) { + root.children.set(MEMORY_SYSTEM_DIR, makeNode()); + } + if (!root.children.has(MEMORY_USER_DIR)) { + root.children.set(MEMORY_USER_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"); +} + +function buildStateHashes( + systemBlocks: Map, + systemFiles: Map, + userBlocks: Map, + userFiles: Map, + userBlockIds: Record, +): SyncState { + const systemBlockHashes: Record = {}; + const systemFileHashes: Record = {}; + const userBlockHashes: Record = {}; + const userFileHashes: Record = {}; + + systemBlocks.forEach((block, label) => { + systemBlockHashes[label] = hashContent(block.value || ""); + }); + + systemFiles.forEach((file, label) => { + systemFileHashes[label] = hashContent(file.content || ""); + }); + + userBlocks.forEach((block, label) => { + userBlockHashes[label] = hashContent(block.value || ""); + }); + + userFiles.forEach((file, label) => { + userFileHashes[label] = hashContent(file.content || ""); + }); + + return { + systemBlocks: systemBlockHashes, + systemFiles: systemFileHashes, + userBlocks: userBlockHashes, + userFiles: userFileHashes, + userBlockIds, + lastSync: new Date().toISOString(), + }; +} + +export async function syncMemoryFilesystem( + agentId: string, + options: { homeDir?: string; resolutions?: MemorySyncResolution[] } = {}, +): Promise { + const homeDir = options.homeDir ?? homedir(); + ensureMemoryFilesystemDirs(agentId, homeDir); + + const systemDir = getMemorySystemDir(agentId, homeDir); + const userDir = getMemoryUserDir(agentId, homeDir); + const systemFiles = await readMemoryFiles(systemDir); + const userFiles = await readMemoryFiles(userDir); + systemFiles.delete(MEMORY_FILESYSTEM_BLOCK_LABEL); + + const attachedBlocks = await fetchAgentBlocks(agentId); + const systemBlockMap = new Map( + attachedBlocks + .filter((block) => block.label) + .map((block) => [block.label as string, block]), + ); + systemBlockMap.delete(MEMORY_FILESYSTEM_BLOCK_LABEL); + + const lastState = loadSyncState(agentId, homeDir); + const conflicts: MemorySyncConflict[] = []; + + const updatedBlocks: string[] = []; + const createdBlocks: string[] = []; + const deletedBlocks: string[] = []; + const updatedFiles: string[] = []; + const createdFiles: string[] = []; + const deletedFiles: string[] = []; + + const resolutions = new Map( + (options.resolutions ?? []).map((resolution) => [ + resolution.label, + resolution, + ]), + ); + + const client = await getClient(); + + const userBlockIds = { ...lastState.userBlockIds }; + const userBlockMap = new Map(); + for (const [label, blockId] of Object.entries(userBlockIds)) { + try { + const block = await client.blocks.retrieve(blockId); + userBlockMap.set(label, block as Block); + } catch { + delete userBlockIds[label]; + } + } + + const systemLabels = new Set([ + ...Array.from(systemFiles.keys()), + ...Array.from(systemBlockMap.keys()), + ...Object.keys(lastState.systemBlocks), + ...Object.keys(lastState.systemFiles), + ]); + + for (const label of Array.from(systemLabels).sort()) { + if (MANAGED_BLOCK_LABELS.has(label)) { + continue; + } + + const fileEntry = systemFiles.get(label); + const blockEntry = systemBlockMap.get(label); + + const fileHash = fileEntry ? hashContent(fileEntry.content) : null; + const blockHash = blockEntry ? hashContent(blockEntry.value || "") : null; + + const lastFileHash = lastState.systemFiles[label] || null; + const lastBlockHash = lastState.systemBlocks[label] || null; + + const fileChanged = fileHash !== lastFileHash; + const blockChanged = blockHash !== lastBlockHash; + + const resolution = resolutions.get(label); + + if (fileEntry && !blockEntry) { + if (lastBlockHash && !fileChanged) { + // Block was deleted elsewhere; delete file. + await deleteMemoryFile(systemDir, label); + deletedFiles.push(label); + continue; + } + + // Create block from file + const createdBlock = await client.blocks.create({ + label, + value: fileEntry.content, + description: `Memory block: ${label}`, + limit: 20000, + }); + if (createdBlock.id) { + await client.agents.blocks.attach(createdBlock.id, { + agent_id: agentId, + }); + } + createdBlocks.push(label); + continue; + } + + if (!fileEntry && blockEntry) { + if (lastFileHash && !blockChanged) { + // File deleted, block unchanged -> delete block + if (blockEntry.id) { + await client.agents.blocks.detach(blockEntry.id, { + agent_id: agentId, + }); + } + deletedBlocks.push(label); + continue; + } + + // Create file from block + await writeMemoryFile(systemDir, label, blockEntry.value || ""); + createdFiles.push(label); + continue; + } + + if (!fileEntry || !blockEntry) { + continue; + } + + if (fileChanged && blockChanged && !resolution) { + conflicts.push({ + label, + blockValue: blockEntry.value || "", + fileValue: fileEntry.content, + }); + continue; + } + + if (resolution?.resolution === "file") { + await client.agents.blocks.update(label, { + agent_id: agentId, + value: fileEntry.content, + }); + updatedBlocks.push(label); + continue; + } + + if (resolution?.resolution === "block") { + await writeMemoryFile(systemDir, label, blockEntry.value || ""); + updatedFiles.push(label); + continue; + } + + if (fileChanged && !blockChanged) { + await client.agents.blocks.update(label, { + agent_id: agentId, + value: fileEntry.content, + }); + updatedBlocks.push(label); + continue; + } + + if (!fileChanged && blockChanged) { + await writeMemoryFile(systemDir, label, blockEntry.value || ""); + updatedFiles.push(label); + } + } + + const userLabels = new Set([ + ...Array.from(userFiles.keys()), + ...Array.from(userBlockMap.keys()), + ...Object.keys(lastState.userBlocks), + ...Object.keys(lastState.userFiles), + ]); + + for (const label of Array.from(userLabels).sort()) { + const fileEntry = userFiles.get(label); + const blockEntry = userBlockMap.get(label); + + const fileHash = fileEntry ? hashContent(fileEntry.content) : null; + const blockHash = blockEntry ? hashContent(blockEntry.value || "") : null; + + const lastFileHash = lastState.userFiles[label] || null; + const lastBlockHash = lastState.userBlocks[label] || null; + + const fileChanged = fileHash !== lastFileHash; + const blockChanged = blockHash !== lastBlockHash; + + const resolution = resolutions.get(label); + + if (fileEntry && !blockEntry) { + if (lastBlockHash && !fileChanged) { + // Block was deleted elsewhere; delete file. + await deleteMemoryFile(userDir, label); + deletedFiles.push(label); + delete userBlockIds[label]; + continue; + } + + const createdBlock = await client.blocks.create({ + label, + value: fileEntry.content, + description: `Memory block: ${label}`, + limit: 20000, + }); + if (createdBlock.id) { + userBlockIds[label] = createdBlock.id; + userBlockMap.set(label, createdBlock as Block); + } + createdBlocks.push(label); + continue; + } + + if (!fileEntry && blockEntry) { + if (lastFileHash && !blockChanged) { + // File deleted, block unchanged -> delete block + if (blockEntry.id) { + await client.blocks.delete(blockEntry.id); + } + deletedBlocks.push(label); + delete userBlockIds[label]; + continue; + } + + await writeMemoryFile(userDir, label, blockEntry.value || ""); + createdFiles.push(label); + continue; + } + + if (!fileEntry || !blockEntry) { + continue; + } + + if (fileChanged && blockChanged && !resolution) { + conflicts.push({ + label, + blockValue: blockEntry.value || "", + fileValue: fileEntry.content, + }); + continue; + } + + if (resolution?.resolution === "file") { + if (blockEntry.id) { + await client.blocks.update(blockEntry.id, { + value: fileEntry.content, + label, + }); + } + updatedBlocks.push(label); + continue; + } + + if (resolution?.resolution === "block") { + await writeMemoryFile(userDir, label, blockEntry.value || ""); + updatedFiles.push(label); + continue; + } + + if (fileChanged && !blockChanged) { + if (blockEntry.id) { + await client.blocks.update(blockEntry.id, { + value: fileEntry.content, + label, + }); + } + updatedBlocks.push(label); + continue; + } + + if (!fileChanged && blockChanged) { + await writeMemoryFile(userDir, label, blockEntry.value || ""); + updatedFiles.push(label); + } + } + + if (conflicts.length === 0) { + const updatedBlocksList = await fetchAgentBlocks(agentId); + const updatedSystemBlockMap = new Map( + updatedBlocksList + .filter( + (block) => + block.label && block.label !== MEMORY_FILESYSTEM_BLOCK_LABEL, + ) + .map((block) => [block.label as string, { value: block.value || "" }]), + ); + + const updatedSystemFilesMap = await readMemoryFiles(systemDir); + updatedSystemFilesMap.delete(MEMORY_FILESYSTEM_BLOCK_LABEL); + const updatedUserFilesMap = await readMemoryFiles(userDir); + const refreshedUserBlocks = new Map(); + + for (const [label, blockId] of Object.entries(userBlockIds)) { + try { + const block = await client.blocks.retrieve(blockId); + refreshedUserBlocks.set(label, { value: block.value || "" }); + } catch { + delete userBlockIds[label]; + } + } + + const nextState = buildStateHashes( + updatedSystemBlockMap, + updatedSystemFilesMap, + refreshedUserBlocks, + updatedUserFilesMap, + userBlockIds, + ); + await saveSyncState(nextState, agentId, homeDir); + } + + return { + updatedBlocks, + createdBlocks, + deletedBlocks, + updatedFiles, + createdFiles, + deletedFiles, + conflicts, + }; +} + +export async function updateMemoryFilesystemBlock( + agentId: string, + homeDir: string = homedir(), +) { + const systemDir = getMemorySystemDir(agentId, homeDir); + const userDir = getMemoryUserDir(agentId, homeDir); + + const systemFiles = await readMemoryFiles(systemDir); + const userFiles = await readMemoryFiles(userDir); + + const tree = renderMemoryFilesystemTree( + Array.from(systemFiles.keys()).filter( + (label) => label !== MEMORY_FILESYSTEM_BLOCK_LABEL, + ), + Array.from(userFiles.keys()), + ); + + const client = await getClient(); + await client.agents.blocks.update(MEMORY_FILESYSTEM_BLOCK_LABEL, { + agent_id: agentId, + value: tree, + }); + + await writeMemoryFile(systemDir, MEMORY_FILESYSTEM_BLOCK_LABEL, tree); +} + +export async function ensureMemoryFilesystemBlock(agentId: string) { + const client = await getClient(); + const blocks = await fetchAgentBlocks(agentId); + const exists = blocks.some( + (block) => block.label === MEMORY_FILESYSTEM_BLOCK_LABEL, + ); + + if (exists) { + return; + } + + const createdBlock = await client.blocks.create({ + label: MEMORY_FILESYSTEM_BLOCK_LABEL, + value: "/memory/", + description: "Filesystem view of memory blocks", + limit: 20000, + read_only: true, + }); + + if (createdBlock.id) { + await client.agents.blocks.attach(createdBlock.id, { agent_id: agentId }); + } +} + +export async function refreshMemoryFilesystemTree( + agentId: string, + homeDir: string = homedir(), +) { + ensureMemoryFilesystemDirs(agentId, homeDir); + await updateMemoryFilesystemBlock(agentId, homeDir); +} + +export async function collectMemorySyncConflicts( + agentId: string, + homeDir: string = homedir(), +): Promise { + const result = await syncMemoryFilesystem(agentId, { homeDir }); + return result.conflicts; +} + +export function formatMemorySyncSummary(result: MemorySyncResult): string { + const lines = ["Memory filesystem sync complete:"]; + const pushCount = (label: string, count: number) => { + if (count > 0) { + lines.push(`⎿ ${label}: ${count}`); + } + }; + + pushCount("Blocks updated", result.updatedBlocks.length); + pushCount("Blocks created", result.createdBlocks.length); + pushCount("Blocks deleted", result.deletedBlocks.length); + pushCount("Files updated", result.updatedFiles.length); + pushCount("Files created", result.createdFiles.length); + pushCount("Files deleted", result.deletedFiles.length); + + if (result.conflicts.length > 0) { + lines.push(`⎿ Conflicts: ${result.conflicts.length}`); + } + + return lines.join("\n"); +} + +/** + * Detach the memory_filesystem block from an agent. + * Used when disabling memfs. + */ +export async function detachMemoryFilesystemBlock( + agentId: string, +): Promise { + const client = await getClient(); + const blocks = await fetchAgentBlocks(agentId); + const memfsBlock = blocks.find( + (block) => block.label === MEMORY_FILESYSTEM_BLOCK_LABEL, + ); + + if (memfsBlock?.id) { + await client.agents.blocks.detach(memfsBlock.id, { agent_id: agentId }); + } +} diff --git a/src/agent/modify.ts b/src/agent/modify.ts index 7613272..45e263f 100644 --- a/src/agent/modify.ts +++ b/src/agent/modify.ts @@ -266,3 +266,49 @@ export async function updateAgentSystemPrompt( }; } } + +/** + * Updates an agent's system prompt to include or exclude the memfs addon section. + * + * @param agentId - The agent ID to update + * @param enableMemfs - Whether to enable (add) or disable (remove) the memfs addon + * @returns Result with success status and message + */ +export async function updateAgentSystemPromptMemfs( + agentId: string, + enableMemfs: boolean, +): Promise { + try { + const client = await getClient(); + const agent = await client.agents.retrieve(agentId); + let currentSystemPrompt = agent.system || ""; + + const { SYSTEM_PROMPT_MEMFS_ADDON } = await import("./promptAssets"); + + // Remove any existing memfs addon section (to avoid duplicates) + // Look for the "## Memory Filesystem" header + const memfsHeaderRegex = /\n## Memory Filesystem[\s\S]*?(?=\n# |$)/; + currentSystemPrompt = currentSystemPrompt.replace(memfsHeaderRegex, ""); + + // If enabling, append the memfs addon + if (enableMemfs) { + currentSystemPrompt = `${currentSystemPrompt}${SYSTEM_PROMPT_MEMFS_ADDON}`; + } + + await client.agents.update(agentId, { + system: currentSystemPrompt, + }); + + return { + success: true, + message: enableMemfs + ? "System prompt updated to include Memory Filesystem section" + : "System prompt updated to remove Memory Filesystem section", + }; + } catch (error) { + return { + success: false, + message: `Failed to update system prompt memfs: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} diff --git a/src/agent/promptAssets.ts b/src/agent/promptAssets.ts index 976b1ff..1bc5223 100644 --- a/src/agent/promptAssets.ts +++ b/src/agent/promptAssets.ts @@ -12,6 +12,7 @@ import lettaCodexPrompt from "./prompts/letta_codex.md"; import lettaGeminiPrompt from "./prompts/letta_gemini.md"; import loadedSkillsPrompt from "./prompts/loaded_skills.mdx"; import memoryCheckReminder from "./prompts/memory_check_reminder.txt"; +import memoryFilesystemPrompt from "./prompts/memory_filesystem.mdx"; import personaPrompt from "./prompts/persona.mdx"; import personaClaudePrompt from "./prompts/persona_claude.mdx"; import personaKawaiiPrompt from "./prompts/persona_kawaii.mdx"; @@ -24,8 +25,10 @@ import skillUnloadReminder from "./prompts/skill_unload_reminder.txt"; import skillsPrompt from "./prompts/skills.mdx"; import stylePrompt from "./prompts/style.mdx"; import systemPrompt from "./prompts/system_prompt.txt"; +import systemPromptMemfsAddon from "./prompts/system_prompt_memfs.txt"; export const SYSTEM_PROMPT = systemPrompt; +export const SYSTEM_PROMPT_MEMFS_ADDON = systemPromptMemfsAddon; export const PLAN_MODE_REMINDER = planModeReminder; export const SKILL_UNLOAD_REMINDER = skillUnloadReminder; export const SKILL_CREATOR_PROMPT = skillCreatorModePrompt; @@ -43,6 +46,7 @@ export const MEMORY_PROMPTS: Record = { "project.mdx": projectPrompt, "skills.mdx": skillsPrompt, "loaded_skills.mdx": loadedSkillsPrompt, + "memory_filesystem.mdx": memoryFilesystemPrompt, "style.mdx": stylePrompt, }; diff --git a/src/agent/prompts/memory_filesystem.mdx b/src/agent/prompts/memory_filesystem.mdx new file mode 100644 index 0000000..ebabe67 --- /dev/null +++ b/src/agent/prompts/memory_filesystem.mdx @@ -0,0 +1,7 @@ +--- +label: memory_filesystem +description: Filesystem view of memory blocks (system + user) +limit: 20000 +--- + +/memory/ diff --git a/src/agent/prompts/system_prompt.txt b/src/agent/prompts/system_prompt.txt index 82beadd..79d8caa 100644 --- a/src/agent/prompts/system_prompt.txt +++ b/src/agent/prompts/system_prompt.txt @@ -39,4 +39,4 @@ How to use Skills: - Then, navigate and discover additional linked files in its directory as needed. Don't load additional files immediately, only load them when needed. - When the task is completed, unload irrelevant skills using the Skill tool with `command: "unload"`. - After creating a new skill, use `command: "refresh"` to re-scan the skills directory and update the available skills list. -IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space. \ No newline at end of file +IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space. diff --git a/src/agent/prompts/system_prompt_memfs.txt b/src/agent/prompts/system_prompt_memfs.txt new file mode 100644 index 0000000..ae818fd --- /dev/null +++ b/src/agent/prompts/system_prompt_memfs.txt @@ -0,0 +1,34 @@ + +## Memory Filesystem +Your memory blocks are synchronized with a filesystem tree at `~/.letta/agents//memory/`. This provides: +- **Persistent storage**: Memory edits survive server restarts and can be version-controlled +- **Two-way sync**: Changes to files sync to memory blocks, and vice versa +- **Visibility**: A `memory_filesystem` block shows the tree structure of all memory files + +### Structure +``` +~/.letta/agents//memory/ +├── system/ # System prompt memory blocks (attached to agent) +│ ├── persona/ # Your identity and approach +│ ├── human.md # What you know about the user +│ └── ... +├── user/ # User notes (detached blocks, not in system prompt) +│ └── ... +└── .sync-state.json # Internal sync state (do not edit) +``` + +### System vs User +- **system/**: Memory blocks attached to your system prompt. These influence your behavior and are always loaded. +- **user/**: Detached blocks for reference/notes. Created as blocks but NOT attached to the agent (similar to the "note" tool pattern). + +### Sync Behavior +- **Startup**: Automatic sync when the CLI starts +- **After memory edits**: Automatic sync after using memory tools +- **Manual**: Run `/memory-sync` to sync on demand +- **Conflicts**: If both file and block changed, you'll be prompted to choose which version to keep + +### How It Works +1. Each `.md` file path maps to a block label (e.g., `system/persona/git_safety.md` → label `persona/git_safety`) +2. File content syncs with block `value` +3. Changes detected via content hashing +4. The `memory_filesystem` block auto-updates with the tree view diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 3bac7a1..7f17df1 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -42,6 +42,16 @@ import { getClient } from "../agent/client"; import { getCurrentAgentId, setCurrentAgentId } from "../agent/context"; import { type AgentProvenance, createAgent } from "../agent/create"; import { ISOLATED_BLOCK_LABELS } from "../agent/memory"; +import { + detachMemoryFilesystemBlock, + ensureMemoryFilesystemBlock, + formatMemorySyncSummary, + getMemoryFilesystemRoot, + type MemorySyncConflict, + type MemorySyncResolution, + syncMemoryFilesystem, + updateMemoryFilesystemBlock, +} from "../agent/memoryFilesystem"; import { sendMessageStream } from "../agent/message"; import { getModelInfo, getModelShortName } from "../agent/model"; import { INTERRUPT_RECOVERY_ALERT } from "../agent/promptAssets"; @@ -108,6 +118,7 @@ import { ErrorMessage } from "./components/ErrorMessageRich"; import { FeedbackDialog } from "./components/FeedbackDialog"; import { HelpDialog } from "./components/HelpDialog"; import { HooksManager } from "./components/HooksManager"; +import { InlineQuestionApproval } from "./components/InlineQuestionApproval"; import { Input } from "./components/InputRich"; import { McpConnectFlow } from "./components/McpConnectFlow"; import { McpSelector } from "./components/McpSelector"; @@ -118,7 +129,6 @@ import { NewAgentDialog } from "./components/NewAgentDialog"; import { PendingApprovalStub } from "./components/PendingApprovalStub"; import { PinDialog, validateAgentName } from "./components/PinDialog"; import { ProviderSelector } from "./components/ProviderSelector"; -// QuestionDialog removed - now using InlineQuestionApproval import { ReasoningMessage } from "./components/ReasoningMessageRich"; import { formatUsageStats } from "./components/SessionStats"; @@ -975,6 +985,7 @@ export default function App({ | "subagent" | "feedback" | "memory" + | "memory-sync" | "pin" | "new" | "mcp" @@ -984,6 +995,14 @@ export default function App({ | "connect" | null; const [activeOverlay, setActiveOverlay] = useState(null); + const [memorySyncConflicts, setMemorySyncConflicts] = useState< + MemorySyncConflict[] | null + >(null); + const memorySyncProcessedToolCallsRef = useRef>(new Set()); + const memorySyncCommandIdRef = useRef(null); + const memorySyncCommandInputRef = useRef("/memory-sync"); + const memorySyncInFlightRef = useRef(false); + const memoryFilesystemInitializedRef = useRef(false); const [feedbackPrefill, setFeedbackPrefill] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const [modelSelectorOptions, setModelSelectorOptions] = useState<{ @@ -1869,6 +1888,238 @@ export default function App({ [refreshDerived, currentModelId], ); + const updateMemorySyncCommand = useCallback( + ( + commandId: string, + output: string, + success: boolean, + input = "/memory-sync", + keepRunning = false, // If true, keep phase as "running" (for conflict dialogs) + ) => { + buffersRef.current.byId.set(commandId, { + kind: "command", + id: commandId, + input, + output, + phase: keepRunning ? "running" : "finished", + success, + }); + refreshDerived(); + }, + [refreshDerived], + ); + + const runMemoryFilesystemSync = useCallback( + async (source: "startup" | "auto" | "command", commandId?: string) => { + if (!agentId || agentId === "loading") { + return; + } + if (memorySyncInFlightRef.current) { + return; + } + + memorySyncInFlightRef.current = true; + + try { + await ensureMemoryFilesystemBlock(agentId); + const result = await syncMemoryFilesystem(agentId); + + if (result.conflicts.length > 0) { + memorySyncCommandIdRef.current = commandId ?? null; + setMemorySyncConflicts(result.conflicts); + setActiveOverlay("memory-sync"); + + if (commandId) { + updateMemorySyncCommand( + commandId, + `Memory sync paused — resolve ${result.conflicts.length} conflict${ + result.conflicts.length === 1 ? "" : "s" + } to continue.`, + false, + "/memory-sync", + true, // keepRunning - don't commit until conflicts resolved + ); + } + return; + } + + await updateMemoryFilesystemBlock(agentId); + + if (commandId) { + updateMemorySyncCommand( + commandId, + formatMemorySyncSummary(result), + true, + ); + } + } catch (error) { + const errorText = formatErrorDetails(error, agentId); + if (commandId) { + updateMemorySyncCommand(commandId, `Failed: ${errorText}`, false); + } else if (source !== "startup") { + appendError(`Memory sync failed: ${errorText}`); + } else { + console.error(`Memory sync failed: ${errorText}`); + } + } finally { + memorySyncInFlightRef.current = false; + } + }, + [agentId, appendError, updateMemorySyncCommand], + ); + + const maybeSyncMemoryFilesystemAfterTurn = useCallback(async () => { + // Only auto-sync if memfs is enabled for this agent + if (!agentId || agentId === "loading") return; + if (!settingsManager.isMemfsEnabled(agentId)) return; + + const newToolCallIds: string[] = []; + for (const line of buffersRef.current.byId.values()) { + if (line.kind !== "tool_call") continue; + if (!line.toolCallId || !line.name) continue; + if (line.name !== "memory" && line.name !== "memory_apply_patch") + continue; + if (memorySyncProcessedToolCallsRef.current.has(line.toolCallId)) + continue; + newToolCallIds.push(line.toolCallId); + } + + if (newToolCallIds.length === 0) { + return; + } + + for (const id of newToolCallIds) { + memorySyncProcessedToolCallsRef.current.add(id); + } + await runMemoryFilesystemSync("auto"); + }, [agentId, runMemoryFilesystemSync]); + + useEffect(() => { + if (loadingState !== "ready") { + return; + } + if (!agentId || agentId === "loading") { + return; + } + if (memoryFilesystemInitializedRef.current) { + return; + } + // Only run startup sync if memfs is enabled for this agent + if (!settingsManager.isMemfsEnabled(agentId)) { + return; + } + + memoryFilesystemInitializedRef.current = true; + runMemoryFilesystemSync("startup"); + }, [agentId, loadingState, runMemoryFilesystemSync]); + + const handleMemorySyncConflictSubmit = useCallback( + async (answers: Record) => { + if (!agentId || agentId === "loading" || !memorySyncConflicts) { + return; + } + + const commandId = memorySyncCommandIdRef.current; + const commandInput = memorySyncCommandInputRef.current; + memorySyncCommandIdRef.current = null; + memorySyncCommandInputRef.current = "/memory-sync"; + + const resolutions: MemorySyncResolution[] = memorySyncConflicts.map( + (conflict) => { + const answer = answers[`Conflict for ${conflict.label}`]; + return { + label: conflict.label, + resolution: answer === "Use file version" ? "file" : "block", + }; + }, + ); + + setMemorySyncConflicts(null); + setActiveOverlay(null); + + if (memorySyncInFlightRef.current) { + return; + } + + memorySyncInFlightRef.current = true; + + try { + const result = await syncMemoryFilesystem(agentId, { + resolutions, + }); + + if (result.conflicts.length > 0) { + setMemorySyncConflicts(result.conflicts); + setActiveOverlay("memory-sync"); + if (commandId) { + updateMemorySyncCommand( + commandId, + `Memory sync paused — resolve ${result.conflicts.length} conflict${ + result.conflicts.length === 1 ? "" : "s" + } to continue.`, + false, + commandInput, + true, // keepRunning - don't commit until all conflicts resolved + ); + } + return; + } + + await updateMemoryFilesystemBlock(agentId); + + // Format resolution summary (align with formatMemorySyncSummary which uses "⎿ " prefix) + const resolutionSummary = resolutions + .map( + (r) => + `⎿ ${r.label}: used ${r.resolution === "file" ? "file" : "block"} version`, + ) + .join("\n"); + + if (commandId) { + updateMemorySyncCommand( + commandId, + `${formatMemorySyncSummary(result)}\nConflicts resolved:\n${resolutionSummary}`, + true, + commandInput, + ); + } + } catch (error) { + const errorText = formatErrorDetails(error, agentId); + if (commandId) { + updateMemorySyncCommand( + commandId, + `Failed: ${errorText}`, + false, + commandInput, + ); + } else { + appendError(`Memory sync failed: ${errorText}`); + } + } finally { + memorySyncInFlightRef.current = false; + } + }, + [agentId, appendError, memorySyncConflicts, updateMemorySyncCommand], + ); + + const handleMemorySyncConflictCancel = useCallback(() => { + const commandId = memorySyncCommandIdRef.current; + const commandInput = memorySyncCommandInputRef.current; + memorySyncCommandIdRef.current = null; + memorySyncCommandInputRef.current = "/memory-sync"; + setMemorySyncConflicts(null); + setActiveOverlay(null); + + if (commandId) { + updateMemorySyncCommand( + commandId, + "Memory sync cancelled.", + false, + commandInput, + ); + } + }, [updateMemorySyncCommand]); + // Core streaming function - iterative loop that processes conversation turns const processConversation = useCallback( async ( @@ -2525,6 +2776,8 @@ export default function App({ queueSnapshotRef.current = []; } + await maybeSyncMemoryFilesystemAfterTurn(); + // === RALPH WIGGUM CONTINUATION CHECK === // Check if ralph mode is active and should auto-continue // This happens at the very end, right before we'd release input @@ -3575,6 +3828,7 @@ export default function App({ needsEagerApprovalCheck, queueApprovalResults, consumeQueuedMessages, + maybeSyncMemoryFilesystemAfterTurn, ], ); @@ -5927,6 +6181,231 @@ export default function App({ return { submitted: true }; } + // Special handling for /memory-sync command - sync filesystem memory + if (trimmed === "/memory-sync") { + // Check if memfs is enabled for this agent + if (!settingsManager.isMemfsEnabled(agentId)) { + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output: + "Memory filesystem is disabled. Run `/memfs enable` first.", + phase: "finished", + success: false, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + return { submitted: true }; + } + + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output: "Syncing memory filesystem...", + phase: "running", + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + + setCommandRunning(true); + + try { + await runMemoryFilesystemSync("command", cmdId); + } finally { + setCommandRunning(false); + } + + return { submitted: true }; + } + + // Special handling for /memfs command - enable/disable filesystem-backed memory + if (trimmed.startsWith("/memfs")) { + const [, subcommand] = trimmed.split(/\s+/); + const cmdId = uid("cmd"); + + if (!subcommand || subcommand === "status") { + // Show status + const enabled = settingsManager.isMemfsEnabled(agentId); + let output: string; + if (enabled) { + const memoryDir = getMemoryFilesystemRoot(agentId); + output = `Memory filesystem is enabled.\nPath: ${memoryDir}`; + } else { + output = + "Memory filesystem is disabled. Run `/memfs enable` to enable."; + } + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output, + phase: "finished", + success: true, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + return { submitted: true }; + } + + if (subcommand === "enable") { + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output: "Enabling memory filesystem...", + phase: "running", + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + setCommandRunning(true); + + try { + // 1. Detach memory tools from agent + const { detachMemoryTools } = await import("../tools/toolset"); + await detachMemoryTools(agentId); + + // 2. Update settings + settingsManager.setMemfsEnabled(agentId, true); + + // 3. Update system prompt to include memfs section + const { updateAgentSystemPromptMemfs } = await import( + "../agent/modify" + ); + await updateAgentSystemPromptMemfs(agentId, true); + + // 4. Run initial sync (creates files from blocks) + await ensureMemoryFilesystemBlock(agentId); + const result = await syncMemoryFilesystem(agentId); + + if (result.conflicts.length > 0) { + // Handle conflicts - show overlay (keep running so it stays in liveItems) + memorySyncCommandIdRef.current = cmdId; + memorySyncCommandInputRef.current = msg; + setMemorySyncConflicts(result.conflicts); + setActiveOverlay("memory-sync"); + updateMemorySyncCommand( + cmdId, + `Memory filesystem enabled with ${result.conflicts.length} conflict${result.conflicts.length === 1 ? "" : "s"} to resolve.`, + false, + msg, + true, // keepRunning - don't commit until conflict resolved + ); + } else { + await updateMemoryFilesystemBlock(agentId); + const memoryDir = getMemoryFilesystemRoot(agentId); + updateMemorySyncCommand( + cmdId, + `Memory filesystem enabled.\nPath: ${memoryDir}\n${formatMemorySyncSummary(result)}`, + true, + msg, + ); + } + } catch (error) { + const errorText = + error instanceof Error ? error.message : String(error); + updateMemorySyncCommand( + cmdId, + `Failed to enable memfs: ${errorText}`, + false, + msg, + ); + } finally { + setCommandRunning(false); + } + + return { submitted: true }; + } + + if (subcommand === "disable") { + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output: "Disabling memory filesystem...", + phase: "running", + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + setCommandRunning(true); + + try { + // 1. Run final sync to ensure blocks are up-to-date + const result = await syncMemoryFilesystem(agentId); + + if (result.conflicts.length > 0) { + // Handle conflicts - show overlay (keep running so it stays in liveItems) + memorySyncCommandIdRef.current = cmdId; + memorySyncCommandInputRef.current = msg; + setMemorySyncConflicts(result.conflicts); + setActiveOverlay("memory-sync"); + updateMemorySyncCommand( + cmdId, + `Cannot disable: resolve ${result.conflicts.length} conflict${result.conflicts.length === 1 ? "" : "s"} first.`, + false, + msg, + true, // keepRunning - don't commit until conflict resolved + ); + return { submitted: true }; + } + + // 2. Re-attach memory tool + const { reattachMemoryTool } = await import("../tools/toolset"); + // Use current model or default to Claude + const modelId = currentModelId || "anthropic/claude-sonnet-4"; + await reattachMemoryTool(agentId, modelId); + + // 3. Detach memory_filesystem block + await detachMemoryFilesystemBlock(agentId); + + // 4. Update system prompt to remove memfs section + const { updateAgentSystemPromptMemfs } = await import( + "../agent/modify" + ); + await updateAgentSystemPromptMemfs(agentId, false); + + // 5. Update settings + settingsManager.setMemfsEnabled(agentId, false); + + updateMemorySyncCommand( + cmdId, + "Memory filesystem disabled. Memory tool re-attached.\nFiles on disk have been kept.", + true, + msg, + ); + } catch (error) { + const errorText = + error instanceof Error ? error.message : String(error); + updateMemorySyncCommand( + cmdId, + `Failed to disable memfs: ${errorText}`, + false, + msg, + ); + } finally { + setCommandRunning(false); + } + + return { submitted: true }; + } + + // Unknown subcommand + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output: `Unknown subcommand: ${subcommand}. Use /memfs, /memfs enable, or /memfs disable.`, + phase: "finished", + success: false, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + return { submitted: true }; + } + // Special handling for /skill command - enter skill creation mode if (trimmed.startsWith("/skill")) { // Check for pending approvals before sending @@ -9768,6 +10247,30 @@ Plan file path: ${planFilePath}`; /> )} + {/* Memory Sync Conflict Resolver */} + {activeOverlay === "memory-sync" && memorySyncConflicts && ( + ({ + header: "Memory sync", + question: `Conflict for ${conflict.label}`, + options: [ + { + label: "Use file version", + description: "Overwrite memory block with file contents", + }, + { + label: "Use block version", + description: "Overwrite file with memory block contents", + }, + ], + multiSelect: false, + allowOther: false, // Only file or block - no custom option + }))} + onSubmit={handleMemorySyncConflictSubmit} + onCancel={handleMemorySyncConflictCancel} + /> + )} + {/* MCP Server Selector - conditionally mounted as overlay */} {activeOverlay === "mcp" && ( = { return "Opening memory viewer..."; }, }, + "/memory-sync": { + desc: "Sync memory blocks with filesystem (requires memfs enabled)", + order: 15.5, + handler: () => { + // Handled specially in App.tsx to run filesystem sync + return "Syncing memory filesystem..."; + }, + }, + "/memfs": { + desc: "Enable/disable filesystem-backed memory (/memfs [enable|disable])", + args: "[enable|disable]", + order: 15.6, + handler: () => { + // Handled specially in App.tsx + return "Managing memory filesystem..."; + }, + }, "/search": { desc: "Search messages across all agents (/search [query])", order: 16, diff --git a/src/cli/components/InlineQuestionApproval.tsx b/src/cli/components/InlineQuestionApproval.tsx index f36321a..fb4047c 100644 --- a/src/cli/components/InlineQuestionApproval.tsx +++ b/src/cli/components/InlineQuestionApproval.tsx @@ -15,6 +15,7 @@ interface Question { header: string; options: QuestionOption[]; multiSelect: boolean; + allowOther?: boolean; // default true - set false to hide "Type something" option } type Props = { @@ -46,12 +47,13 @@ export const InlineQuestionApproval = memo( const currentQuestion = questions[currentQuestionIndex]; - // Build options list: regular options + "Type something" + // Build options list: regular options + "Type something" (unless allowOther=false) // For multi-select, we also track a separate "Submit" action + const showOther = currentQuestion?.allowOther !== false; const baseOptions = currentQuestion ? [ ...currentQuestion.options, - { label: "Type something.", description: "" }, + ...(showOther ? [{ label: "Type something.", description: "" }] : []), ] : []; @@ -60,12 +62,12 @@ export const InlineQuestionApproval = memo( ? [...baseOptions, { label: "Submit", description: "" }] : baseOptions; - const customOptionIndex = baseOptions.length - 1; // "Type something" index + const customOptionIndex = showOther ? baseOptions.length - 1 : -1; // "Type something" index (-1 if disabled) const submitOptionIndex = currentQuestion?.multiSelect ? optionsWithOther.length - 1 : -1; // Submit index (only for multi-select) - const isOnCustomOption = selectedOption === customOptionIndex; + const isOnCustomOption = showOther && selectedOption === customOptionIndex; const isOnSubmitOption = selectedOption === submitOptionIndex; const handleSubmitAnswer = (answer: string) => { diff --git a/src/cli/components/ModelSelector.tsx b/src/cli/components/ModelSelector.tsx index 8adea6f..c6029f7 100644 --- a/src/cli/components/ModelSelector.tsx +++ b/src/cli/components/ModelSelector.tsx @@ -318,10 +318,7 @@ export function ModelSelector({ const serverRecommendedModels = useMemo(() => { if (!isSelfHosted || availableHandles === undefined) return []; const available = typedModels.filter( - (m) => - availableHandles !== null && - availableHandles.has(m.handle) && - m.handle !== "letta/letta-free", + (m) => availableHandles?.has(m.handle) && m.handle !== "letta/letta-free", ); if (searchQuery) { const query = searchQuery.toLowerCase(); diff --git a/src/cli/components/QuestionDialog.tsx b/src/cli/components/QuestionDialog.tsx deleted file mode 100644 index d26a798..0000000 --- a/src/cli/components/QuestionDialog.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { Box, Text, useInput } from "ink"; -import { memo, useState } from "react"; -import { colors } from "./colors"; -import { PasteAwareTextInput } from "./PasteAwareTextInput"; - -interface QuestionOption { - label: string; - description: string; -} - -interface Question { - question: string; - header: string; - options: QuestionOption[]; - multiSelect: boolean; -} - -type Props = { - questions: Question[]; - onSubmit: (answers: Record) => void; - onCancel?: () => void; -}; - -export const QuestionDialog = memo( - ({ questions, onSubmit, onCancel }: Props) => { - const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); - const [answers, setAnswers] = useState>({}); - const [selectedOption, setSelectedOption] = useState(0); - const [isOtherMode, setIsOtherMode] = useState(false); - const [otherText, setOtherText] = useState(""); - const [selectedMulti, setSelectedMulti] = useState>(new Set()); - - const currentQuestion = questions[currentQuestionIndex]; - const optionsWithOther = currentQuestion - ? [ - ...currentQuestion.options, - { label: "Other", description: "Provide a custom response" }, - ] - : []; - - const handleSubmitAnswer = (answer: string) => { - if (!currentQuestion) return; - const newAnswers = { - ...answers, - [currentQuestion.question]: answer, - }; - setAnswers(newAnswers); - - if (currentQuestionIndex < questions.length - 1) { - setCurrentQuestionIndex(currentQuestionIndex + 1); - setSelectedOption(0); - setIsOtherMode(false); - setOtherText(""); - setSelectedMulti(new Set()); - } else { - onSubmit(newAnswers); - } - }; - - useInput((input, key) => { - if (!currentQuestion) return; - - // CTRL-C: immediately cancel (works in any mode) - if (key.ctrl && input === "c") { - if (onCancel) { - onCancel(); - } - return; - } - - if (isOtherMode) { - if (key.escape) { - setIsOtherMode(false); - setOtherText(""); - } - return; - } - - // ESC in main selection mode: cancel the dialog - if (key.escape) { - if (onCancel) { - onCancel(); - } - return; - } - - if (key.upArrow) { - setSelectedOption((prev) => Math.max(0, prev - 1)); - } else if (key.downArrow) { - setSelectedOption((prev) => - Math.min(optionsWithOther.length - 1, prev + 1), - ); - } else if (key.return) { - if (currentQuestion.multiSelect) { - if (selectedOption === optionsWithOther.length - 1) { - setIsOtherMode(true); - } else if (selectedMulti.size > 0) { - const selectedLabels = Array.from(selectedMulti) - .map((i) => optionsWithOther[i]?.label) - .filter(Boolean) - .join(", "); - handleSubmitAnswer(selectedLabels); - } - } else { - if (selectedOption === optionsWithOther.length - 1) { - setIsOtherMode(true); - } else { - handleSubmitAnswer(optionsWithOther[selectedOption]?.label || ""); - } - } - } else if (input === " " && currentQuestion.multiSelect) { - if (selectedOption < optionsWithOther.length - 1) { - setSelectedMulti((prev) => { - const newSet = new Set(prev); - if (newSet.has(selectedOption)) { - newSet.delete(selectedOption); - } else { - newSet.add(selectedOption); - } - return newSet; - }); - } - } else if (input >= "1" && input <= "9") { - const optionIndex = Number.parseInt(input, 10) - 1; - if (optionIndex < optionsWithOther.length) { - if (currentQuestion.multiSelect) { - if (optionIndex < optionsWithOther.length - 1) { - setSelectedMulti((prev) => { - const newSet = new Set(prev); - if (newSet.has(optionIndex)) { - newSet.delete(optionIndex); - } else { - newSet.add(optionIndex); - } - return newSet; - }); - } - } else { - if (optionIndex === optionsWithOther.length - 1) { - setIsOtherMode(true); - } else { - handleSubmitAnswer(optionsWithOther[optionIndex]?.label || ""); - } - } - } - } - }); - - const handleOtherSubmit = (text: string) => { - handleSubmitAnswer(text); - }; - - if (!currentQuestion) return null; - - return ( - - - - [{currentQuestion.header}]{" "} - {currentQuestion.question} - - - - {questions.length > 1 && ( - - - Question {currentQuestionIndex + 1} of {questions.length} - - - )} - - {isOtherMode ? ( - - Type your response (Esc to cancel): - - > - - - - ) : ( - - {optionsWithOther.map((option, index) => { - const isSelected = index === selectedOption; - const isChecked = selectedMulti.has(index); - const color = isSelected ? colors.approval.header : undefined; - - return ( - - - - {isSelected ? ">" : " "} - - {currentQuestion.multiSelect && - index < optionsWithOther.length - 1 && ( - - [{isChecked ? "x" : " "}] - - )} - - - {index + 1}. {option.label} - - - - {option.description && ( - - {option.description} - - )} - - ); - })} - - - - {currentQuestion.multiSelect - ? "Space to toggle, Enter to confirm selection" - : `Enter to select, or type 1-${optionsWithOther.length}`} - - - - )} - - ); - }, -); - -QuestionDialog.displayName = "QuestionDialog"; diff --git a/src/headless.ts b/src/headless.ts index f839236..bd27023 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -24,6 +24,12 @@ import { } from "./agent/context"; import { createAgent } from "./agent/create"; import { ensureSkillsBlocks, ISOLATED_BLOCK_LABELS } from "./agent/memory"; +import { + ensureMemoryFilesystemBlock, + formatMemorySyncSummary, + syncMemoryFilesystem, + updateMemoryFilesystemBlock, +} from "./agent/memoryFilesystem"; import { sendMessageStream } from "./agent/message"; import { getModelUpdateArgs } from "./agent/model"; import { SessionStats } from "./agent/stats"; @@ -580,6 +586,36 @@ export async function handleHeadlessCommand( } } + // Sync filesystem-backed memory before creating conversations (only if memfs is enabled) + if (settingsManager.isMemfsEnabled(agent.id)) { + try { + await ensureMemoryFilesystemBlock(agent.id); + const syncResult = await syncMemoryFilesystem(agent.id); + if (syncResult.conflicts.length > 0) { + console.error( + `Memory filesystem sync conflicts detected (${syncResult.conflicts.length}). Run in interactive mode to resolve.`, + ); + process.exit(1); + } + await updateMemoryFilesystemBlock(agent.id); + if ( + syncResult.updatedBlocks.length > 0 || + syncResult.createdBlocks.length > 0 || + syncResult.deletedBlocks.length > 0 || + syncResult.updatedFiles.length > 0 || + syncResult.createdFiles.length > 0 || + syncResult.deletedFiles.length > 0 + ) { + console.log(formatMemorySyncSummary(syncResult)); + } + } catch (error) { + console.error( + `Memory filesystem sync failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } + } + // Determine which blocks to isolate for the conversation let isolatedBlockLabels: string[] = []; if (!noSkillsFlag) { diff --git a/src/settings-manager.ts b/src/settings-manager.ts index 30e6a0f..ed9cfdc 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -24,6 +24,17 @@ export interface SessionRef { conversationId: string; } +/** + * Per-agent settings stored in a flat array. + * baseUrl is omitted/undefined for Letta API (api.letta.com). + */ +export interface AgentSettings { + agentId: string; + baseUrl?: string; // undefined = Letta API (api.letta.com) + pinned?: boolean; // true if agent is pinned + memfs?: boolean; // true if memory filesystem is enabled +} + export interface Settings { lastAgent: string | null; // DEPRECATED: kept for migration to lastSession lastSession?: SessionRef; // DEPRECATED: kept for backwards compat, use sessionsByServer @@ -40,7 +51,9 @@ export interface Settings { env?: Record; // Server-indexed settings (agent IDs are server-specific) sessionsByServer?: Record; // key = normalized base URL (e.g., "api.letta.com", "localhost:8283") - pinnedAgentsByServer?: Record; // key = normalized base URL + pinnedAgentsByServer?: Record; // DEPRECATED: use agents array + // Unified agent settings array (replaces pinnedAgentsByServer) + agents?: AgentSettings[]; // Letta Cloud OAuth token management (stored separately in secrets) refreshToken?: string; // DEPRECATED: kept for migration, now stored in secrets tokenExpiresAt?: number; // Unix timestamp in milliseconds @@ -160,6 +173,9 @@ class SettingsManager { // Migrate tokens to secrets if they exist in settings await this.migrateTokensToSecrets(); + + // Migrate pinnedAgents/pinnedAgentsByServer to agents array + this.migrateToAgentsArray(); } catch (error) { console.error("Error loading settings, using defaults:", error); this.settings = { ...DEFAULT_SETTINGS }; @@ -168,6 +184,7 @@ class SettingsManager { // Still check secrets support and try to migrate in case of partial failure await this.checkSecretsSupport(); await this.migrateTokensToSecrets(); + this.migrateToAgentsArray(); } } @@ -249,6 +266,58 @@ class SettingsManager { } } + /** + * Migrate from legacy pinnedAgents/pinnedAgentsByServer to unified agents array. + * Runs on initialize if agents array doesn't exist yet. + */ + private migrateToAgentsArray(): void { + if (!this.settings) return; + if (this.settings.agents) return; // Already migrated + + const agents: AgentSettings[] = []; + const seen = new Set(); // agentId+baseUrl dedup key + + // Migrate from pinnedAgentsByServer (newest legacy format) + if (this.settings.pinnedAgentsByServer) { + for (const [serverKey, agentIds] of Object.entries( + this.settings.pinnedAgentsByServer, + )) { + for (const agentId of agentIds) { + // Normalize baseUrl: api.letta.com -> undefined + const baseUrl = serverKey === "api.letta.com" ? undefined : serverKey; + const key = `${agentId}@${baseUrl ?? "cloud"}`; + if (!seen.has(key)) { + agents.push({ + agentId, + baseUrl, + pinned: true, + }); + seen.add(key); + } + } + } + } + + // Migrate from pinnedAgents (oldest legacy format - assumes Letta API) + if (this.settings.pinnedAgents) { + for (const agentId of this.settings.pinnedAgents) { + const key = `${agentId}@cloud`; + if (!seen.has(key)) { + agents.push({ agentId, pinned: true }); + seen.add(key); + } + } + } + + if (agents.length > 0) { + this.settings = { ...this.settings, agents }; + // Persist the migration (async, fire-and-forget) + this.persistSettings().catch((error) => { + console.warn("Failed to persist agents array migration:", error); + }); + } + } + /** * Get all settings (synchronous, from memory) * Note: Does not include secure tokens (API key, refresh token) from secrets @@ -1161,6 +1230,91 @@ class SettingsManager { console.warn("unpinProfile is deprecated, use unpinLocal(agentId) instead"); } + // ===================================================================== + // Agent Settings (unified agents array) Helpers + // ===================================================================== + + /** + * Get settings for a specific agent on the current server. + * Returns undefined if agent not found in settings. + */ + private getAgentSettings(agentId: string): AgentSettings | undefined { + const settings = this.getSettings(); + const serverKey = getCurrentServerKey(settings); + const normalizedBaseUrl = + serverKey === "api.letta.com" ? undefined : serverKey; + + return settings.agents?.find( + (a) => + a.agentId === agentId && (a.baseUrl ?? undefined) === normalizedBaseUrl, + ); + } + + /** + * Create or update settings for a specific agent on the current server. + */ + private upsertAgentSettings( + agentId: string, + updates: Partial>, + ): void { + const settings = this.getSettings(); + const serverKey = getCurrentServerKey(settings); + const normalizedBaseUrl = + serverKey === "api.letta.com" ? undefined : serverKey; + + const agents = [...(settings.agents || [])]; + const idx = agents.findIndex( + (a) => + a.agentId === agentId && (a.baseUrl ?? undefined) === normalizedBaseUrl, + ); + + if (idx >= 0) { + // Update existing (idx >= 0 guarantees this exists) + const existing = agents[idx] as AgentSettings; + const updated: AgentSettings = { + agentId: existing.agentId, + baseUrl: existing.baseUrl, + // Use nullish coalescing for pinned (undefined = keep existing) + pinned: updates.pinned !== undefined ? updates.pinned : existing.pinned, + // Use nullish coalescing for memfs (undefined = keep existing) + memfs: updates.memfs !== undefined ? updates.memfs : existing.memfs, + }; + // Clean up undefined/false values + if (!updated.pinned) delete updated.pinned; + if (!updated.memfs) delete updated.memfs; + if (!updated.baseUrl) delete updated.baseUrl; + agents[idx] = updated; + } else { + // Create new + const newAgent: AgentSettings = { + agentId, + baseUrl: normalizedBaseUrl, + ...updates, + }; + // Clean up undefined/false values + if (!newAgent.pinned) delete newAgent.pinned; + if (!newAgent.memfs) delete newAgent.memfs; + if (!newAgent.baseUrl) delete newAgent.baseUrl; + agents.push(newAgent); + } + + this.updateSettings({ agents }); + } + + /** + * Check if memory filesystem is enabled for an agent on the current server. + */ + isMemfsEnabled(agentId: string): boolean { + return this.getAgentSettings(agentId)?.memfs === true; + } + + /** + * Enable or disable memory filesystem for an agent on the current server. + */ + setMemfsEnabled(agentId: string, enabled: boolean): void { + this.upsertAgentSettings(agentId, { memfs: enabled }); + } + /** * Check if local .letta directory exists (indicates existing project) */ diff --git a/src/tests/settings-manager.test.ts b/src/tests/settings-manager.test.ts index a23d530..fae5fdb 100644 --- a/src/tests/settings-manager.test.ts +++ b/src/tests/settings-manager.test.ts @@ -759,3 +759,165 @@ describe("Settings Manager - Edge Cases", () => { expect(settings.lastAgent).toBe("agent-2"); // Updated }); }); + +// ============================================================================ +// Agents Array Migration Tests +// ============================================================================ + +describe("Settings Manager - Agents Array Migration", () => { + test("Migrates from pinnedAgents (oldest legacy format)", async () => { + // Setup: Write old format to disk + const { writeFile, mkdir } = await import("../utils/fs.js"); + const settingsDir = join(testHomeDir, ".letta"); + await mkdir(settingsDir, { recursive: true }); + await writeFile( + join(settingsDir, "settings.json"), + JSON.stringify({ + pinnedAgents: ["agent-old-1", "agent-old-2"], + tokenStreaming: true, + }), + ); + + await settingsManager.initialize(); + const settings = settingsManager.getSettings(); + + // Should have migrated to agents array + expect(settings.agents).toBeDefined(); + expect(settings.agents).toHaveLength(2); + expect(settings.agents?.[0]).toEqual({ + agentId: "agent-old-1", + pinned: true, + }); + expect(settings.agents?.[1]).toEqual({ + agentId: "agent-old-2", + pinned: true, + }); + // Legacy field should still exist for downgrade compat + expect(settings.pinnedAgents).toEqual(["agent-old-1", "agent-old-2"]); + }); + + test("Migrates from pinnedAgentsByServer (newer legacy format)", async () => { + const { writeFile, mkdir } = await import("../utils/fs.js"); + const settingsDir = join(testHomeDir, ".letta"); + await mkdir(settingsDir, { recursive: true }); + await writeFile( + join(settingsDir, "settings.json"), + JSON.stringify({ + pinnedAgentsByServer: { + "api.letta.com": ["agent-cloud-1"], + "localhost:8283": ["agent-local-1", "agent-local-2"], + }, + }), + ); + + await settingsManager.initialize(); + const settings = settingsManager.getSettings(); + + expect(settings.agents).toHaveLength(3); + // Cloud agents have no baseUrl (or undefined) + expect(settings.agents).toContainEqual({ + agentId: "agent-cloud-1", + pinned: true, + }); + // Local agents have baseUrl + expect(settings.agents).toContainEqual({ + agentId: "agent-local-1", + baseUrl: "localhost:8283", + pinned: true, + }); + expect(settings.agents).toContainEqual({ + agentId: "agent-local-2", + baseUrl: "localhost:8283", + pinned: true, + }); + }); + + test("Migrates from both legacy formats (deduplicated)", async () => { + const { writeFile, mkdir } = await import("../utils/fs.js"); + const settingsDir = join(testHomeDir, ".letta"); + await mkdir(settingsDir, { recursive: true }); + await writeFile( + join(settingsDir, "settings.json"), + JSON.stringify({ + pinnedAgents: ["agent-1", "agent-2"], // Old old format + pinnedAgentsByServer: { + "api.letta.com": ["agent-1", "agent-3"], // agent-1 is duplicate + }, + }), + ); + + await settingsManager.initialize(); + const settings = settingsManager.getSettings(); + + // Should have 3 agents (agent-1 deduped) + expect(settings.agents).toHaveLength(3); + const agentIds = settings.agents?.map((a) => a.agentId); + expect(agentIds).toContain("agent-1"); + expect(agentIds).toContain("agent-2"); + expect(agentIds).toContain("agent-3"); + }); + + test("Already migrated settings are not re-migrated", async () => { + const { writeFile, mkdir } = await import("../utils/fs.js"); + const settingsDir = join(testHomeDir, ".letta"); + await mkdir(settingsDir, { recursive: true }); + await writeFile( + join(settingsDir, "settings.json"), + JSON.stringify({ + agents: [{ agentId: "agent-new", pinned: true, memfs: true }], + pinnedAgentsByServer: { + "api.letta.com": ["agent-old"], // Should be ignored since agents exists + }, + }), + ); + + await settingsManager.initialize(); + const settings = settingsManager.getSettings(); + + // Should only have the new format agent + expect(settings.agents).toHaveLength(1); + expect(settings.agents?.[0]?.agentId).toBe("agent-new"); + expect(settings.agents?.[0]?.memfs).toBe(true); + }); + + test("isMemfsEnabled returns false for agents without memfs flag", async () => { + await settingsManager.initialize(); + + // Manually set up agents array + settingsManager.updateSettings({ + agents: [ + { agentId: "agent-with-memfs", pinned: true, memfs: true }, + { agentId: "agent-without-memfs", pinned: true }, + ], + }); + + expect(settingsManager.isMemfsEnabled("agent-with-memfs")).toBe(true); + expect(settingsManager.isMemfsEnabled("agent-without-memfs")).toBe(false); + expect(settingsManager.isMemfsEnabled("agent-unknown")).toBe(false); + }); + + test("setMemfsEnabled adds/removes memfs flag", async () => { + await settingsManager.initialize(); + + settingsManager.setMemfsEnabled("agent-test", true); + expect(settingsManager.isMemfsEnabled("agent-test")).toBe(true); + + settingsManager.setMemfsEnabled("agent-test", false); + expect(settingsManager.isMemfsEnabled("agent-test")).toBe(false); + }); + + test("setMemfsEnabled persists to disk", async () => { + await settingsManager.initialize(); + + settingsManager.setMemfsEnabled("agent-persist-test", true); + + // Wait for async persist + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Reset and reload + await settingsManager.reset(); + await settingsManager.initialize(); + + expect(settingsManager.isMemfsEnabled("agent-persist-test")).toBe(true); + }); +}); diff --git a/src/tools/toolset.ts b/src/tools/toolset.ts index a90f3ea..7f72330 100644 --- a/src/tools/toolset.ts +++ b/src/tools/toolset.ts @@ -112,6 +112,89 @@ export async function ensureCorrectMemoryTool( } } +/** + * Detach all memory tools from an agent. + * Used when enabling memfs (filesystem-backed memory). + * + * @param agentId - Agent to detach memory tools from + * @returns true if any tools were detached + */ +export async function detachMemoryTools(agentId: string): Promise { + const client = await getClient(); + + try { + const agentWithTools = await client.agents.retrieve(agentId, { + include: ["agent.tools"], + }); + const currentTools = agentWithTools.tools || []; + + let detachedAny = false; + for (const tool of currentTools) { + if (tool.name === "memory" || tool.name === "memory_apply_patch") { + if (tool.id) { + await client.agents.tools.detach(tool.id, { agent_id: agentId }); + detachedAny = true; + } + } + } + + return detachedAny; + } catch (err) { + console.warn( + `Warning: Failed to detach memory tools: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } +} + +/** + * Re-attach the appropriate memory tool to an agent. + * Used when disabling memfs (filesystem-backed memory). + * Forces attachment even if agent had no memory tool before. + * + * @param agentId - Agent to attach memory tool to + * @param modelIdentifier - Model handle to determine which memory tool to use + */ +export async function reattachMemoryTool( + agentId: string, + modelIdentifier: string, +): Promise { + const resolvedModel = resolveModel(modelIdentifier) ?? modelIdentifier; + const client = await getClient(); + const shouldUsePatch = isOpenAIModel(resolvedModel); + + try { + const agentWithTools = await client.agents.retrieve(agentId, { + include: ["agent.tools"], + }); + const currentTools = agentWithTools.tools || []; + const mapByName = new Map(currentTools.map((t) => [t.name, t.id])); + + // Determine which memory tool we want + const desiredMemoryTool = shouldUsePatch ? "memory_apply_patch" : "memory"; + + // Already has the tool? + if (mapByName.has(desiredMemoryTool)) { + return; + } + + // Find the tool on the server + const resp = await client.tools.list({ name: desiredMemoryTool }); + const toolId = resp.items[0]?.id; + if (!toolId) { + console.warn(`Memory tool "${desiredMemoryTool}" not found on server`); + return; + } + + // Attach it + await client.agents.tools.attach(toolId, { agent_id: agentId }); + } catch (err) { + console.warn( + `Warning: Failed to reattach memory tool: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + /** * Force switch to a specific toolset regardless of model. *