diff --git a/src/agent/memoryFilesystem.ts b/src/agent/memoryFilesystem.ts index 2584ab5..e87de10 100644 --- a/src/agent/memoryFilesystem.ts +++ b/src/agent/memoryFilesystem.ts @@ -6,11 +6,7 @@ import { dirname, join, relative } from "node:path"; import type { Block } from "@letta-ai/letta-client/resources/agents/blocks"; import { getClient } from "./client"; -import { - ISOLATED_BLOCK_LABELS, - parseMdxFrontmatter, - READ_ONLY_BLOCK_LABELS, -} from "./memory"; +import { parseMdxFrontmatter, READ_ONLY_BLOCK_LABELS } from "./memory"; export const MEMORY_FILESYSTEM_BLOCK_LABEL = "memory_filesystem"; export const MEMORY_FS_ROOT = ".letta"; @@ -22,14 +18,14 @@ export const MEMORY_USER_DIR = "user"; export const MEMORY_FS_STATE_FILE = ".sync-state.json"; /** - * Block labels that are managed by the system and should be skipped during sync. - * These blocks are auto-created/managed by the harness (skills, loaded_skills) - * or by the memfs system itself (memory_filesystem). + * Block labels that are managed by the memfs system itself and have special + * update logic (updateMemoryFilesystemBlock). These are skipped in the main + * sync loop but written separately. */ -const MANAGED_BLOCK_LABELS = new Set([ - MEMORY_FILESYSTEM_BLOCK_LABEL, - ...ISOLATED_BLOCK_LABELS, -]); +const MEMFS_MANAGED_LABELS = new Set([MEMORY_FILESYSTEM_BLOCK_LABEL]); + +// Note: skills and loaded_skills (ISOLATED_BLOCK_LABELS) are now synced +// but are read_only in the API, so file edits are ignored (API → file only) // Unified sync state - no system/detached split // The attached/detached distinction is derived at runtime from API and FS @@ -149,6 +145,15 @@ function hashContent(content: string): string { return createHash("sha256").update(content).digest("hex"); } +/** + * Hash just the body content of a file (excluding frontmatter). + * Used for "content matches" checks where we compare file body to block value. + */ +function hashFileBody(content: string): string { + const { body } = parseMdxFrontmatter(content); + return hashContent(body); +} + function loadSyncState( agentId: string, homeDir: string = homedir(), @@ -316,6 +321,94 @@ async function writeMemoryFile(dir: string, label: string, content: string) { await writeFile(filePath, content, "utf-8"); } +/** + * Serialize a block to file content with YAML frontmatter. + * Includes description, limit, and read_only (if true) for round-trip preservation. + * Label is omitted since it's implied by the file path. + */ +export function renderBlockToFileContent(block: { + value?: string | null; + description?: string | null; + limit?: number | null; + read_only?: boolean | null; +}): string { + const lines: string[] = ["---"]; + + // Always include description (so it round-trips and doesn't silently reset) + if (block.description) { + // Escape description for YAML if it contains special chars + const desc = + block.description.includes(":") || block.description.includes("\n") + ? `"${block.description.replace(/"/g, '\\"')}"` + : block.description; + lines.push(`description: ${desc}`); + } + + // Always include limit + if (block.limit) { + lines.push(`limit: ${block.limit}`); + } + + // Only include read_only if true (avoid cluttering frontmatter) + if (block.read_only === true) { + lines.push("read_only: true"); + } + + lines.push("---"); + lines.push(""); // blank line after frontmatter + lines.push(block.value || ""); + + return lines.join("\n"); +} + +/** + * Parse file content for UPDATING an existing block. + * Only includes metadata fields that are explicitly present in frontmatter. + * This prevents overwriting existing API metadata when frontmatter is absent. + */ +export function parseBlockUpdateFromFileContent( + fileContent: string, + defaultLabel: string, +): { + label: string; + value: string; + description?: string; + limit?: number; + read_only?: boolean; + // Flags indicating which fields were explicitly present + hasDescription: boolean; + hasLimit: boolean; + hasReadOnly: boolean; +} { + const { frontmatter, body } = parseMdxFrontmatter(fileContent); + + const label = frontmatter.label || defaultLabel; + + // Check explicit presence using hasOwnProperty + const hasDescription = Object.hasOwn(frontmatter, "description"); + const hasLimit = Object.hasOwn(frontmatter, "limit"); + const hasReadOnly = Object.hasOwn(frontmatter, "read_only"); + + let limit: number | undefined; + if (hasLimit && frontmatter.limit) { + const parsed = Number.parseInt(frontmatter.limit, 10); + if (!Number.isNaN(parsed) && parsed > 0) { + limit = parsed; + } + } + + return { + label, + value: body, + ...(hasDescription && { description: frontmatter.description }), + ...(hasLimit && limit !== undefined && { limit }), + ...(hasReadOnly && { read_only: frontmatter.read_only === "true" }), + hasDescription, + hasLimit, + hasReadOnly, + }; +} + async function deleteMemoryFile(dir: string, label: string) { const filePath = join(dir, `${label}.md`); if (existsSync(filePath)) { @@ -532,8 +625,8 @@ export async function syncMemoryFilesystem( const detachedBlockMap = new Map(); for (const block of detachedBlocks) { if (block.label && block.id) { - // Skip managed blocks (skills, loaded_skills, memory_filesystem) - if (MANAGED_BLOCK_LABELS.has(block.label)) { + // Skip memfs-managed blocks (memory_filesystem has special handling) + if (MEMFS_MANAGED_LABELS.has(block.label)) { continue; } // Skip blocks whose label matches a system block (prevents duplicates) @@ -564,7 +657,8 @@ export async function syncMemoryFilesystem( const allFilesMap = new Map(); for (const label of Array.from(allLabels).sort()) { - if (MANAGED_BLOCK_LABELS.has(label)) { + // Skip memfs-managed blocks (memory_filesystem has special handling) + if (MEMFS_MANAGED_LABELS.has(label)) { continue; } @@ -584,6 +678,8 @@ export async function syncMemoryFilesystem( const fileDir = fileInSystem ? systemDir : detachedDir; const fileHash = fileEntry ? hashContent(fileEntry.content) : null; + // Body hash excludes frontmatter - used for "content matches" checks + const fileBodyHash = fileEntry ? hashFileBody(fileEntry.content) : null; const blockHash = blockEntry ? hashContent(blockEntry.value || "") : null; // Use unified hash lookup @@ -613,6 +709,14 @@ export async function syncMemoryFilesystem( continue; } + // Read-only labels are API-authoritative. If file exists but block doesn't, delete the file. + if ((READ_ONLY_BLOCK_LABELS as readonly string[]).includes(label)) { + await deleteMemoryFile(fileDir, label); + deletedFiles.push(label); + allFilesMap.delete(label); + continue; + } + // Create block from file const blockData = parseBlockFromFileContent(fileEntry.content, label); const createdBlock = await client.blocks.create({ @@ -637,6 +741,16 @@ export async function syncMemoryFilesystem( // Case 2: Block exists, no file if (!fileEntry && blockEntry) { + // Read-only blocks: never delete/un-tag. Always recreate file instead. + if (blockEntry.read_only) { + const targetDir = isAttached ? systemDir : detachedDir; + const fileContent = renderBlockToFileContent(blockEntry); + await writeMemoryFile(targetDir, label, fileContent); + createdFiles.push(label); + allFilesMap.set(label, { content: fileContent }); + continue; + } + if (lastFileHash && !blockChanged) { // File deleted, block unchanged → remove owner tag so file doesn't resurrect if (blockEntry.id) { @@ -666,9 +780,10 @@ export async function syncMemoryFilesystem( // Create file from block - use block's attached status to determine location const targetDir = isAttached ? systemDir : detachedDir; - await writeMemoryFile(targetDir, label, blockEntry.value || ""); + const fileContent = renderBlockToFileContent(blockEntry); + await writeMemoryFile(targetDir, label, fileContent); createdFiles.push(label); - allFilesMap.set(label, { content: blockEntry.value || "" }); + allFilesMap.set(label, { content: fileContent }); continue; } @@ -684,7 +799,8 @@ export async function syncMemoryFilesystem( (fileInSystem && !isAttached) || (!fileInSystem && isAttached); // If content matches but location mismatches, sync attachment to match file location - if (fileHash === blockHash) { + // Use body hash (excludes frontmatter) for "content matches" check + if (fileBodyHash === blockHash) { if (locationMismatch && blockEntry.id) { if (fileInSystem && !isAttached) { // File in system/, block detached → attach block @@ -711,9 +827,10 @@ export async function syncMemoryFilesystem( ) { // User explicitly requested block wins via resolution for CONTENT // But FS still wins for LOCATION (attachment status) - await writeMemoryFile(fileDir, label, blockEntry.value || ""); + const fileContent = renderBlockToFileContent(blockEntry); + await writeMemoryFile(fileDir, label, fileContent); updatedFiles.push(label); - allFilesMap.set(label, { content: blockEntry.value || "" }); + allFilesMap.set(label, { content: fileContent }); // Sync attachment status to match file location (FS wins for location) if (locationMismatch && blockEntry.id) { @@ -733,9 +850,10 @@ export async function syncMemoryFilesystem( // Handle explicit resolution override if (resolution?.resolution === "block") { // Block wins for CONTENT, but FS wins for LOCATION - await writeMemoryFile(fileDir, label, blockEntry.value || ""); + const fileContent = renderBlockToFileContent(blockEntry); + await writeMemoryFile(fileDir, label, fileContent); updatedFiles.push(label); - allFilesMap.set(label, { content: blockEntry.value || "" }); + allFilesMap.set(label, { content: fileContent }); // Sync attachment status to match file location (FS wins for location) if (locationMismatch && blockEntry.id) { @@ -753,18 +871,39 @@ export async function syncMemoryFilesystem( } // "FS wins all": if file changed at all, file wins (update block from file) + // EXCEPT for read_only blocks - those are API → file only (ignore local changes) // Also sync attachment status to match file location if (fileChanged) { + // Read-only blocks: ignore local changes, overwrite file with API content + if (blockEntry.read_only) { + const fileContent = renderBlockToFileContent(blockEntry); + await writeMemoryFile(fileDir, label, fileContent); + updatedFiles.push(label); + allFilesMap.set(label, { content: fileContent }); + continue; + } + if (blockEntry.id) { try { - const blockData = parseBlockFromFileContent(fileEntry.content, label); - const updatePayload = isAttached - ? { value: blockData.value } - : { value: blockData.value, label }; + // Use update-mode parsing to preserve metadata not in frontmatter + const parsed = parseBlockUpdateFromFileContent( + fileEntry.content, + label, + ); + const updatePayload: Record = { + value: parsed.value, + }; + // Only include metadata if explicitly present in frontmatter + if (parsed.hasDescription) + updatePayload.description = parsed.description; + if (parsed.hasLimit) updatePayload.limit = parsed.limit; + if (parsed.hasReadOnly) updatePayload.read_only = parsed.read_only; + // For detached blocks, also update label if changed + if (!isAttached) updatePayload.label = label; await client.blocks.update(blockEntry.id, updatePayload); updatedBlocks.push(label); allBlocksMap.set(label, { - value: blockData.value, + value: parsed.value, id: blockEntry.id, }); @@ -814,9 +953,10 @@ export async function syncMemoryFilesystem( // Only block changed (file unchanged) → update file from block // Also sync attachment status to match file location if (blockChanged) { - await writeMemoryFile(fileDir, label, blockEntry.value || ""); + const fileContent = renderBlockToFileContent(blockEntry); + await writeMemoryFile(fileDir, label, fileContent); updatedFiles.push(label); - allFilesMap.set(label, { content: blockEntry.value || "" }); + allFilesMap.set(label, { content: fileContent }); // Sync attachment status to match file location (FS wins for location) if (locationMismatch && blockEntry.id) { @@ -879,9 +1019,20 @@ export async function updateMemoryFilesystemBlock( if (memfsBlock?.id) { await client.blocks.update(memfsBlock.id, { value: content }); - } - await writeMemoryFile(systemDir, MEMORY_FILESYSTEM_BLOCK_LABEL, content); + // Write file with frontmatter (consistent with other blocks) + const fileContent = renderBlockToFileContent({ + value: content, + description: memfsBlock.description, + limit: memfsBlock.limit, + read_only: memfsBlock.read_only, + }); + await writeMemoryFile( + systemDir, + MEMORY_FILESYSTEM_BLOCK_LABEL, + fileContent, + ); + } } export async function ensureMemoryFilesystemBlock(agentId: string) { @@ -994,8 +1145,8 @@ export async function checkMemoryFilesystemStatus( const detachedBlockMap = new Map(); for (const block of detachedBlocks) { if (block.label) { - // Skip managed blocks - if (MANAGED_BLOCK_LABELS.has(block.label)) { + // Skip memfs-managed blocks + if (MEMFS_MANAGED_LABELS.has(block.label)) { continue; } // Skip blocks whose label matches a system block (prevents duplicates) @@ -1017,7 +1168,8 @@ export async function checkMemoryFilesystemStatus( ]); for (const label of Array.from(allLabels).sort()) { - if (MANAGED_BLOCK_LABELS.has(label)) continue; + // Skip memfs-managed blocks (memory_filesystem has special handling) + if (MEMFS_MANAGED_LABELS.has(label)) continue; // Determine current state at runtime const systemFile = systemFiles.get(label); @@ -1100,6 +1252,10 @@ function classifyLabel( // Block was deleted, file unchanged — would delete file return; } + // Ignore file-only read_only labels (API is authoritative, file will be deleted on sync) + if ((READ_ONLY_BLOCK_LABELS as readonly string[]).includes(label)) { + return; + } newFiles.push(label); return; } diff --git a/src/agent/prompts/system_prompt_memfs.txt b/src/agent/prompts/system_prompt_memfs.txt index 9868295..8b69dae 100644 --- a/src/agent/prompts/system_prompt_memfs.txt +++ b/src/agent/prompts/system_prompt_memfs.txt @@ -24,10 +24,10 @@ Your memory blocks are synchronized with a filesystem tree at `~/.letta/agents/< ### Sync Behavior - **Startup**: Automatic sync when the CLI starts - **After memory edits**: Automatic sync after using memory tools -- **Manual**: Run `/memfs-sync` to sync on demand +- **Manual**: Run `/memfs sync` to sync on demand - **Conflict detection**: After each turn, the system checks for conflicts (both file and block changed since last sync) - **Agent-driven resolution**: If conflicts are detected, you'll receive a system reminder with the conflicting labels and instructions to resolve them using the `syncing-memory-filesystem` skill scripts -- **User fallback**: The user can also run `/memfs-sync` to resolve conflicts manually via an interactive prompt +- **User fallback**: The user can also run `/memfs sync` to resolve conflicts manually via an interactive prompt ### How It Works 1. Each `.md` file path maps to a block label (e.g., `system/persona/git_safety.md` → label `persona/git_safety`) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index e0107d8..6d6cb0e 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -1,7 +1,7 @@ // src/cli/App.tsx -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; +import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { APIError, APIUserAbortError } from "@letta-ai/letta-client/core/error"; import type { @@ -45,6 +45,7 @@ import { checkMemoryFilesystemStatus, detachMemoryFilesystemBlock, ensureMemoryFilesystemBlock, + ensureMemoryFilesystemDirs, formatMemorySyncSummary, getMemoryFilesystemRoot, type MemorySyncConflict, @@ -1036,7 +1037,7 @@ export default function App({ >(null); const memorySyncProcessedToolCallsRef = useRef>(new Set()); const memorySyncCommandIdRef = useRef(null); - const memorySyncCommandInputRef = useRef("/memfs-sync"); + const memorySyncCommandInputRef = useRef("/memfs sync"); const memorySyncInFlightRef = useRef(false); const memoryFilesystemInitializedRef = useRef(false); const pendingMemfsConflictsRef = useRef(null); @@ -1931,7 +1932,7 @@ export default function App({ commandId: string, output: string, success: boolean, - input = "/memfs-sync", + input = "/memfs sync", keepRunning = false, // If true, keep phase as "running" (for conflict dialogs) ) => { buffersRef.current.byId.set(commandId, { @@ -1972,7 +1973,7 @@ export default function App({ if (result.conflicts.length > 0) { if (source === "command") { - // User explicitly ran /memfs-sync — show the interactive overlay + // User explicitly ran /memfs sync — show the interactive overlay memorySyncCommandIdRef.current = commandId ?? null; setMemorySyncConflicts(result.conflicts); setActiveOverlay("memfs-sync"); @@ -1984,7 +1985,7 @@ export default function App({ result.conflicts.length === 1 ? "" : "s" } to continue.`, false, - "/memfs-sync", + "/memfs sync", true, // keepRunning - don't commit until conflicts resolved ); } @@ -2175,7 +2176,7 @@ export default function App({ const commandId = memorySyncCommandIdRef.current; const commandInput = memorySyncCommandInputRef.current; memorySyncCommandIdRef.current = null; - memorySyncCommandInputRef.current = "/memfs-sync"; + memorySyncCommandInputRef.current = "/memfs sync"; const resolutions: MemorySyncResolution[] = memorySyncConflicts.map( (conflict) => { @@ -2259,7 +2260,7 @@ export default function App({ const commandId = memorySyncCommandIdRef.current; const commandInput = memorySyncCommandInputRef.current; memorySyncCommandIdRef.current = null; - memorySyncCommandInputRef.current = "/memfs-sync"; + memorySyncCommandInputRef.current = "/memfs sync"; memorySyncInFlightRef.current = false; setMemorySyncConflicts(null); setActiveOverlay(null); @@ -6226,59 +6227,34 @@ export default function App({ return { submitted: true }; } - // Special handling for /memfs-sync command - sync filesystem memory - if (trimmed === "/memfs-sync") { - // Check if memfs is enabled for this agent - if (!settingsManager.isMemfsEnabled(agentId)) { - const cmdId = uid("cmd"); + // Special handling for /memfs command - manage filesystem-backed memory + if (trimmed.startsWith("/memfs")) { + const [, subcommand] = trimmed.split(/\s+/); + const cmdId = uid("cmd"); + + if (!subcommand || subcommand === "help") { + const output = [ + "memfs commands:", + "- /memfs status — show status", + "- /memfs enable — enable filesystem-backed memory", + "- /memfs disable — disable filesystem-backed memory", + "- /memfs sync — sync blocks and files now", + "- /memfs reset — move local memfs to /tmp and recreate dirs", + ].join("\n"); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: msg, - output: - "Memory filesystem is disabled. Run `/memfs enable` first.", + output, phase: "finished", - success: false, + success: true, }); 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); - } catch (error) { - // runMemoryFilesystemSync has its own error handling, but catch any - // unexpected errors that slip through - const errorText = - error instanceof Error ? error.message : String(error); - updateMemorySyncCommand(cmdId, `Failed: ${errorText}`, false); - } 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") { + if (subcommand === "status") { // Show status const enabled = settingsManager.isMemfsEnabled(agentId); let output: string; @@ -6371,6 +6347,104 @@ export default function App({ return { submitted: true }; } + if (subcommand === "sync") { + // Check if memfs is enabled for this agent + if (!settingsManager.isMemfsEnabled(agentId)) { + 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 }; + } + + 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); + } catch (error) { + // runMemoryFilesystemSync has its own error handling, but catch any + // unexpected errors that slip through + const errorText = + error instanceof Error ? error.message : String(error); + updateMemorySyncCommand(cmdId, `Failed: ${errorText}`, false); + } finally { + setCommandRunning(false); + } + + return { submitted: true }; + } + + if (subcommand === "reset") { + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output: "Resetting memory filesystem...", + phase: "running", + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + setCommandRunning(true); + + try { + const memoryDir = getMemoryFilesystemRoot(agentId); + if (!existsSync(memoryDir)) { + updateMemorySyncCommand( + cmdId, + "No local memory filesystem found to reset.", + true, + msg, + ); + return { submitted: true }; + } + + const backupDir = join( + tmpdir(), + `letta-memfs-reset-${agentId}-${Date.now()}`, + ); + renameSync(memoryDir, backupDir); + + ensureMemoryFilesystemDirs(agentId); + + updateMemorySyncCommand( + cmdId, + `Memory filesystem reset.\nBackup moved to ${backupDir}\nRun \`/memfs sync\` to repopulate from API.`, + true, + msg, + ); + } catch (error) { + const errorText = + error instanceof Error ? error.message : String(error); + updateMemorySyncCommand( + cmdId, + `Failed to reset memfs: ${errorText}`, + false, + msg, + ); + } finally { + setCommandRunning(false); + } + + return { submitted: true }; + } + if (subcommand === "disable") { buffersRef.current.byId.set(cmdId, { kind: "command", @@ -6448,7 +6522,7 @@ export default function App({ kind: "command", id: cmdId, input: msg, - output: `Unknown subcommand: ${subcommand}. Use /memfs, /memfs enable, or /memfs disable.`, + output: `Unknown subcommand: ${subcommand}. Use /memfs, /memfs enable, /memfs disable, /memfs sync, or /memfs reset.`, phase: "finished", success: false, }); diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index f5d1051..d2bc3b8 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -60,18 +60,10 @@ export const commands: Record = { return "Opening memory viewer..."; }, }, - "/memfs-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, + desc: "Manage filesystem-backed memory (/memfs [enable|disable|sync|reset])", + args: "[enable|disable|sync|reset]", + order: 15.5, handler: () => { // Handled specially in App.tsx return "Managing memory filesystem..."; diff --git a/src/skills/builtin/syncing-memory-filesystem/SKILL.md b/src/skills/builtin/syncing-memory-filesystem/SKILL.md index 7e6af5d..7046528 100644 --- a/src/skills/builtin/syncing-memory-filesystem/SKILL.md +++ b/src/skills/builtin/syncing-memory-filesystem/SKILL.md @@ -5,7 +5,7 @@ description: Manage memory filesystem sync conflicts with git-like commands. Loa # Memory Filesystem Sync -When memFS is enabled, your memory blocks are mirrored as `.md` files on disk at `~/.letta/agents//memory/`. Changes to blocks or files are detected via content hashing and synced at startup and on manual `/memfs-sync`. +When memFS is enabled, your memory blocks are mirrored as `.md` files on disk at `~/.letta/agents//memory/`. Changes to blocks or files are detected via content hashing and synced at startup and on manual `/memfs sync`. **Conflicts** occur when both the file and the block are modified since the last sync (e.g., user edits a file in their editor while the block is also updated manually by the user via the API). Non-conflicting changes (only one side changed) are resolved automatically during the next sync. @@ -96,5 +96,5 @@ npx tsx /scripts/memfs-resolve.ts $LETTA_AGENT_ID --resolutions '[{"l ## Notes - Non-conflicting changes (only one side modified) are resolved automatically during the next sync — you only need to intervene for true conflicts -- The `/memfs-sync` command is still available for users to manually trigger sync and resolve conflicts via the CLI overlay +- The `/memfs sync` command is still available for users to manually trigger sync and resolve conflicts via the CLI overlay - After resolving, the sync state is updated so the same conflicts won't reappear diff --git a/src/tests/agent/memoryFilesystem.sync.integration.test.ts b/src/tests/agent/memoryFilesystem.sync.integration.test.ts index 1ded927..fa6f87d 100644 --- a/src/tests/agent/memoryFilesystem.sync.integration.test.ts +++ b/src/tests/agent/memoryFilesystem.sync.integration.test.ts @@ -489,4 +489,165 @@ describeIntegration("memfs sync integration", () => { expect(status.locationMismatches).toContain(label); expect(status.isClean).toBe(false); }); + + // ========================================================================= + // Read-only block tests + // ========================================================================= + + test("read_only block: file edit is overwritten by API content", async () => { + const label = `test-readonly-${Date.now()}`; + const originalContent = "Original read-only content"; + const editedContent = "User tried to edit this"; + + // Create a read_only block via API + const block = await client.blocks.create({ + label, + value: originalContent, + description: "Test read-only block", + read_only: true, + tags: [`owner:${testAgentId}`], + }); + createdBlockIds.push(block.id); + + // Attach to agent + await client.agents.blocks.attach(block.id, { agent_id: testAgentId }); + + // First sync - creates file + await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir }); + + // Verify file was created + const filePath = join(getSystemDir(), `${label}.md`); + expect(existsSync(filePath)).toBe(true); + + // Edit the file locally + writeFileSync(filePath, editedContent); + + // Second sync - should overwrite with API content + const result = await syncMemoryFilesystem(testAgentId, { + homeDir: tempHomeDir, + }); + + // File should be in updatedFiles (overwritten) + expect(result.updatedFiles).toContain(label); + + // Verify file content is back to original (API wins) + const fileContent = readFileSync(filePath, "utf-8"); + expect(fileContent).toContain(originalContent); + + // Verify block was NOT updated (still has original content) + const updatedBlock = await client.blocks.retrieve(block.id); + expect(updatedBlock.value).toBe(originalContent); + }); + + test("read_only block: deleted file is recreated", async () => { + const label = `test-readonly-delete-${Date.now()}`; + const content = "Content that should persist"; + + // Create a read_only block via API + const block = await client.blocks.create({ + label, + value: content, + description: "Test read-only block for deletion", + read_only: true, + tags: [`owner:${testAgentId}`], + }); + createdBlockIds.push(block.id); + + // Attach to agent + await client.agents.blocks.attach(block.id, { agent_id: testAgentId }); + + // First sync - creates file + await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir }); + + // Verify file was created + const filePath = join(getSystemDir(), `${label}.md`); + expect(existsSync(filePath)).toBe(true); + + // Delete the file locally + rmSync(filePath); + expect(existsSync(filePath)).toBe(false); + + // Second sync - should recreate file (not remove owner tag) + const result = await syncMemoryFilesystem(testAgentId, { + homeDir: tempHomeDir, + }); + + // File should be recreated + expect(result.createdFiles).toContain(label); + expect(existsSync(filePath)).toBe(true); + + // Verify block still has owner tag and is attached + const attachedBlocks = await client.agents.blocks.list(testAgentId); + const attachedArray = Array.isArray(attachedBlocks) + ? attachedBlocks + : (attachedBlocks as { items?: Array<{ id: string }> }).items || []; + expect(attachedArray.some((b) => b.id === block.id)).toBe(true); + }); + + test("read_only label: file-only (no block) is deleted", async () => { + // This tests the case where someone creates a file for a read_only label + // but no corresponding block exists - the file should be deleted + const label = "skills"; + + // Helper to ensure no block exists for this label + async function ensureNoBlock(labelToDelete: string) { + // Remove attached blocks with this label + const attachedBlocks = await getAttachedBlocks(); + for (const b of attachedBlocks.filter((x) => x.label === labelToDelete)) { + if (b.id) { + try { + await client.agents.blocks.detach(b.id, { agent_id: testAgentId }); + await client.blocks.delete(b.id); + } catch { + // Ignore errors (block may not be deletable) + } + } + } + // Remove detached owned blocks with this label + const ownedBlocks = await getOwnedBlocks(); + for (const b of ownedBlocks.filter((x) => x.label === labelToDelete)) { + if (b.id) { + try { + await client.blocks.delete(b.id); + } catch { + // Ignore errors + } + } + } + } + + // Ensure API has no block for this label + await ensureNoBlock(label); + + // Verify no block exists + const attachedBefore = await getAttachedBlocks(); + const ownedBefore = await getOwnedBlocks(); + const blockExists = + attachedBefore.some((b) => b.label === label) || + ownedBefore.some((b) => b.label === label); + + // For fresh test agents, there should be no skills block + // If one exists and can't be deleted, we can't run this test + expect(blockExists).toBe(false); + if (blockExists) { + // This assertion above will fail, but just in case: + return; + } + + // Create local file in system/ + writeSystemFile(label, "local skills content that should be deleted"); + + // Verify file was created + const filePath = join(getSystemDir(), `${label}.md`); + expect(existsSync(filePath)).toBe(true); + + // Sync - should delete the file (API is authoritative for read_only labels) + const result = await syncMemoryFilesystem(testAgentId, { + homeDir: tempHomeDir, + }); + + // File should be deleted + expect(existsSync(filePath)).toBe(false); + expect(result.deletedFiles).toContain(label); + }); });