feat: memory filesystem sync (#905)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-10 18:06:05 -08:00
committed by GitHub
parent eaa813ddb9
commit d1a6eeb40a
13 changed files with 1085 additions and 3079 deletions

View File

@@ -43,16 +43,8 @@ import { getLettaCodeHeaders } from "../agent/http-headers";
import { ISOLATED_BLOCK_LABELS } from "../agent/memory";
import {
checkMemoryFilesystemStatus,
detachMemoryFilesystemBlock,
ensureMemoryFilesystemBlock,
ensureMemoryFilesystemDirs,
formatMemorySyncSummary,
getMemoryFilesystemRoot,
type MemorySyncConflict,
type MemorySyncResolution,
syncMemoryFilesystem,
updateMemoryFilesystemBlock,
} from "../agent/memoryFilesystem";
import { sendMessageStream } from "../agent/message";
import { getModelInfo, getModelShortName } from "../agent/model";
@@ -125,7 +117,6 @@ import { EventMessage } from "./components/EventMessage";
import { FeedbackDialog } from "./components/FeedbackDialog";
import { HelpDialog } from "./components/HelpDialog";
import { HooksManager } from "./components/HooksManager";
import { InlineQuestionApproval } from "./components/InlineQuestionApproval";
import { Input } from "./components/InputRich";
import { McpConnectFlow } from "./components/McpConnectFlow";
import { McpSelector } from "./components/McpSelector";
@@ -233,7 +224,6 @@ import {
import {
isFileEditTool,
isFileWriteTool,
isMemoryTool,
isPatchTool,
isShellTool,
} from "./helpers/toolNameMapping";
@@ -1137,20 +1127,16 @@ export default function App({
openingOutput: string;
dismissOutput: string;
} | null>(null);
const [memorySyncConflicts, setMemorySyncConflicts] = useState<
MemorySyncConflict[] | null
>(null);
const memorySyncProcessedToolCallsRef = useRef<Set<string>>(new Set());
const memorySyncCommandIdRef = useRef<string | null>(null);
const memorySyncCommandInputRef = useRef<string>("/memfs sync");
const memorySyncInFlightRef = useRef(false);
const memoryFilesystemInitializedRef = useRef(false);
const pendingMemfsConflictsRef = useRef<MemorySyncConflict[] | null>(null);
const memfsDirtyRef = useRef(false);
const memfsWatcherRef = useRef<ReturnType<
typeof import("node:fs").watch
> | null>(null);
const memfsConflictCheckInFlightRef = useRef(false);
const memfsGitCheckInFlightRef = useRef(false);
const pendingGitReminderRef = useRef<{
dirty: boolean;
aheadOfRemote: boolean;
summary: string;
} | null>(null);
const [feedbackPrefill, setFeedbackPrefill] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [modelSelectorOptions, setModelSelectorOptions] = useState<{
@@ -2606,156 +2592,32 @@ export default function App({
[refreshDerived],
);
const runMemoryFilesystemSync = useCallback(
async (source: "startup" | "auto" | "command", commandId?: string) => {
if (!agentId || agentId === "loading") {
return;
}
if (memorySyncInFlightRef.current) {
// If called from a command while another sync is in flight, update the UI
if (source === "command" && commandId) {
updateMemorySyncCommand(
commandId,
"Sync already in progress — try again in a moment",
false,
);
}
return;
}
memorySyncInFlightRef.current = true;
try {
await ensureMemoryFilesystemBlock(agentId);
const result = await syncMemoryFilesystem(agentId);
if (result.conflicts.length > 0) {
if (source === "command") {
// User explicitly ran /memfs sync — show the interactive overlay
memorySyncCommandIdRef.current = commandId ?? null;
setMemorySyncConflicts(result.conflicts);
setActiveOverlay("memfs-sync");
if (commandId) {
updateMemorySyncCommand(
commandId,
`Memory sync paused — resolve ${result.conflicts.length} conflict${
result.conflicts.length === 1 ? "" : "s"
} to continue.`,
false,
"/memfs sync",
true, // keepRunning - don't commit until conflicts resolved
);
}
} else {
// Auto or startup sync — queue conflicts for agent-driven resolution
debugLog(
"memfs",
`${source} sync found ${result.conflicts.length} conflict(s), queuing for agent`,
);
pendingMemfsConflictsRef.current = result.conflicts;
}
return;
}
await updateMemoryFilesystemBlock(agentId);
if (commandId) {
updateMemorySyncCommand(
commandId,
formatMemorySyncSummary(result),
true,
);
}
} catch (error) {
const errorText = formatErrorDetails(error, agentId);
if (commandId) {
updateMemorySyncCommand(commandId, `Failed: ${errorText}`, false);
} else if (source !== "startup") {
appendError(`Memory sync failed: ${errorText}`);
} else {
console.error(`Memory sync failed: ${errorText}`);
}
} finally {
memorySyncInFlightRef.current = false;
}
},
[agentId, appendError, updateMemorySyncCommand],
);
const maybeSyncMemoryFilesystemAfterTurn = useCallback(async () => {
// Only auto-sync if memfs is enabled for this agent
const maybeCheckMemoryGitStatus = useCallback(async () => {
// Only check if memfs is enabled for this agent
if (!agentId || agentId === "loading") return;
if (!settingsManager.isMemfsEnabled(agentId)) return;
// Check for memory tool calls that need syncing (legacy path — memory tools
// are detached when memfs is enabled, but kept for backwards compatibility)
const newToolCallIds: string[] = [];
for (const line of buffersRef.current.byId.values()) {
if (line.kind !== "tool_call") continue;
if (!line.toolCallId || !line.name) continue;
if (!isMemoryTool(line.name)) continue;
if (memorySyncProcessedToolCallsRef.current.has(line.toolCallId))
continue;
newToolCallIds.push(line.toolCallId);
}
if (newToolCallIds.length > 0) {
for (const id of newToolCallIds) {
memorySyncProcessedToolCallsRef.current.add(id);
}
await runMemoryFilesystemSync("auto");
}
// Agent-driven conflict detection (fire-and-forget, non-blocking).
// Check when: (a) fs.watch detected a file change, or (b) every N turns
// to catch block-only changes (e.g. user manually editing blocks via the API).
const isDirty = memfsDirtyRef.current;
// Git-backed memory: check status periodically (fire-and-forget).
// Runs every N turns to detect uncommitted changes or unpushed commits.
const isIntervalTurn =
turnCountRef.current > 0 &&
turnCountRef.current % MEMFS_CONFLICT_CHECK_INTERVAL === 0;
if ((isDirty || isIntervalTurn) && !memfsConflictCheckInFlightRef.current) {
memfsDirtyRef.current = false;
memfsConflictCheckInFlightRef.current = true;
if (isIntervalTurn && !memfsGitCheckInFlightRef.current) {
memfsGitCheckInFlightRef.current = true;
// Fire-and-forget — don't await, don't block the turn
debugLog(
"memfs",
`Conflict check triggered (dirty=${isDirty}, interval=${isIntervalTurn}, turn=${turnCountRef.current})`,
);
checkMemoryFilesystemStatus(agentId)
.then(async (status) => {
if (status.conflicts.length > 0) {
debugLog(
"memfs",
`Found ${status.conflicts.length} conflict(s): ${status.conflicts.map((c) => c.label).join(", ")}`,
);
pendingMemfsConflictsRef.current = status.conflicts;
} else if (
status.newFiles.length > 0 ||
status.pendingFromFile.length > 0 ||
status.locationMismatches.length > 0
) {
// New files, file changes, or location mismatches detected - auto-sync
debugLog(
"memfs",
`Auto-syncing: ${status.newFiles.length} new, ${status.pendingFromFile.length} changed, ${status.locationMismatches.length} location mismatches`,
);
pendingMemfsConflictsRef.current = null;
await runMemoryFilesystemSync("auto");
} else {
pendingMemfsConflictsRef.current = null;
}
})
.catch((err) => {
debugWarn("memfs", "Conflict check failed", err);
import("../agent/memoryGit")
.then(({ getMemoryGitStatus }) => getMemoryGitStatus(agentId))
.then((status) => {
pendingGitReminderRef.current =
status.dirty || status.aheadOfRemote ? status : null;
})
.catch(() => {})
.finally(() => {
memfsConflictCheckInFlightRef.current = false;
memfsGitCheckInFlightRef.current = false;
});
}
}, [agentId, runMemoryFilesystemSync]);
}, [agentId]);
useEffect(() => {
if (loadingState !== "ready") {
@@ -2773,8 +2635,32 @@ export default function App({
}
memoryFilesystemInitializedRef.current = true;
runMemoryFilesystemSync("startup");
}, [agentId, loadingState, runMemoryFilesystemSync]);
// Git-backed memory: clone or pull on startup
(async () => {
try {
const { isGitRepo, cloneMemoryRepo, pullMemory } = await import(
"../agent/memoryGit"
);
if (!isGitRepo(agentId)) {
await cloneMemoryRepo(agentId);
} else {
await pullMemory(agentId);
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
debugWarn("memfs-git", `Startup sync failed: ${errMsg}`);
// Warn user visually
appendError(`Memory git sync failed: ${errMsg}`);
// Inject reminder so the agent also knows memory isn't synced
pendingGitReminderRef.current = {
dirty: false,
aheadOfRemote: false,
summary: `Git memory sync failed on startup: ${errMsg}\nMemory may be stale. Try running: git -C ~/.letta/agents/${agentId}/memory pull`,
};
}
})();
}, [agentId, loadingState, appendError]);
// Set up fs.watch on the memory directory to detect external file edits.
// When a change is detected, set a dirty flag — the actual conflict check
@@ -2793,7 +2679,8 @@ export default function App({
if (!existsSync(memRoot)) return;
watcher = watch(memRoot, { recursive: true }, () => {
memfsDirtyRef.current = true;
// Git-backed memory: no auto-sync on file changes.
// Agent handles commit/push. Status checked on interval.
});
memfsWatcherRef.current = watcher;
debugLog("memfs", `Watching memory directory: ${memRoot}`);
@@ -2824,113 +2711,8 @@ export default function App({
};
}, [agentId]);
const handleMemorySyncConflictSubmit = useCallback(
async (answers: Record<string, string>) => {
if (!agentId || agentId === "loading" || !memorySyncConflicts) {
return;
}
const commandId = memorySyncCommandIdRef.current;
const commandInput = memorySyncCommandInputRef.current;
memorySyncCommandIdRef.current = null;
memorySyncCommandInputRef.current = "/memfs sync";
const resolutions: MemorySyncResolution[] = memorySyncConflicts.map(
(conflict) => {
const answer = answers[`Conflict for ${conflict.label}`];
return {
label: conflict.label,
resolution: answer === "Use file version" ? "file" : "block",
};
},
);
setMemorySyncConflicts(null);
setActiveOverlay(null);
if (memorySyncInFlightRef.current) {
return;
}
memorySyncInFlightRef.current = true;
try {
const result = await syncMemoryFilesystem(agentId, {
resolutions,
});
if (result.conflicts.length > 0) {
setMemorySyncConflicts(result.conflicts);
setActiveOverlay("memfs-sync");
if (commandId) {
updateMemorySyncCommand(
commandId,
`Memory sync paused — resolve ${result.conflicts.length} conflict${
result.conflicts.length === 1 ? "" : "s"
} to continue.`,
false,
commandInput,
true, // keepRunning - don't commit until all conflicts resolved
);
}
return;
}
await updateMemoryFilesystemBlock(agentId);
// Format resolution summary (align with formatMemorySyncSummary which uses "⎿ " prefix)
const resolutionSummary = resolutions
.map(
(r) =>
`${r.label}: used ${r.resolution === "file" ? "file" : "block"} version`,
)
.join("\n");
if (commandId) {
updateMemorySyncCommand(
commandId,
`${formatMemorySyncSummary(result)}\nConflicts resolved:\n${resolutionSummary}`,
true,
commandInput,
);
}
} catch (error) {
const errorText = formatErrorDetails(error, agentId);
if (commandId) {
updateMemorySyncCommand(
commandId,
`Failed: ${errorText}`,
false,
commandInput,
);
} else {
appendError(`Memory sync failed: ${errorText}`);
}
} finally {
memorySyncInFlightRef.current = false;
}
},
[agentId, appendError, memorySyncConflicts, updateMemorySyncCommand],
);
const handleMemorySyncConflictCancel = useCallback(() => {
const commandId = memorySyncCommandIdRef.current;
const commandInput = memorySyncCommandInputRef.current;
memorySyncCommandIdRef.current = null;
memorySyncCommandInputRef.current = "/memfs sync";
memorySyncInFlightRef.current = false;
setMemorySyncConflicts(null);
setActiveOverlay(null);
if (commandId) {
updateMemorySyncCommand(
commandId,
"Memory sync cancelled.",
false,
commandInput,
);
}
}, [updateMemorySyncCommand]);
// Note: Old memFS conflict resolution overlay (handleMemorySyncConflictSubmit/Cancel)
// removed. Git-backed memory uses standard git merge conflict resolution via the agent.
// Core streaming function - iterative loop that processes conversation turns
const processConversation = useCallback(
@@ -3709,7 +3491,7 @@ export default function App({
queueSnapshotRef.current = [];
}
await maybeSyncMemoryFilesystemAfterTurn();
await maybeCheckMemoryGitStatus();
// === RALPH WIGGUM CONTINUATION CHECK ===
// Check if ralph mode is active and should auto-continue
@@ -4702,7 +4484,7 @@ export default function App({
queueApprovalResults,
consumeQueuedMessages,
appendTaskNotificationEvents,
maybeSyncMemoryFilesystemAfterTurn,
maybeCheckMemoryGitStatus,
openTrajectorySegment,
syncTrajectoryTokenBase,
syncTrajectoryElapsedBase,
@@ -7118,33 +6900,20 @@ export default function App({
);
await updateAgentSystemPromptMemfs(agentId, true);
// 4. Run initial sync (creates files from blocks)
await ensureMemoryFilesystemBlock(agentId);
const result = await syncMemoryFilesystem(agentId);
if (result.conflicts.length > 0) {
// Handle conflicts - show overlay (keep running so it stays in liveItems)
memorySyncCommandIdRef.current = cmdId;
memorySyncCommandInputRef.current = msg;
setMemorySyncConflicts(result.conflicts);
setActiveOverlay("memfs-sync");
updateMemorySyncCommand(
cmdId,
`Memory filesystem enabled with ${result.conflicts.length} conflict${result.conflicts.length === 1 ? "" : "s"} to resolve.`,
false,
msg,
true, // keepRunning - don't commit until conflict resolved
);
} else {
await updateMemoryFilesystemBlock(agentId);
const memoryDir = getMemoryFilesystemRoot(agentId);
updateMemorySyncCommand(
cmdId,
`Memory filesystem enabled.\nPath: ${memoryDir}\n${formatMemorySyncSummary(result)}`,
true,
msg,
);
// 4. Add git-memory-enabled tag and clone repo
const { addGitMemoryTag, isGitRepo, cloneMemoryRepo } =
await import("../agent/memoryGit");
await addGitMemoryTag(agentId);
if (!isGitRepo(agentId)) {
await cloneMemoryRepo(agentId);
}
const memoryDir = getMemoryFilesystemRoot(agentId);
updateMemorySyncCommand(
cmdId,
`Memory filesystem enabled (git-backed).\nPath: ${memoryDir}`,
true,
msg,
);
} catch (error) {
const errorText =
error instanceof Error ? error.message : String(error);
@@ -7172,7 +6941,7 @@ export default function App({
updateMemorySyncCommand(
cmdId,
"Syncing memory filesystem...",
"Pulling latest memory from server...",
true,
msg,
true,
@@ -7181,10 +6950,10 @@ export default function App({
setCommandRunning(true);
try {
await runMemoryFilesystemSync("command", cmdId);
const { pullMemory } = await import("../agent/memoryGit");
const result = await pullMemory(agentId);
updateMemorySyncCommand(cmdId, result.summary, true, msg);
} 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);
@@ -7258,41 +7027,18 @@ export default function App({
setCommandRunning(true);
try {
// 1. Run final sync to ensure blocks are up-to-date
const result = await syncMemoryFilesystem(agentId);
if (result.conflicts.length > 0) {
// Handle conflicts - show overlay (keep running so it stays in liveItems)
memorySyncCommandIdRef.current = cmdId;
memorySyncCommandInputRef.current = msg;
setMemorySyncConflicts(result.conflicts);
setActiveOverlay("memfs-sync");
updateMemorySyncCommand(
cmdId,
`Cannot disable: resolve ${result.conflicts.length} conflict${result.conflicts.length === 1 ? "" : "s"} first.`,
false,
msg,
true, // keepRunning - don't commit until conflict resolved
);
return { submitted: true };
}
// 2. Re-attach memory tool
// 1. Re-attach memory tool
const { reattachMemoryTool } = await import("../tools/toolset");
// Use current model or default to Claude
const modelId = currentModelId || "anthropic/claude-sonnet-4";
await reattachMemoryTool(agentId, modelId);
// 3. Detach memory_filesystem block
await detachMemoryFilesystemBlock(agentId);
// 4. Update system prompt to remove memfs section
// 2. Update system prompt to remove memfs section
const { updateAgentSystemPromptMemfs } = await import(
"../agent/modify"
);
await updateAgentSystemPromptMemfs(agentId, false);
// 5. Update settings
// 3. Update settings
settingsManager.setMemfsEnabled(agentId, false);
updateMemorySyncCommand(
@@ -7766,43 +7512,26 @@ ${SYSTEM_REMINDER_CLOSE}
// Increment turn count for next iteration
turnCountRef.current += 1;
// Build memfs conflict reminder if conflicts were detected after the last turn
let memfsConflictReminder = "";
if (
pendingMemfsConflictsRef.current &&
pendingMemfsConflictsRef.current.length > 0
) {
const conflicts = pendingMemfsConflictsRef.current;
const conflictRows = conflicts
.map((c) => `| ${c.label} | Both file and block modified |`)
.join("\n");
memfsConflictReminder = `${SYSTEM_REMINDER_OPEN}
## Memory Filesystem: Sync Conflicts Detected
// Build git memory sync reminder if uncommitted changes or unpushed commits
let memoryGitReminder = "";
const gitStatus = pendingGitReminderRef.current;
if (gitStatus) {
memoryGitReminder = `${SYSTEM_REMINDER_OPEN}
MEMORY SYNC: Your memory directory has uncommitted changes or is ahead of the remote.
${conflicts.length} memory block${conflicts.length === 1 ? "" : "s"} ha${conflicts.length === 1 ? "s" : "ve"} conflicts (both the file and the in-memory block were modified since last sync):
${gitStatus.summary}
| Block | Status |
|-------|--------|
${conflictRows}
To see the full diff for each conflict, run:
To sync:
\`\`\`bash
letta memfs diff --agent $LETTA_AGENT_ID
cd ~/.letta/agents/${agentId}/memory
git add system/
git commit -m "<type>: <what changed>"
git push
\`\`\`
The diff will be written to a file for review. After reviewing, resolve all conflicts at once:
\`\`\`bash
letta memfs resolve --agent $LETTA_AGENT_ID --resolutions '<JSON array of {label, resolution}>'
\`\`\`
Resolution options: \`"file"\` (overwrite block with file) or \`"block"\` (overwrite file with block).
You MUST resolve all conflicts. They will not be synced automatically until resolved.
For more context, load the \`syncing-memory-filesystem\` skill.
${SYSTEM_REMINDER_CLOSE}
`;
// Clear after injecting so it doesn't repeat on subsequent turns
pendingMemfsConflictsRef.current = null;
// Clear after injecting so it doesn't repeat
pendingGitReminderRef.current = null;
}
// Build permission mode change alert if mode changed since last notification
@@ -7871,7 +7600,7 @@ ${SYSTEM_REMINDER_CLOSE}
pushReminder(bashCommandPrefix);
pushReminder(userPromptSubmitHookFeedback);
pushReminder(memoryReminderContent);
pushReminder(memfsConflictReminder);
pushReminder(memoryGitReminder);
const messageContent =
reminderParts.length > 0
? [...reminderParts, ...contentParts]
@@ -11196,29 +10925,8 @@ Plan file path: ${planFilePath}`;
/>
))}
{/* Memory Sync Conflict Resolver */}
{activeOverlay === "memfs-sync" && memorySyncConflicts && (
<InlineQuestionApproval
questions={memorySyncConflicts.map((conflict) => ({
header: "Memory sync",
question: `Conflict for ${conflict.label}`,
options: [
{
label: "Use file version",
description: "Overwrite memory block with file contents",
},
{
label: "Use block version",
description: "Overwrite file with memory block contents",
},
],
multiSelect: false,
allowOther: false, // Only file or block - no custom option
}))}
onSubmit={handleMemorySyncConflictSubmit}
onCancel={handleMemorySyncConflictCancel}
/>
)}
{/* Memory sync conflict overlay removed - git-backed memory
uses standard git merge conflicts resolved by the agent */}
{/* MCP Server Selector - conditionally mounted as overlay */}
{activeOverlay === "mcp" && (

View File

@@ -3,10 +3,7 @@ import { join, relative } from "node:path";
import { Box, useInput } from "ink";
import Link from "ink-link";
import { useMemo, useState } from "react";
import {
getMemoryFilesystemRoot,
MEMORY_FS_STATE_FILE,
} from "../../agent/memoryFilesystem";
import { getMemoryFilesystemRoot } from "../../agent/memoryFilesystem";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors";
import { Text } from "./Text";
@@ -51,9 +48,7 @@ function scanMemoryFilesystem(memoryRoot: string): TreeNode[] {
}
// Filter out hidden files and state file
const filtered = entries.filter(
(name) => !name.startsWith(".") && name !== MEMORY_FS_STATE_FILE,
);
const filtered = entries.filter((name) => !name.startsWith("."));
// Sort: directories first, "system" always first among dirs, then alphabetically
const sorted = filtered.sort((a, b) => {

View File

@@ -1,35 +1,14 @@
import { createHash, randomUUID } from "node:crypto";
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { cpSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs";
import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join, normalize, relative } from "node:path";
import { join } from "node:path";
import { parseArgs } from "node:util";
import { getClient } from "../../agent/client";
import { parseMdxFrontmatter } from "../../agent/memory";
import { READ_ONLY_BLOCK_LABELS } from "../../agent/memoryConstants";
import {
ensureMemoryFilesystemDirs,
syncMemoryFilesystem,
} from "../../agent/memoryFilesystem";
const MEMORY_FS_STATE_FILE = ".sync-state.json";
const MEMFS_MANAGED_LABELS = new Set(["memory_filesystem"]);
const READ_ONLY_LABELS = new Set(READ_ONLY_BLOCK_LABELS as readonly string[]);
type SyncState = {
blockHashes: Record<string, string>;
fileHashes: Record<string, string>;
blockIds: Record<string, string>;
lastSync: string | null;
};
getMemoryGitStatus,
getMemoryRepoDir,
isGitRepo,
pullMemory,
} from "../../agent/memoryGit";
function printUsage(): void {
console.log(
@@ -37,23 +16,21 @@ function printUsage(): void {
Usage:
letta memfs status [--agent <id>]
letta memfs diff [--agent <id>]
letta memfs resolve --resolutions '<JSON>' [--agent <id>]
letta memfs backup [--agent <id>]
letta memfs backups [--agent <id>]
letta memfs restore --from <backup> --force [--agent <id>]
letta memfs export --agent <id> --out <dir>
letta memfs pull [--agent <id>]
Notes:
- Requires agent id via --agent or LETTA_AGENT_ID.
- Output is JSON only.
- Memory is git-backed. Use git commands for commit/push.
Examples:
LETTA_AGENT_ID=agent-123 letta memfs status
letta memfs diff --agent agent-123
letta memfs resolve --agent agent-123 --resolutions '[{"label":"human/prefs","resolution":"file"}]'
letta memfs pull --agent agent-123
letta memfs backup --agent agent-123
letta memfs backups --agent agent-123
letta memfs restore --agent agent-123 --from memory-backup-20260131-204903 --force
letta memfs export --agent agent-123 --out /tmp/letta-memfs-agent-123
`.trim(),
);
@@ -63,46 +40,6 @@ function getAgentId(agentFromArgs?: string, agentIdFromArgs?: string): string {
return agentFromArgs || agentIdFromArgs || process.env.LETTA_AGENT_ID || "";
}
function hashContent(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
function hashFileBody(content: string): string {
const { body } = parseMdxFrontmatter(content);
return hashContent(body);
}
function loadSyncState(agentId: string): SyncState {
const root = getMemoryRoot(agentId);
const statePath = join(root, MEMORY_FS_STATE_FILE);
if (!existsSync(statePath)) {
return {
blockHashes: {},
fileHashes: {},
blockIds: {},
lastSync: null,
};
}
try {
const raw = readFileSync(statePath, "utf-8");
const parsed = JSON.parse(raw);
return {
blockHashes: parsed.blockHashes || {},
fileHashes: parsed.fileHashes || {},
blockIds: parsed.blockIds || {},
lastSync: parsed.lastSync || null,
};
} catch {
return {
blockHashes: {},
fileHashes: {},
blockIds: {},
lastSync: null,
};
}
}
function getMemoryRoot(agentId: string): string {
return join(homedir(), ".letta", "agents", agentId, "memory");
}
@@ -153,448 +90,12 @@ async function listBackups(
}
function resolveBackupPath(agentId: string, from: string): string {
if (from.startsWith("/") || /^[A-Za-z]:[\\/]/.test(from)) {
if (from.startsWith("/") || /^[A-Za-z]:[/\\]/.test(from)) {
return from;
}
return join(getAgentRoot(agentId), from);
}
async function scanMdFiles(
dir: string,
baseDir = dir,
excludeDirs: string[] = [],
): Promise<string[]> {
if (!existsSync(dir)) return [];
const entries = await readdir(dir, { withFileTypes: true });
const results: string[] = [];
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
if (excludeDirs.includes(entry.name)) continue;
results.push(...(await scanMdFiles(fullPath, baseDir, excludeDirs)));
} else if (entry.isFile() && entry.name.endsWith(".md")) {
results.push(relative(baseDir, fullPath));
}
}
return results;
}
function labelFromPath(relativePath: string): string {
return relativePath.replace(/\\/g, "/").replace(/\.md$/, "");
}
async function readMemoryFiles(
dir: string,
excludeDirs: string[] = [],
): Promise<Map<string, { content: string }>> {
const files = await scanMdFiles(dir, dir, excludeDirs);
const entries = new Map<string, { content: string }>();
for (const rel of files) {
const label = labelFromPath(rel);
const content = await readFile(join(dir, rel), "utf-8");
entries.set(label, { content });
}
return entries;
}
function getOverflowDirectory(): string {
const cwd = process.cwd();
const normalizedPath = normalize(cwd);
const sanitizedPath = normalizedPath
.replace(/^[/\\]/, "")
.replace(/[/\\:]/g, "_")
.replace(/\s+/g, "_");
return join(homedir(), ".letta", "projects", sanitizedPath, "agent-tools");
}
type Conflict = {
label: string;
fileContent: string;
blockContent: string;
};
type MetadataChange = {
label: string;
fileContent: string;
blockContent: string;
};
async function computeStatus(agentId: string) {
const client = await getClient();
const root = getMemoryRoot(agentId);
const systemDir = join(root, "system");
const detachedDir = root;
for (const dir of [root, systemDir]) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
const systemFiles = await readMemoryFiles(systemDir);
const detachedFiles = await readMemoryFiles(detachedDir, ["system", "user"]);
const blocksResponse = await client.agents.blocks.list(agentId, {
limit: 1000,
});
const attachedBlocks = Array.isArray(blocksResponse)
? blocksResponse
: ((blocksResponse as { items?: unknown[] }).items as Array<{
id?: string;
label?: string;
value?: string;
read_only?: boolean;
}>) || [];
const systemBlockMap = new Map<
string,
{ value: string; id: string; read_only?: boolean }
>();
for (const block of attachedBlocks) {
if (block.label && block.id) {
systemBlockMap.set(block.label, {
value: block.value || "",
id: block.id,
read_only: block.read_only,
});
}
}
const ownedBlocksResponse = await client.blocks.list({
tags: [`owner:${agentId}`],
limit: 1000,
});
const ownedBlocks = Array.isArray(ownedBlocksResponse)
? ownedBlocksResponse
: ((ownedBlocksResponse as { items?: unknown[] }).items as Array<{
id?: string;
label?: string;
value?: string;
read_only?: boolean;
}>) || [];
const attachedIds = new Set(attachedBlocks.map((b) => b.id));
const detachedBlockMap = new Map<
string,
{ value: string; id: string; read_only?: boolean }
>();
for (const block of ownedBlocks) {
if (block.label && block.id && !attachedIds.has(block.id)) {
if (!systemBlockMap.has(block.label)) {
detachedBlockMap.set(block.label, {
value: block.value || "",
id: block.id,
read_only: block.read_only,
});
}
}
}
const lastState = loadSyncState(agentId);
const conflicts: Array<{ label: string }> = [];
const pendingFromFile: string[] = [];
const pendingFromBlock: string[] = [];
const newFiles: string[] = [];
const newBlocks: string[] = [];
const locationMismatches: string[] = [];
const allLabels = new Set<string>([
...systemFiles.keys(),
...detachedFiles.keys(),
...systemBlockMap.keys(),
...detachedBlockMap.keys(),
...Object.keys(lastState.blockHashes),
...Object.keys(lastState.fileHashes),
]);
for (const label of [...allLabels].sort()) {
if (MEMFS_MANAGED_LABELS.has(label)) continue;
const systemFile = systemFiles.get(label);
const detachedFile = detachedFiles.get(label);
const attachedBlock = systemBlockMap.get(label);
const detachedBlock = detachedBlockMap.get(label);
const fileEntry = systemFile || detachedFile;
const fileInSystem = !!systemFile;
const blockEntry = attachedBlock || detachedBlock;
const isAttached = !!attachedBlock;
const effectiveReadOnly =
!!blockEntry?.read_only || READ_ONLY_LABELS.has(label);
if (fileEntry && blockEntry) {
const locationMismatch =
(fileInSystem && !isAttached) || (!fileInSystem && isAttached);
if (locationMismatch) locationMismatches.push(label);
}
const fileHash = fileEntry ? hashContent(fileEntry.content) : null;
const fileBodyHash = fileEntry ? hashFileBody(fileEntry.content) : null;
const blockHash = blockEntry ? hashContent(blockEntry.value) : null;
const lastFileHash = lastState.fileHashes[label] ?? null;
const lastBlockHash = lastState.blockHashes[label] ?? null;
const fileChanged = fileHash !== lastFileHash;
const blockChanged = blockHash !== lastBlockHash;
if (fileEntry && !blockEntry) {
if (READ_ONLY_LABELS.has(label)) continue;
if (lastBlockHash && !fileChanged) continue;
newFiles.push(label);
continue;
}
if (!fileEntry && blockEntry) {
if (effectiveReadOnly) {
pendingFromFile.push(label);
continue;
}
if (lastFileHash && !blockChanged) continue;
newBlocks.push(label);
continue;
}
if (!fileEntry || !blockEntry) continue;
if (effectiveReadOnly) {
if (blockChanged) pendingFromBlock.push(label);
continue;
}
if (fileBodyHash === blockHash) {
if (fileChanged) pendingFromFile.push(label);
continue;
}
if (fileChanged) {
pendingFromFile.push(label);
continue;
}
if (blockChanged) {
pendingFromBlock.push(label);
}
}
const isClean =
conflicts.length === 0 &&
pendingFromFile.length === 0 &&
pendingFromBlock.length === 0 &&
newFiles.length === 0 &&
newBlocks.length === 0 &&
locationMismatches.length === 0;
return {
conflicts,
pendingFromFile,
pendingFromBlock,
newFiles,
newBlocks,
locationMismatches,
isClean,
lastSync: lastState.lastSync,
};
}
async function computeDiff(agentId: string): Promise<{
conflicts: Conflict[];
metadataOnly: MetadataChange[];
}> {
const client = await getClient();
const root = getMemoryRoot(agentId);
const systemDir = join(root, "system");
const detachedDir = root;
for (const dir of [root, systemDir]) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
const systemFiles = await readMemoryFiles(systemDir);
const detachedFiles = await readMemoryFiles(detachedDir, ["system", "user"]);
const blocksResponse = await client.agents.blocks.list(agentId, {
limit: 1000,
});
const attachedBlocks = Array.isArray(blocksResponse)
? blocksResponse
: ((blocksResponse as { items?: unknown[] }).items as Array<{
id?: string;
label?: string;
value?: string;
read_only?: boolean;
}>) || [];
const systemBlockMap = new Map<
string,
{ value: string; id: string; read_only?: boolean }
>();
for (const block of attachedBlocks) {
if (block.label && block.id) {
systemBlockMap.set(block.label, {
value: block.value || "",
id: block.id,
read_only: block.read_only,
});
}
}
const ownedBlocksResponse = await client.blocks.list({
tags: [`owner:${agentId}`],
limit: 1000,
});
const ownedBlocks = Array.isArray(ownedBlocksResponse)
? ownedBlocksResponse
: ((ownedBlocksResponse as { items?: unknown[] }).items as Array<{
id?: string;
label?: string;
value?: string;
read_only?: boolean;
}>) || [];
const attachedIds = new Set(attachedBlocks.map((b) => b.id));
const detachedBlockMap = new Map<
string,
{ value: string; id: string; read_only?: boolean }
>();
for (const block of ownedBlocks) {
if (block.label && block.id && !attachedIds.has(block.id)) {
if (!systemBlockMap.has(block.label)) {
detachedBlockMap.set(block.label, {
value: block.value || "",
id: block.id,
read_only: block.read_only,
});
}
}
}
const lastState = loadSyncState(agentId);
const conflicts: Conflict[] = [];
const metadataOnly: MetadataChange[] = [];
const allLabels = new Set<string>([
...systemFiles.keys(),
...detachedFiles.keys(),
...systemBlockMap.keys(),
...detachedBlockMap.keys(),
...Object.keys(lastState.blockHashes),
...Object.keys(lastState.fileHashes),
]);
for (const label of [...allLabels].sort()) {
if (MEMFS_MANAGED_LABELS.has(label)) continue;
const systemFile = systemFiles.get(label);
const detachedFile = detachedFiles.get(label);
const attachedBlock = systemBlockMap.get(label);
const detachedBlock = detachedBlockMap.get(label);
const fileEntry = systemFile || detachedFile;
const blockEntry = attachedBlock || detachedBlock;
if (!fileEntry || !blockEntry) continue;
const effectiveReadOnly =
!!blockEntry.read_only || READ_ONLY_LABELS.has(label);
if (effectiveReadOnly) continue;
const fileHash = hashContent(fileEntry.content);
const fileBodyHash = hashFileBody(fileEntry.content);
const blockHash = hashContent(blockEntry.value);
const lastFileHash = lastState.fileHashes[label] ?? null;
const lastBlockHash = lastState.blockHashes[label] ?? null;
const fileChanged = fileHash !== lastFileHash;
const blockChanged = blockHash !== lastBlockHash;
if (fileBodyHash === blockHash) {
if (fileChanged) {
metadataOnly.push({
label,
fileContent: fileEntry.content,
blockContent: blockEntry.value,
});
}
continue;
}
if (fileChanged && blockChanged) {
conflicts.push({
label,
fileContent: fileEntry.content,
blockContent: blockEntry.value,
});
}
}
return { conflicts, metadataOnly };
}
function formatDiffFile(
conflicts: Conflict[],
metadataOnly: MetadataChange[],
agentId: string,
): string {
const lines: string[] = [
`# Memory Filesystem Diff`,
``,
`Agent: ${agentId}`,
`Generated: ${new Date().toISOString()}`,
`Conflicts: ${conflicts.length}`,
`Metadata-only changes: ${metadataOnly.length}`,
``,
`---`,
``,
];
for (const conflict of conflicts) {
lines.push(`## Conflict: ${conflict.label}`);
lines.push(``);
lines.push(`### File Version`);
lines.push(`\`\`\``);
lines.push(conflict.fileContent);
lines.push(`\`\`\``);
lines.push(``);
lines.push(`### Block Version`);
lines.push(`\`\`\``);
lines.push(conflict.blockContent);
lines.push(`\`\`\``);
lines.push(``);
lines.push(`---`);
lines.push(``);
}
if (metadataOnly.length > 0) {
lines.push(`## Metadata-only Changes`);
lines.push(``);
lines.push(
`Frontmatter changed while body content stayed the same (file wins).`,
);
lines.push(``);
for (const change of metadataOnly) {
lines.push(`### ${change.label}`);
lines.push(``);
lines.push(`#### File Version (with frontmatter)`);
lines.push(`\`\`\``);
lines.push(change.fileContent);
lines.push(`\`\`\``);
lines.push(``);
lines.push(`#### Block Version (body only)`);
lines.push(`\`\`\``);
lines.push(change.blockContent);
lines.push(`\`\`\``);
lines.push(``);
lines.push(`---`);
lines.push(``);
}
}
return lines.join("\n");
}
export async function runMemfsSubcommand(argv: string[]): Promise<number> {
let parsed: ReturnType<typeof parseArgs>;
try {
@@ -607,7 +108,6 @@ export async function runMemfsSubcommand(argv: string[]): Promise<number> {
from: { type: "string" },
force: { type: "boolean" },
out: { type: "string" },
resolutions: { type: "string" },
},
strict: true,
allowPositionals: true,
@@ -640,82 +140,49 @@ export async function runMemfsSubcommand(argv: string[]): Promise<number> {
try {
if (action === "status") {
ensureMemoryFilesystemDirs(agentId);
const status = await computeStatus(agentId);
if (!isGitRepo(agentId)) {
console.log(
JSON.stringify({ error: "Not a git repo", gitEnabled: false }),
);
return 1;
}
const status = await getMemoryGitStatus(agentId);
console.log(JSON.stringify(status, null, 2));
return status.isClean ? 0 : 2;
return status.dirty || status.aheadOfRemote ? 2 : 0;
}
if (action === "diff") {
ensureMemoryFilesystemDirs(agentId);
const { conflicts, metadataOnly } = await computeDiff(agentId);
if (conflicts.length === 0 && metadataOnly.length === 0) {
console.log(
JSON.stringify(
{ conflicts: [], metadataOnly: [], diffPath: null, clean: true },
null,
2,
),
);
return 0;
if (!isGitRepo(agentId)) {
console.error("Not a git repo. Enable git-backed memory first.");
return 1;
}
const diffContent = formatDiffFile(conflicts, metadataOnly, agentId);
const overflowDir = getOverflowDirectory();
if (!existsSync(overflowDir)) {
mkdirSync(overflowDir, { recursive: true });
const { execFile: execFileCb } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFile = promisify(execFileCb);
const dir = getMemoryRepoDir(agentId);
const { stdout } = await execFile("git", ["diff"], { cwd: dir });
if (stdout.trim()) {
console.log(stdout);
return 2;
}
const filename = `memfs-diff-${randomUUID()}.md`;
const diffPath = join(overflowDir, filename);
writeFileSync(diffPath, diffContent, "utf-8");
console.log(
JSON.stringify(
{ conflicts, metadataOnly, diffPath, clean: false },
null,
2,
),
);
return conflicts.length > 0 ? 2 : 0;
console.log("No changes.");
return 0;
}
if (action === "resolve") {
ensureMemoryFilesystemDirs(agentId);
const resolutionsRaw = parsed.values.resolutions as string | undefined;
if (!resolutionsRaw) {
console.error("Missing --resolutions JSON.");
if (action === "pull") {
if (!isGitRepo(agentId)) {
console.error("Not a git repo. Enable git-backed memory first.");
return 1;
}
let resolutions: Array<{ label: string; resolution: "file" | "block" }> =
[];
try {
const parsedResolutions = JSON.parse(resolutionsRaw);
if (!Array.isArray(parsedResolutions)) {
throw new Error("resolutions must be an array");
}
resolutions = parsedResolutions;
} catch (error) {
console.error(
`Invalid --resolutions JSON: ${error instanceof Error ? error.message : String(error)}`,
);
return 1;
}
const result = await syncMemoryFilesystem(agentId, {
resolutions,
});
const result = await pullMemory(agentId);
console.log(JSON.stringify(result, null, 2));
return result.conflicts.length > 0 ? 2 : 0;
return 0;
}
if (action === "backup") {
const root = getMemoryRoot(agentId);
if (!existsSync(root)) {
console.error(
`Memory directory not found for agent ${agentId}. Run memfs sync first.`,
);
console.error(`Memory directory not found for agent ${agentId}.`);
return 1;
}
const agentRoot = getAgentRoot(agentId);
@@ -771,9 +238,7 @@ export async function runMemfsSubcommand(argv: string[]): Promise<number> {
}
const root = getMemoryRoot(agentId);
if (!existsSync(root)) {
console.error(
`Memory directory not found for agent ${agentId}. Run memfs sync first.`,
);
console.error(`Memory directory not found for agent ${agentId}.`);
return 1;
}
if (existsSync(out)) {