feat(memfs): flatten directory structure - detached blocks at root (#743)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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<string, string>;
|
||||
systemFiles: Record<string, string>;
|
||||
userBlocks: Record<string, string>;
|
||||
userFiles: Record<string, string>;
|
||||
userBlockIds: Record<string, string>;
|
||||
detachedBlocks: Record<string, string>;
|
||||
detachedFiles: Record<string, string>;
|
||||
detachedBlockIds: Record<string, string>;
|
||||
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<Block[]> {
|
||||
|
||||
export function renderMemoryFilesystemTree(
|
||||
systemLabels: string[],
|
||||
userLabels: string[],
|
||||
detachedLabels: string[],
|
||||
): string {
|
||||
type TreeNode = { children: Map<string, TreeNode>; isFile: boolean };
|
||||
|
||||
const makeNode = (): TreeNode => ({ children: new Map(), isFile: false });
|
||||
const root = makeNode();
|
||||
|
||||
const insertPath = (base: string, 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<string, { value: string }>,
|
||||
systemFiles: Map<string, { content: string }>,
|
||||
userBlocks: Map<string, { value: string }>,
|
||||
userFiles: Map<string, { content: string }>,
|
||||
userBlockIds: Record<string, string>,
|
||||
detachedBlocks: Map<string, { value: string }>,
|
||||
detachedFiles: Map<string, { content: string }>,
|
||||
detachedBlockIds: Record<string, string>,
|
||||
): SyncState {
|
||||
const systemBlockHashes: Record<string, string> = {};
|
||||
const systemFileHashes: Record<string, string> = {};
|
||||
@@ -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<string, Block>();
|
||||
for (const [label, blockId] of Object.entries(userBlockIds)) {
|
||||
const detachedBlockIds = { ...lastState.detachedBlockIds };
|
||||
const detachedBlockMap = new Map<string, Block>();
|
||||
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<string>([
|
||||
...Array.from(userFiles.keys()),
|
||||
...Array.from(userBlockMap.keys()),
|
||||
...Object.keys(lastState.userBlocks),
|
||||
...Object.keys(lastState.userFiles),
|
||||
const detachedLabels = new Set<string>([
|
||||
...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<string, { value: string }>();
|
||||
|
||||
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<string, Block>();
|
||||
const detachedBlockIds = { ...lastState.detachedBlockIds };
|
||||
const detachedBlockMap = new Map<string, Block>();
|
||||
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<string>([
|
||||
...Array.from(userFiles.keys()),
|
||||
...Array.from(userBlockMap.keys()),
|
||||
...Object.keys(lastState.userBlocks),
|
||||
...Object.keys(lastState.userFiles),
|
||||
const detachedLabels = new Set<string>([
|
||||
...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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<agent-id>/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/<agent-id>/memory/system/` (and `user/`)
|
||||
- Read files from `~/.letta/agents/<agent-id>/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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,9 +48,9 @@ const MEMORY_FS_STATE_FILE = ".sync-state.json";
|
||||
type SyncState = {
|
||||
systemBlocks: Record<string, string>;
|
||||
systemFiles: Record<string, string>;
|
||||
userBlocks: Record<string, string>;
|
||||
userFiles: Record<string, string>;
|
||||
userBlockIds: Record<string, string>;
|
||||
detachedBlocks: Record<string, string>;
|
||||
detachedFiles: Record<string, string>;
|
||||
detachedBlockIds: Record<string, string>;
|
||||
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<Conflict[]> {
|
||||
|
||||
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<Conflict[]> {
|
||||
|
||||
const lastState = loadSyncState(agentId);
|
||||
|
||||
const userBlockMap = new Map<string, string>();
|
||||
for (const [label, blockId] of Object.entries(lastState.userBlockIds)) {
|
||||
const detachedBlockMap = new Map<string, string>();
|
||||
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<Conflict[]> {
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,9 +58,9 @@ const MEMORY_FS_STATE_FILE = ".sync-state.json";
|
||||
type SyncState = {
|
||||
systemBlocks: Record<string, string>;
|
||||
systemFiles: Record<string, string>;
|
||||
userBlocks: Record<string, string>;
|
||||
userFiles: Record<string, string>;
|
||||
userBlockIds: Record<string, string>;
|
||||
detachedBlocks: Record<string, string>;
|
||||
detachedFiles: Record<string, string>;
|
||||
detachedBlockIds: Record<string, string>;
|
||||
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<string, { id: string; value: string }>();
|
||||
for (const [label, blockId] of Object.entries(lastState.userBlockIds)) {
|
||||
const detachedBlockMap = new Map<string, { id: string; value: string }>();
|
||||
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<string, string> = {};
|
||||
const systemFileHashes: Record<string, string> = {};
|
||||
const userBlockHashes: Record<string, string> = {};
|
||||
const userFileHashes: Record<string, string> = {};
|
||||
const detachedBlockHashes: Record<string, string> = {};
|
||||
const detachedFileHashes: Record<string, string> = {};
|
||||
|
||||
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,
|
||||
|
||||
@@ -56,9 +56,9 @@ const MEMORY_FS_STATE_FILE = ".sync-state.json";
|
||||
type SyncState = {
|
||||
systemBlocks: Record<string, string>;
|
||||
systemFiles: Record<string, string>;
|
||||
userBlocks: Record<string, string>;
|
||||
userFiles: Record<string, string>;
|
||||
userBlockIds: Record<string, string>;
|
||||
detachedBlocks: Record<string, string>;
|
||||
detachedFiles: Record<string, string>;
|
||||
detachedBlockIds: Record<string, string>;
|
||||
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<StatusResult> {
|
||||
|
||||
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<StatusResult> {
|
||||
const newBlocks: string[] = [];
|
||||
|
||||
// Fetch user blocks
|
||||
const userBlockMap = new Map<string, string>();
|
||||
for (const [label, blockId] of Object.entries(lastState.userBlockIds)) {
|
||||
const detachedBlockMap = new Map<string, string>();
|
||||
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<StatusResult> {
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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/");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user