From fe1070fb21cdb017eb45f0ffc188c66d1a32399e Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Thu, 29 Jan 2026 14:36:21 -0800 Subject: [PATCH] feat(memfs): flatten directory structure - detached blocks at root (#743) Co-authored-by: Letta --- src/agent/memoryFilesystem.ts | 170 +++++++++--------- src/agent/prompts/init_memory.md | 17 +- .../builtin/defragmenting-memory/SKILL.md | 9 +- .../builtin/initializing-memory/SKILL.md | 17 +- .../scripts/memfs-diff.ts | 53 +++--- .../scripts/memfs-resolve.ts | 72 ++++---- .../scripts/memfs-status.ts | 53 +++--- src/tests/agent/memoryFilesystem.test.ts | 8 +- 8 files changed, 207 insertions(+), 192 deletions(-) diff --git a/src/agent/memoryFilesystem.ts b/src/agent/memoryFilesystem.ts index a9d1c0b..8cb7946 100644 --- a/src/agent/memoryFilesystem.ts +++ b/src/agent/memoryFilesystem.ts @@ -17,6 +17,7 @@ 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"; +/** @deprecated Detached blocks now go at root level, not in /user/ */ export const MEMORY_USER_DIR = "user"; export const MEMORY_FS_STATE_FILE = ".sync-state.json"; @@ -33,9 +34,9 @@ const MANAGED_BLOCK_LABELS = new Set([ type SyncState = { systemBlocks: Record; systemFiles: Record; - userBlocks: Record; - userFiles: Record; - userBlockIds: Record; + detachedBlocks: Record; + detachedFiles: Record; + detachedBlockIds: Record; lastSync: string | null; }; @@ -95,11 +96,16 @@ export function getMemorySystemDir( return join(getMemoryFilesystemRoot(agentId, homeDir), MEMORY_SYSTEM_DIR); } -export function getMemoryUserDir( +/** + * Get the directory for detached (non-attached) blocks. + * In the flat structure, detached blocks go directly in the memory root. + */ +export function getMemoryDetachedDir( agentId: string, homeDir: string = homedir(), ): string { - return join(getMemoryFilesystemRoot(agentId, homeDir), MEMORY_USER_DIR); + // Detached blocks go at root level (flat structure) + return getMemoryFilesystemRoot(agentId, homeDir); } function getMemoryStatePath( @@ -115,7 +121,6 @@ export function ensureMemoryFilesystemDirs( ): void { const root = getMemoryFilesystemRoot(agentId, homeDir); const systemDir = getMemorySystemDir(agentId, homeDir); - const userDir = getMemoryUserDir(agentId, homeDir); if (!existsSync(root)) { mkdirSync(root, { recursive: true }); @@ -123,9 +128,7 @@ export function ensureMemoryFilesystemDirs( if (!existsSync(systemDir)) { mkdirSync(systemDir, { recursive: true }); } - if (!existsSync(userDir)) { - mkdirSync(userDir, { recursive: true }); - } + // Note: detached blocks go directly in root, no separate directory needed } function hashContent(content: string): string { @@ -141,9 +144,9 @@ function loadSyncState( return { systemBlocks: {}, systemFiles: {}, - userBlocks: {}, - userFiles: {}, - userBlockIds: {}, + detachedBlocks: {}, + detachedFiles: {}, + detachedBlockIds: {}, lastSync: null, }; } @@ -157,18 +160,18 @@ function loadSyncState( return { systemBlocks: parsed.systemBlocks || parsed.blocks || {}, systemFiles: parsed.systemFiles || parsed.files || {}, - userBlocks: parsed.userBlocks || {}, - userFiles: parsed.userFiles || {}, - userBlockIds: parsed.userBlockIds || {}, + detachedBlocks: parsed.detachedBlocks || {}, + detachedFiles: parsed.detachedFiles || {}, + detachedBlockIds: parsed.detachedBlockIds || {}, lastSync: parsed.lastSync || null, }; } catch { return { systemBlocks: {}, systemFiles: {}, - userBlocks: {}, - userFiles: {}, - userBlockIds: {}, + detachedBlocks: {}, + detachedFiles: {}, + detachedBlockIds: {}, lastSync: null, }; } @@ -305,15 +308,16 @@ async function fetchAgentBlocks(agentId: string): Promise { export function renderMemoryFilesystemTree( systemLabels: string[], - userLabels: string[], + detachedLabels: string[], ): string { type TreeNode = { children: Map; isFile: boolean }; const makeNode = (): TreeNode => ({ children: new Map(), isFile: false }); const root = makeNode(); - const insertPath = (base: string, label: string) => { - const parts = [base, ...label.split("/")]; + const insertPath = (base: string | null, label: string) => { + // If base is null, insert at root level + const parts = base ? [base, ...label.split("/")] : label.split("/"); let current = root; for (const [i, partName] of parts.entries()) { const part = i === parts.length - 1 ? `${partName}.md` : partName; @@ -327,19 +331,19 @@ export function renderMemoryFilesystemTree( } }; + // System blocks go in /system/ for (const label of systemLabels) { insertPath(MEMORY_SYSTEM_DIR, label); } - for (const label of userLabels) { - insertPath(MEMORY_USER_DIR, label); + // Detached blocks go at root level (flat structure) + for (const label of detachedLabels) { + insertPath(null, label); } + // Always show system/ directory even if empty if (!root.children.has(MEMORY_SYSTEM_DIR)) { root.children.set(MEMORY_SYSTEM_DIR, makeNode()); } - 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()); @@ -374,9 +378,9 @@ export function renderMemoryFilesystemTree( function buildStateHashes( systemBlocks: Map, systemFiles: Map, - userBlocks: Map, - userFiles: Map, - userBlockIds: Record, + detachedBlocks: Map, + detachedFiles: Map, + detachedBlockIds: Record, ): SyncState { const systemBlockHashes: Record = {}; const systemFileHashes: Record = {}; @@ -391,20 +395,20 @@ function buildStateHashes( systemFileHashes[label] = hashContent(file.content || ""); }); - userBlocks.forEach((block, label) => { + detachedBlocks.forEach((block, label) => { userBlockHashes[label] = hashContent(block.value || ""); }); - userFiles.forEach((file, label) => { + detachedFiles.forEach((file, label) => { userFileHashes[label] = hashContent(file.content || ""); }); return { systemBlocks: systemBlockHashes, systemFiles: systemFileHashes, - userBlocks: userBlockHashes, - userFiles: userFileHashes, - userBlockIds, + detachedBlocks: userBlockHashes, + detachedFiles: userFileHashes, + detachedBlockIds, lastSync: new Date().toISOString(), }; } @@ -417,9 +421,9 @@ export async function syncMemoryFilesystem( ensureMemoryFilesystemDirs(agentId, homeDir); const systemDir = getMemorySystemDir(agentId, homeDir); - const userDir = getMemoryUserDir(agentId, homeDir); + const detachedDir = getMemoryDetachedDir(agentId, homeDir); const systemFiles = await readMemoryFiles(systemDir); - const userFiles = await readMemoryFiles(userDir); + const detachedFiles = await readMemoryFiles(detachedDir); systemFiles.delete(MEMORY_FILESYSTEM_BLOCK_LABEL); const attachedBlocks = await fetchAgentBlocks(agentId); @@ -449,14 +453,14 @@ export async function syncMemoryFilesystem( const client = await getClient(); - const userBlockIds = { ...lastState.userBlockIds }; - const userBlockMap = new Map(); - for (const [label, blockId] of Object.entries(userBlockIds)) { + const detachedBlockIds = { ...lastState.detachedBlockIds }; + const detachedBlockMap = new Map(); + for (const [label, blockId] of Object.entries(detachedBlockIds)) { try { const block = await client.blocks.retrieve(blockId); - userBlockMap.set(label, block as Block); + detachedBlockMap.set(label, block as Block); } catch { - delete userBlockIds[label]; + delete detachedBlockIds[label]; } } @@ -602,22 +606,22 @@ export async function syncMemoryFilesystem( } } - const userLabels = new Set([ - ...Array.from(userFiles.keys()), - ...Array.from(userBlockMap.keys()), - ...Object.keys(lastState.userBlocks), - ...Object.keys(lastState.userFiles), + const detachedLabels = new Set([ + ...Array.from(detachedFiles.keys()), + ...Array.from(detachedBlockMap.keys()), + ...Object.keys(lastState.detachedBlocks), + ...Object.keys(lastState.detachedFiles), ]); - for (const label of Array.from(userLabels).sort()) { - const fileEntry = userFiles.get(label); - const blockEntry = userBlockMap.get(label); + for (const label of Array.from(detachedLabels).sort()) { + const fileEntry = detachedFiles.get(label); + const blockEntry = detachedBlockMap.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 lastFileHash = lastState.detachedFiles[label] || null; + const lastBlockHash = lastState.detachedBlocks[label] || null; const fileChanged = fileHash !== lastFileHash; const blockChanged = blockHash !== lastBlockHash; @@ -627,17 +631,17 @@ export async function syncMemoryFilesystem( if (fileEntry && !blockEntry) { if (lastBlockHash && !fileChanged) { // Block was deleted elsewhere; delete file. - await deleteMemoryFile(userDir, label); + await deleteMemoryFile(detachedDir, label); deletedFiles.push(label); - delete userBlockIds[label]; + delete detachedBlockIds[label]; continue; } const blockData = parseBlockFromFileContent(fileEntry.content, label); const createdBlock = await client.blocks.create(blockData); if (createdBlock.id) { - userBlockIds[blockData.label] = createdBlock.id; - userBlockMap.set(blockData.label, createdBlock as Block); + detachedBlockIds[blockData.label] = createdBlock.id; + detachedBlockMap.set(blockData.label, createdBlock as Block); } createdBlocks.push(blockData.label); continue; @@ -650,11 +654,11 @@ export async function syncMemoryFilesystem( await client.blocks.delete(blockEntry.id); } deletedBlocks.push(label); - delete userBlockIds[label]; + delete detachedBlockIds[label]; continue; } - await writeMemoryFile(userDir, label, blockEntry.value || ""); + await writeMemoryFile(detachedDir, label, blockEntry.value || ""); createdFiles.push(label); continue; } @@ -689,7 +693,7 @@ export async function syncMemoryFilesystem( } if (resolution?.resolution === "block") { - await writeMemoryFile(userDir, label, blockEntry.value || ""); + await writeMemoryFile(detachedDir, label, blockEntry.value || ""); updatedFiles.push(label); continue; } @@ -706,7 +710,7 @@ export async function syncMemoryFilesystem( } if (!fileChanged && blockChanged) { - await writeMemoryFile(userDir, label, blockEntry.value || ""); + await writeMemoryFile(detachedDir, label, blockEntry.value || ""); updatedFiles.push(label); } } @@ -724,15 +728,15 @@ export async function syncMemoryFilesystem( const updatedSystemFilesMap = await readMemoryFiles(systemDir); updatedSystemFilesMap.delete(MEMORY_FILESYSTEM_BLOCK_LABEL); - const updatedUserFilesMap = await readMemoryFiles(userDir); + const updatedUserFilesMap = await readMemoryFiles(detachedDir); const refreshedUserBlocks = new Map(); - for (const [label, blockId] of Object.entries(userBlockIds)) { + for (const [label, blockId] of Object.entries(detachedBlockIds)) { try { const block = await client.blocks.retrieve(blockId); refreshedUserBlocks.set(label, { value: block.value || "" }); } catch { - delete userBlockIds[label]; + delete detachedBlockIds[label]; } } @@ -741,7 +745,7 @@ export async function syncMemoryFilesystem( updatedSystemFilesMap, refreshedUserBlocks, updatedUserFilesMap, - userBlockIds, + detachedBlockIds, ); await saveSyncState(nextState, agentId, homeDir); } @@ -762,16 +766,16 @@ export async function updateMemoryFilesystemBlock( homeDir: string = homedir(), ) { const systemDir = getMemorySystemDir(agentId, homeDir); - const userDir = getMemoryUserDir(agentId, homeDir); + const detachedDir = getMemoryDetachedDir(agentId, homeDir); const systemFiles = await readMemoryFiles(systemDir); - const userFiles = await readMemoryFiles(userDir); + const detachedFiles = await readMemoryFiles(detachedDir); const tree = renderMemoryFilesystemTree( Array.from(systemFiles.keys()).filter( (label) => label !== MEMORY_FILESYSTEM_BLOCK_LABEL, ), - Array.from(userFiles.keys()), + Array.from(detachedFiles.keys()), ); const client = await getClient(); @@ -866,9 +870,9 @@ export async function checkMemoryFilesystemStatus( ensureMemoryFilesystemDirs(agentId, homeDir); const systemDir = getMemorySystemDir(agentId, homeDir); - const userDir = getMemoryUserDir(agentId, homeDir); + const detachedDir = getMemoryDetachedDir(agentId, homeDir); const systemFiles = await readMemoryFiles(systemDir); - const userFiles = await readMemoryFiles(userDir); + const detachedFiles = await readMemoryFiles(detachedDir); systemFiles.delete(MEMORY_FILESYSTEM_BLOCK_LABEL); const attachedBlocks = await fetchAgentBlocks(agentId); @@ -888,15 +892,15 @@ export async function checkMemoryFilesystemStatus( const newBlocks: string[] = []; // Fetch user blocks for status check - const userBlockIds = { ...lastState.userBlockIds }; - const userBlockMap = new Map(); + const detachedBlockIds = { ...lastState.detachedBlockIds }; + const detachedBlockMap = new Map(); const client = await getClient(); - for (const [label, blockId] of Object.entries(userBlockIds)) { + for (const [label, blockId] of Object.entries(detachedBlockIds)) { try { const block = await client.blocks.retrieve(blockId); - userBlockMap.set(label, block as Block); + detachedBlockMap.set(label, block as Block); } catch { - delete userBlockIds[label]; + delete detachedBlockIds[label]; } } @@ -925,20 +929,20 @@ export async function checkMemoryFilesystemStatus( } // Check user labels - const userLabels = new Set([ - ...Array.from(userFiles.keys()), - ...Array.from(userBlockMap.keys()), - ...Object.keys(lastState.userBlocks), - ...Object.keys(lastState.userFiles), + const detachedLabels = new Set([ + ...Array.from(detachedFiles.keys()), + ...Array.from(detachedBlockMap.keys()), + ...Object.keys(lastState.detachedBlocks), + ...Object.keys(lastState.detachedFiles), ]); - for (const label of Array.from(userLabels).sort()) { + for (const label of Array.from(detachedLabels).sort()) { classifyLabel( label, - userFiles.get(label)?.content ?? null, - userBlockMap.get(label)?.value ?? null, - lastState.userFiles[label] ?? null, - lastState.userBlocks[label] ?? null, + detachedFiles.get(label)?.content ?? null, + detachedBlockMap.get(label)?.value ?? null, + lastState.detachedFiles[label] ?? null, + lastState.detachedBlocks[label] ?? null, conflicts, pendingFromFile, pendingFromBlock, diff --git a/src/agent/prompts/init_memory.md b/src/agent/prompts/init_memory.md index d53624f..dae9158 100644 --- a/src/agent/prompts/init_memory.md +++ b/src/agent/prompts/init_memory.md @@ -43,7 +43,8 @@ This changes how you should approach initialization: │ ├── human.md # User information │ ├── project/ # Project-specific info │ └── ... -└── user/ # Detached blocks (loaded on demand) +├── notes.md # Detached block at root (on-demand) +└── archive/ # Detached blocks can be nested too └── ... ``` @@ -164,15 +165,15 @@ Consider whether information is: ## Recommended Memory Structure -**Understanding system/ vs user/ (with memory filesystem):** +**Understanding system/ vs root level (with memory filesystem):** - **system/**: Memory blocks attached to your system prompt - always loaded and influence your behavior - Use for: Current work context, active preferences, project conventions you need constantly - Examples: `persona`, `human`, `project`, active `ticket` or `context` -- **user/**: Detached blocks - not in system prompt but available via tools +- **Root level** (outside system/): Detached blocks - not in system prompt but available via tools - Use for: Historical information, archived decisions, reference material, completed investigations - - Examples: Past project notes, old ticket context, archived decisions + - Examples: `notes.md`, `archive/old-project.md`, `research/findings.md` -**Rule of thumb**: If you need to see it every time you respond → `system/`. If it's reference material you'll look up occasionally → `user/`. +**Rule of thumb**: If you need to see it every time you respond → `system/`. If it's reference material you'll look up occasionally → root level. ### Core Blocks (Usually Present in system/) @@ -202,18 +203,18 @@ Consider whether information is: - A ticket/task memory block is a **scratchpad** for pinned context that should stay visible - Examples: Linear ticket ID and URL, Jira issue key, branch name, PR number, relevant links - Information that's useful to keep in context but doesn't fit in a TODO list -- **Location**: Usually in `system/` if you want it always visible, or `user/` if it's reference material +- **Location**: Usually in `system/` if you want it always visible, or root level if it's reference material **`context`**: Debugging or investigation scratchpad - Current hypotheses being tested - Files already examined - Clues and observations -- **Location**: Usually in `system/` during active investigations, move to `user/` when complete +- **Location**: Usually in `system/` during active investigations, move to root level when complete **`decisions`**: Architectural decisions and their rationale - Why certain approaches were chosen - Trade-offs that were considered -- **Location**: `system/` for currently relevant decisions, `user/` for historical archive +- **Location**: `system/` for currently relevant decisions, root level for historical archive - **With memfs**: Could organize as `project/decisions/architecture.md`, `project/decisions/tech_stack.md` ## Writing Good Memory Blocks diff --git a/src/skills/builtin/defragmenting-memory/SKILL.md b/src/skills/builtin/defragmenting-memory/SKILL.md index d6894d8..966e3f3 100644 --- a/src/skills/builtin/defragmenting-memory/SKILL.md +++ b/src/skills/builtin/defragmenting-memory/SKILL.md @@ -9,7 +9,7 @@ description: Decomposes and reorganizes agent memory blocks into focused, single > > This skill works by directly editing memory files on disk. It requires the memory filesystem feature to be enabled. > -> **To check:** Look for a `memory_filesystem` block in your system prompt. If it shows a tree structure of `/memory/system/` and `/memory/user/`, memfs is enabled. +> **To check:** Look for a `memory_filesystem` block in your system prompt. If it shows a tree structure starting with `/memory/` including a `system/` directory, memfs is enabled. > > **To enable:** Ask the user to run `/memfs enable`, then reload the CLI. @@ -58,7 +58,8 @@ These files ARE the agent's memory — they sync directly to API memory blocks v ~/.letta/agents//memory/ ├── system/ ← Attached blocks (always loaded in system prompt) — EDIT THESE -├── user/ ← Detached blocks (on-demand) — can create new files here +├── notes.md ← Detached blocks at root level (on-demand) — can create here +├── archive/ ← Detached blocks can be nested too └── .sync-state.json ← DO NOT EDIT (internal sync tracking) ## Files to Skip (DO NOT edit) @@ -74,7 +75,7 @@ These files ARE the agent's memory — they sync directly to API memory blocks v - Any other non-system blocks present ## How Memfs File ↔ Block Mapping Works -- File path relative to system/ or user/ becomes the block label +- File path relative to memory root becomes the block label (system/ prefix for attached, root level for detached) - Example: system/project/tooling/bun.md → block label "project/tooling/bun" - New files you create will become new memory blocks on next sync - Files you delete will cause the corresponding blocks to be deleted on next sync @@ -145,7 +146,7 @@ Provide a detailed report including: ``` The subagent will: -- Read files from `~/.letta/agents//memory/system/` (and `user/`) +- Read files from `~/.letta/agents//memory/system/` (and root level for detached) - Edit them to reorganize and decompose large blocks - Create new hierarchically-named files (e.g., `project/overview.md`) - Add clear structure with markdown formatting diff --git a/src/skills/builtin/initializing-memory/SKILL.md b/src/skills/builtin/initializing-memory/SKILL.md index c213ab9..6dc005c 100644 --- a/src/skills/builtin/initializing-memory/SKILL.md +++ b/src/skills/builtin/initializing-memory/SKILL.md @@ -48,7 +48,8 @@ This changes how you should approach initialization: │ ├── human.md # User information │ ├── project/ # Project-specific info │ └── ... -└── user/ # Detached blocks (loaded on demand) +├── notes.md # Detached block at root (on-demand) +└── archive/ # Detached blocks can be nested too └── ... ``` @@ -169,15 +170,15 @@ Consider whether information is: ## Recommended Memory Structure -**Understanding system/ vs user/ (with memory filesystem):** +**Understanding system/ vs root level (with memory filesystem):** - **system/**: Memory blocks attached to your system prompt - always loaded and influence your behavior - Use for: Current work context, active preferences, project conventions you need constantly - Examples: `persona`, `human`, `project`, active `ticket` or `context` -- **user/**: Detached blocks - not in system prompt but available via tools +- **Root level** (outside system/): Detached blocks - not in system prompt but available via tools - Use for: Historical information, archived decisions, reference material, completed investigations - - Examples: Past project notes, old ticket context, archived decisions + - Examples: `notes.md`, `archive/old-project.md`, `research/findings.md` -**Rule of thumb**: If you need to see it every time you respond → `system/`. If it's reference material you'll look up occasionally → `user/`. +**Rule of thumb**: If you need to see it every time you respond → `system/`. If it's reference material you'll look up occasionally → root level. ### Core Blocks (Usually Present in system/) @@ -207,18 +208,18 @@ Consider whether information is: - A ticket/task memory block is a **scratchpad** for pinned context that should stay visible - Examples: Linear ticket ID and URL, Jira issue key, branch name, PR number, relevant links - Information that's useful to keep in context but doesn't fit in a TODO list -- **Location**: Usually in `system/` if you want it always visible, or `user/` if it's reference material +- **Location**: Usually in `system/` if you want it always visible, or root level if it's reference material **`context`**: Debugging or investigation scratchpad - Current hypotheses being tested - Files already examined - Clues and observations -- **Location**: Usually in `system/` during active investigations, move to `user/` when complete +- **Location**: Usually in `system/` during active investigations, move to root level when complete **`decisions`**: Architectural decisions and their rationale - Why certain approaches were chosen - Trade-offs that were considered -- **Location**: `system/` for currently relevant decisions, `user/` for historical archive +- **Location**: `system/` for currently relevant decisions, root level for historical archive - **With memfs**: Could organize as `project/decisions/architecture.md`, `project/decisions/tech_stack.md` ## Writing Good Memory Blocks diff --git a/src/skills/builtin/syncing-memory-filesystem/scripts/memfs-diff.ts b/src/skills/builtin/syncing-memory-filesystem/scripts/memfs-diff.ts index 393b51b..9dbe58b 100644 --- a/src/skills/builtin/syncing-memory-filesystem/scripts/memfs-diff.ts +++ b/src/skills/builtin/syncing-memory-filesystem/scripts/memfs-diff.ts @@ -48,9 +48,9 @@ const MEMORY_FS_STATE_FILE = ".sync-state.json"; type SyncState = { systemBlocks: Record; systemFiles: Record; - userBlocks: Record; - userFiles: Record; - userBlockIds: Record; + detachedBlocks: Record; + detachedFiles: Record; + detachedBlockIds: Record; lastSync: string | null; }; @@ -68,9 +68,9 @@ function loadSyncState(agentId: string): SyncState { return { systemBlocks: {}, systemFiles: {}, - userBlocks: {}, - userFiles: {}, - userBlockIds: {}, + detachedBlocks: {}, + detachedFiles: {}, + detachedBlockIds: {}, lastSync: null, }; } @@ -84,18 +84,18 @@ function loadSyncState(agentId: string): SyncState { return { systemBlocks: parsed.systemBlocks || parsed.blocks || {}, systemFiles: parsed.systemFiles || parsed.files || {}, - userBlocks: parsed.userBlocks || {}, - userFiles: parsed.userFiles || {}, - userBlockIds: parsed.userBlockIds || {}, + detachedBlocks: parsed.detachedBlocks || {}, + detachedFiles: parsed.detachedFiles || {}, + detachedBlockIds: parsed.detachedBlockIds || {}, lastSync: parsed.lastSync || null, }; } catch { return { systemBlocks: {}, systemFiles: {}, - userBlocks: {}, - userFiles: {}, - userBlockIds: {}, + detachedBlocks: {}, + detachedFiles: {}, + detachedBlockIds: {}, lastSync: null, }; } @@ -166,14 +166,15 @@ async function findConflicts(agentId: string): Promise { const root = getMemoryRoot(agentId); const systemDir = join(root, "system"); - const userDir = join(root, "user"); + // Detached files go at root level (flat structure) + const detachedDir = root; - for (const dir of [root, systemDir, userDir]) { + for (const dir of [root, systemDir]) { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } const systemFiles = await readMemoryFiles(systemDir); - const userFiles = await readMemoryFiles(userDir); + const detachedFiles = await readMemoryFiles(detachedDir); systemFiles.delete("memory_filesystem"); const blocksResponse = await client.agents.blocks.list(agentId, { @@ -198,11 +199,11 @@ async function findConflicts(agentId: string): Promise { const lastState = loadSyncState(agentId); - const userBlockMap = new Map(); - for (const [label, blockId] of Object.entries(lastState.userBlockIds)) { + const detachedBlockMap = new Map(); + for (const [label, blockId] of Object.entries(lastState.detachedBlockIds)) { try { const block = await client.blocks.retrieve(blockId); - userBlockMap.set(label, block.value || ""); + detachedBlockMap.set(label, block.value || ""); } catch { // Block no longer exists } @@ -249,19 +250,19 @@ async function findConflicts(agentId: string): Promise { // Check user labels const userLabels = new Set([ - ...userFiles.keys(), - ...userBlockMap.keys(), - ...Object.keys(lastState.userBlocks), - ...Object.keys(lastState.userFiles), + ...detachedFiles.keys(), + ...detachedBlockMap.keys(), + ...Object.keys(lastState.detachedBlocks), + ...Object.keys(lastState.detachedFiles), ]); for (const label of [...userLabels].sort()) { checkConflict( label, - userFiles.get(label)?.content ?? null, - userBlockMap.get(label) ?? null, - lastState.userFiles[label] ?? null, - lastState.userBlocks[label] ?? null, + detachedFiles.get(label)?.content ?? null, + detachedBlockMap.get(label) ?? null, + lastState.detachedFiles[label] ?? null, + lastState.detachedBlocks[label] ?? null, ); } diff --git a/src/skills/builtin/syncing-memory-filesystem/scripts/memfs-resolve.ts b/src/skills/builtin/syncing-memory-filesystem/scripts/memfs-resolve.ts index 3c59371..af0d14d 100644 --- a/src/skills/builtin/syncing-memory-filesystem/scripts/memfs-resolve.ts +++ b/src/skills/builtin/syncing-memory-filesystem/scripts/memfs-resolve.ts @@ -58,9 +58,9 @@ const MEMORY_FS_STATE_FILE = ".sync-state.json"; type SyncState = { systemBlocks: Record; systemFiles: Record; - userBlocks: Record; - userFiles: Record; - userBlockIds: Record; + detachedBlocks: Record; + detachedFiles: Record; + detachedBlockIds: Record; lastSync: string | null; }; @@ -78,9 +78,9 @@ function loadSyncState(agentId: string): SyncState { return { systemBlocks: {}, systemFiles: {}, - userBlocks: {}, - userFiles: {}, - userBlockIds: {}, + detachedBlocks: {}, + detachedFiles: {}, + detachedBlockIds: {}, lastSync: null, }; } @@ -94,18 +94,18 @@ function loadSyncState(agentId: string): SyncState { return { systemBlocks: parsed.systemBlocks || parsed.blocks || {}, systemFiles: parsed.systemFiles || parsed.files || {}, - userBlocks: parsed.userBlocks || {}, - userFiles: parsed.userFiles || {}, - userBlockIds: parsed.userBlockIds || {}, + detachedBlocks: parsed.detachedBlocks || {}, + detachedFiles: parsed.detachedFiles || {}, + detachedBlockIds: parsed.detachedBlockIds || {}, lastSync: parsed.lastSync || null, }; } catch { return { systemBlocks: {}, systemFiles: {}, - userBlocks: {}, - userFiles: {}, - userBlockIds: {}, + detachedBlocks: {}, + detachedFiles: {}, + detachedBlockIds: {}, lastSync: null, }; } @@ -171,15 +171,16 @@ async function resolveConflicts( const root = getMemoryRoot(agentId); const systemDir = join(root, "system"); - const userDir = join(root, "user"); + // Detached files go at root level (flat structure) + const detachedDir = root; - for (const dir of [root, systemDir, userDir]) { + for (const dir of [root, systemDir]) { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } // Read current state const systemFiles = await readMemoryFiles(systemDir); - const userFiles = await readMemoryFiles(userDir); + const detachedFiles = await readMemoryFiles(detachedDir); systemFiles.delete("memory_filesystem"); const blocksResponse = await client.agents.blocks.list(agentId, { @@ -203,11 +204,14 @@ async function resolveConflicts( ); const lastState = loadSyncState(agentId); - const userBlockMap = new Map(); - for (const [label, blockId] of Object.entries(lastState.userBlockIds)) { + const detachedBlockMap = new Map(); + for (const [label, blockId] of Object.entries(lastState.detachedBlockIds)) { try { const block = await client.blocks.retrieve(blockId); - userBlockMap.set(label, { id: block.id || "", value: block.value || "" }); + detachedBlockMap.set(label, { + id: block.id || "", + value: block.value || "", + }); } catch { // Block no longer exists } @@ -220,16 +224,16 @@ async function resolveConflicts( // Check system blocks/files first, then user blocks/files const systemBlock = systemBlockMap.get(label); const systemFile = systemFiles.get(label); - const userBlock = userBlockMap.get(label); - const userFile = userFiles.get(label); + const detachedBlock = detachedBlockMap.get(label); + const detachedFile = detachedFiles.get(label); - const block = systemBlock || userBlock; - const file = systemFile || userFile; + const block = systemBlock || detachedBlock; + const file = systemFile || detachedFile; const dir = systemBlock || systemFile ? systemDir - : userBlock || userFile - ? userDir + : detachedBlock || detachedFile + ? detachedDir : null; if (!block || !file || !dir) { @@ -268,7 +272,7 @@ async function resolveConflicts( // Update sync state after resolving all conflicts // Re-read everything to capture the new state const updatedSystemFiles = await readMemoryFiles(systemDir); - const updatedUserFiles = await readMemoryFiles(userDir); + const updatedDetachedFiles = await readMemoryFiles(detachedDir); updatedSystemFiles.delete("memory_filesystem"); const updatedBlocksResponse = await client.agents.blocks.list(agentId, { @@ -283,8 +287,8 @@ async function resolveConflicts( const systemBlockHashes: Record = {}; const systemFileHashes: Record = {}; - const userBlockHashes: Record = {}; - const userFileHashes: Record = {}; + const detachedBlockHashes: Record = {}; + const detachedFileHashes: Record = {}; for (const block of updatedBlocks.filter( (b: { label?: string }) => b.label && b.label !== "memory_filesystem", @@ -298,26 +302,26 @@ async function resolveConflicts( systemFileHashes[label] = hashContent(file.content); } - for (const [label, blockId] of Object.entries(lastState.userBlockIds)) { + for (const [label, blockId] of Object.entries(lastState.detachedBlockIds)) { try { const block = await client.blocks.retrieve(blockId); - userBlockHashes[label] = hashContent(block.value || ""); + detachedBlockHashes[label] = hashContent(block.value || ""); } catch { // Block gone } } - for (const [label, file] of updatedUserFiles) { - userFileHashes[label] = hashContent(file.content); + for (const [label, file] of updatedDetachedFiles) { + detachedFileHashes[label] = hashContent(file.content); } saveSyncState( { systemBlocks: systemBlockHashes, systemFiles: systemFileHashes, - userBlocks: userBlockHashes, - userFiles: userFileHashes, - userBlockIds: lastState.userBlockIds, + detachedBlocks: detachedBlockHashes, + detachedFiles: detachedFileHashes, + detachedBlockIds: lastState.detachedBlockIds, lastSync: new Date().toISOString(), }, agentId, diff --git a/src/skills/builtin/syncing-memory-filesystem/scripts/memfs-status.ts b/src/skills/builtin/syncing-memory-filesystem/scripts/memfs-status.ts index 263bf7c..d0771bb 100644 --- a/src/skills/builtin/syncing-memory-filesystem/scripts/memfs-status.ts +++ b/src/skills/builtin/syncing-memory-filesystem/scripts/memfs-status.ts @@ -56,9 +56,9 @@ const MEMORY_FS_STATE_FILE = ".sync-state.json"; type SyncState = { systemBlocks: Record; systemFiles: Record; - userBlocks: Record; - userFiles: Record; - userBlockIds: Record; + detachedBlocks: Record; + detachedFiles: Record; + detachedBlockIds: Record; lastSync: string | null; }; @@ -76,9 +76,9 @@ function loadSyncState(agentId: string): SyncState { return { systemBlocks: {}, systemFiles: {}, - userBlocks: {}, - userFiles: {}, - userBlockIds: {}, + detachedBlocks: {}, + detachedFiles: {}, + detachedBlockIds: {}, lastSync: null, }; } @@ -92,18 +92,18 @@ function loadSyncState(agentId: string): SyncState { return { systemBlocks: parsed.systemBlocks || parsed.blocks || {}, systemFiles: parsed.systemFiles || parsed.files || {}, - userBlocks: parsed.userBlocks || {}, - userFiles: parsed.userFiles || {}, - userBlockIds: parsed.userBlockIds || {}, + detachedBlocks: parsed.detachedBlocks || {}, + detachedFiles: parsed.detachedFiles || {}, + detachedBlockIds: parsed.detachedBlockIds || {}, lastSync: parsed.lastSync || null, }; } catch { return { systemBlocks: {}, systemFiles: {}, - userBlocks: {}, - userFiles: {}, - userBlockIds: {}, + detachedBlocks: {}, + detachedFiles: {}, + detachedBlockIds: {}, lastSync: null, }; } @@ -163,15 +163,16 @@ async function checkStatus(agentId: string): Promise { const root = getMemoryRoot(agentId); const systemDir = join(root, "system"); - const userDir = join(root, "user"); + // Detached files go at root level (flat structure) + const detachedDir = root; // Ensure directories exist - for (const dir of [root, systemDir, userDir]) { + for (const dir of [root, systemDir]) { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } const systemFiles = await readMemoryFiles(systemDir); - const userFiles = await readMemoryFiles(userDir); + const detachedFiles = await readMemoryFiles(detachedDir); systemFiles.delete("memory_filesystem"); // Fetch attached blocks @@ -204,11 +205,11 @@ async function checkStatus(agentId: string): Promise { const newBlocks: string[] = []; // Fetch user blocks - const userBlockMap = new Map(); - for (const [label, blockId] of Object.entries(lastState.userBlockIds)) { + const detachedBlockMap = new Map(); + for (const [label, blockId] of Object.entries(lastState.detachedBlockIds)) { try { const block = await client.blocks.retrieve(blockId); - userBlockMap.set(label, block.value || ""); + detachedBlockMap.set(label, block.value || ""); } catch { // Block no longer exists } @@ -272,19 +273,19 @@ async function checkStatus(agentId: string): Promise { // Check user labels const userLabels = new Set([ - ...userFiles.keys(), - ...userBlockMap.keys(), - ...Object.keys(lastState.userBlocks), - ...Object.keys(lastState.userFiles), + ...detachedFiles.keys(), + ...detachedBlockMap.keys(), + ...Object.keys(lastState.detachedBlocks), + ...Object.keys(lastState.detachedFiles), ]); for (const label of [...userLabels].sort()) { classify( label, - userFiles.get(label)?.content ?? null, - userBlockMap.get(label) ?? null, - lastState.userFiles[label] ?? null, - lastState.userBlocks[label] ?? null, + detachedFiles.get(label)?.content ?? null, + detachedBlockMap.get(label) ?? null, + lastState.detachedFiles[label] ?? null, + lastState.detachedBlocks[label] ?? null, ); } diff --git a/src/tests/agent/memoryFilesystem.test.ts b/src/tests/agent/memoryFilesystem.test.ts index 6801641..c17bc2b 100644 --- a/src/tests/agent/memoryFilesystem.test.ts +++ b/src/tests/agent/memoryFilesystem.test.ts @@ -195,7 +195,7 @@ describe("renderMemoryFilesystemTree", () => { const tree = renderMemoryFilesystemTree([], []); expect(tree).toContain("/memory/"); expect(tree).toContain("system/"); - expect(tree).toContain("user/"); + // Note: detached blocks go at root level now, not in /user/ }); test("renders system blocks with nesting", () => { @@ -209,16 +209,18 @@ describe("renderMemoryFilesystemTree", () => { expect(tree).toContain("personal_info.md"); }); - test("renders both system and user blocks", () => { + test("renders both system and detached blocks", () => { const tree = renderMemoryFilesystemTree( ["persona"], ["notes/project-ideas"], ); expect(tree).toContain("system/"); expect(tree).toContain("persona.md"); - expect(tree).toContain("user/"); + // Detached blocks go at root level (flat structure) expect(tree).toContain("notes/"); expect(tree).toContain("project-ideas.md"); + // Should NOT have user/ directory anymore + expect(tree).not.toContain("user/"); }); });