fix(memfs): add frontmatter round-trip to preserve block metadata (#754)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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<string, Block>();
|
||||
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<string, { content: string }>();
|
||||
|
||||
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<string, unknown> = {
|
||||
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<string, Block>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
176
src/cli/App.tsx
176
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<Set<string>>(new Set());
|
||||
const memorySyncCommandIdRef = useRef<string | null>(null);
|
||||
const memorySyncCommandInputRef = useRef<string>("/memfs-sync");
|
||||
const memorySyncCommandInputRef = useRef<string>("/memfs sync");
|
||||
const memorySyncInFlightRef = useRef(false);
|
||||
const memoryFilesystemInitializedRef = useRef(false);
|
||||
const pendingMemfsConflictsRef = useRef<MemorySyncConflict[] | null>(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,
|
||||
});
|
||||
|
||||
@@ -60,18 +60,10 @@ export const commands: Record<string, Command> = {
|
||||
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...";
|
||||
|
||||
@@ -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/<agent-id>/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/<agent-id>/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 <SKILL_DIR>/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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user