feat: agent-driven memory filesystem sync conflict resolution (#724)

Co-authored-by: Letta <noreply@letta.com>
Co-authored-by: Kevin Lin <kl2806@columbia.edu>
This commit is contained in:
Charles Packer
2026-01-28 19:30:26 -08:00
committed by GitHub
parent af1f2df260
commit 654e492479
10 changed files with 1599 additions and 34 deletions

View File

@@ -45,6 +45,21 @@ export type MemorySyncConflict = {
fileValue: string | null;
};
export type MemfsSyncStatus = {
/** Blocks where both file and block changed since last sync */
conflicts: MemorySyncConflict[];
/** Labels where only the file changed (would auto-resolve to block) */
pendingFromFile: string[];
/** Labels where only the block changed (would auto-resolve to file) */
pendingFromBlock: string[];
/** Labels where a file exists but no block */
newFiles: string[];
/** Labels where a block exists but no file */
newBlocks: string[];
/** True when there are no conflicts or pending changes */
isClean: boolean;
};
export type MemorySyncResult = {
updatedBlocks: string[];
createdBlocks: string[];
@@ -838,6 +853,181 @@ export function formatMemorySyncSummary(result: MemorySyncResult): string {
return `Memory filesystem sync complete:\n${lines.join("\n")}`;
}
/**
* Read-only check of the current memFS sync status.
* Does NOT modify any blocks, files, or sync state.
* Safe to call frequently (e.g., after every turn).
*/
export async function checkMemoryFilesystemStatus(
agentId: string,
options?: { homeDir?: string },
): Promise<MemfsSyncStatus> {
const homeDir = options?.homeDir ?? homedir();
ensureMemoryFilesystemDirs(agentId, homeDir);
const systemDir = getMemorySystemDir(agentId, homeDir);
const userDir = getMemoryUserDir(agentId, homeDir);
const systemFiles = await readMemoryFiles(systemDir);
const userFiles = await readMemoryFiles(userDir);
systemFiles.delete(MEMORY_FILESYSTEM_BLOCK_LABEL);
const attachedBlocks = await fetchAgentBlocks(agentId);
const systemBlockMap = new Map(
attachedBlocks
.filter((block) => block.label)
.map((block) => [block.label as string, block]),
);
systemBlockMap.delete(MEMORY_FILESYSTEM_BLOCK_LABEL);
const lastState = loadSyncState(agentId, homeDir);
const conflicts: MemorySyncConflict[] = [];
const pendingFromFile: string[] = [];
const pendingFromBlock: string[] = [];
const newFiles: string[] = [];
const newBlocks: string[] = [];
// Fetch user blocks for status check
const userBlockIds = { ...lastState.userBlockIds };
const userBlockMap = new Map<string, Block>();
const client = await getClient();
for (const [label, blockId] of Object.entries(userBlockIds)) {
try {
const block = await client.blocks.retrieve(blockId);
userBlockMap.set(label, block as Block);
} catch {
delete userBlockIds[label];
}
}
// Check system labels
const systemLabels = new Set<string>([
...Array.from(systemFiles.keys()),
...Array.from(systemBlockMap.keys()),
...Object.keys(lastState.systemBlocks),
...Object.keys(lastState.systemFiles),
]);
for (const label of Array.from(systemLabels).sort()) {
if (MANAGED_BLOCK_LABELS.has(label)) continue;
classifyLabel(
label,
systemFiles.get(label)?.content ?? null,
systemBlockMap.get(label)?.value ?? null,
lastState.systemFiles[label] ?? null,
lastState.systemBlocks[label] ?? null,
conflicts,
pendingFromFile,
pendingFromBlock,
newFiles,
newBlocks,
);
}
// Check user labels
const userLabels = new Set<string>([
...Array.from(userFiles.keys()),
...Array.from(userBlockMap.keys()),
...Object.keys(lastState.userBlocks),
...Object.keys(lastState.userFiles),
]);
for (const label of Array.from(userLabels).sort()) {
classifyLabel(
label,
userFiles.get(label)?.content ?? null,
userBlockMap.get(label)?.value ?? null,
lastState.userFiles[label] ?? null,
lastState.userBlocks[label] ?? null,
conflicts,
pendingFromFile,
pendingFromBlock,
newFiles,
newBlocks,
);
}
const isClean =
conflicts.length === 0 &&
pendingFromFile.length === 0 &&
pendingFromBlock.length === 0 &&
newFiles.length === 0 &&
newBlocks.length === 0;
return {
conflicts,
pendingFromFile,
pendingFromBlock,
newFiles,
newBlocks,
isClean,
};
}
/**
* Classify a single label's sync status (read-only).
* Pushes into the appropriate output array based on file/block state comparison.
*/
function classifyLabel(
label: string,
fileContent: string | null,
blockValue: string | null,
lastFileHash: string | null,
lastBlockHash: string | null,
conflicts: MemorySyncConflict[],
pendingFromFile: string[],
pendingFromBlock: string[],
newFiles: string[],
newBlocks: string[],
): void {
const fileHash = fileContent !== null ? hashContent(fileContent) : null;
const blockHash = blockValue !== null ? hashContent(blockValue) : null;
const fileChanged = fileHash !== lastFileHash;
const blockChanged = blockHash !== lastBlockHash;
if (fileContent !== null && blockValue === null) {
if (lastBlockHash && !fileChanged) {
// Block was deleted, file unchanged — would delete file
return;
}
newFiles.push(label);
return;
}
if (fileContent === null && blockValue !== null) {
if (lastFileHash && !blockChanged) {
// File was deleted, block unchanged — would delete block
return;
}
newBlocks.push(label);
return;
}
if (fileContent === null || blockValue === null) {
return;
}
// Both exist — check for differences
if (fileHash === blockHash) {
return; // In sync
}
if (fileChanged && blockChanged) {
conflicts.push({ label, blockValue, fileValue: fileContent });
return;
}
if (fileChanged && !blockChanged) {
pendingFromFile.push(label);
return;
}
if (!fileChanged && blockChanged) {
pendingFromBlock.push(label);
}
}
/**
* Detach the memory_filesystem block from an agent.
* Used when disabling memfs.

View File

@@ -24,8 +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 `/memory-sync` to sync on demand
- **Conflicts**: If both file and block changed, you'll be prompted to choose which version to keep
- **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
### How It Works
1. Each `.md` file path maps to a block label (e.g., `system/persona/git_safety.md` → label `persona/git_safety`)

View File

@@ -42,6 +42,7 @@ import { type AgentProvenance, createAgent } from "../agent/create";
import { getLettaCodeHeaders } from "../agent/http-headers";
import { ISOLATED_BLOCK_LABELS } from "../agent/memory";
import {
checkMemoryFilesystemStatus,
detachMemoryFilesystemBlock,
ensureMemoryFilesystemBlock,
formatMemorySyncSummary,
@@ -57,6 +58,7 @@ import { INTERRUPT_RECOVERY_ALERT } from "../agent/promptAssets";
import { SessionStats } from "../agent/stats";
import {
INTERRUPTED_BY_USER,
MEMFS_CONFLICT_CHECK_INTERVAL,
SYSTEM_REMINDER_CLOSE,
SYSTEM_REMINDER_OPEN,
} from "../constants";
@@ -1018,7 +1020,7 @@ export default function App({
| "subagent"
| "feedback"
| "memory"
| "memory-sync"
| "memfs-sync"
| "pin"
| "new"
| "mcp"
@@ -1033,9 +1035,15 @@ export default function App({
>(null);
const memorySyncProcessedToolCallsRef = useRef<Set<string>>(new Set());
const memorySyncCommandIdRef = useRef<string | null>(null);
const memorySyncCommandInputRef = useRef<string>("/memory-sync");
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 [feedbackPrefill, setFeedbackPrefill] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [modelSelectorOptions, setModelSelectorOptions] = useState<{
@@ -1922,7 +1930,7 @@ export default function App({
commandId: string,
output: string,
success: boolean,
input = "/memory-sync",
input = "/memfs-sync",
keepRunning = false, // If true, keep phase as "running" (for conflict dialogs)
) => {
buffersRef.current.byId.set(commandId, {
@@ -1954,20 +1962,30 @@ export default function App({
const result = await syncMemoryFilesystem(agentId);
if (result.conflicts.length > 0) {
memorySyncCommandIdRef.current = commandId ?? null;
setMemorySyncConflicts(result.conflicts);
setActiveOverlay("memory-sync");
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,
"/memory-sync",
true, // keepRunning - don't commit until conflicts resolved
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;
}
@@ -2002,6 +2020,8 @@ export default function App({
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;
@@ -2013,14 +2033,49 @@ export default function App({
newToolCallIds.push(line.toolCallId);
}
if (newToolCallIds.length === 0) {
return;
if (newToolCallIds.length > 0) {
for (const id of newToolCallIds) {
memorySyncProcessedToolCallsRef.current.add(id);
}
await runMemoryFilesystemSync("auto");
}
for (const id of newToolCallIds) {
memorySyncProcessedToolCallsRef.current.add(id);
// 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;
const isIntervalTurn =
turnCountRef.current > 0 &&
turnCountRef.current % MEMFS_CONFLICT_CHECK_INTERVAL === 0;
if ((isDirty || isIntervalTurn) && !memfsConflictCheckInFlightRef.current) {
memfsDirtyRef.current = false;
memfsConflictCheckInFlightRef.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((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 {
pendingMemfsConflictsRef.current = null;
}
})
.catch((err) => {
debugWarn("memfs", "Conflict check failed", err);
})
.finally(() => {
memfsConflictCheckInFlightRef.current = false;
});
}
await runMemoryFilesystemSync("auto");
}, [agentId, runMemoryFilesystemSync]);
useEffect(() => {
@@ -2042,6 +2097,54 @@ export default function App({
runMemoryFilesystemSync("startup");
}, [agentId, loadingState, runMemoryFilesystemSync]);
// 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
// runs on the next turn (debounced, non-blocking).
useEffect(() => {
if (!agentId || agentId === "loading") return;
if (!settingsManager.isMemfsEnabled(agentId)) return;
let watcher: ReturnType<typeof import("node:fs").watch> | null = null;
(async () => {
try {
const { watch } = await import("node:fs");
const { existsSync } = await import("node:fs");
const memRoot = getMemoryFilesystemRoot(agentId);
if (!existsSync(memRoot)) return;
watcher = watch(memRoot, { recursive: true }, () => {
memfsDirtyRef.current = true;
});
memfsWatcherRef.current = watcher;
debugLog("memfs", `Watching memory directory: ${memRoot}`);
watcher.on("error", (err) => {
debugWarn(
"memfs",
"fs.watch error (falling back to interval check)",
err,
);
});
} catch (err) {
debugWarn(
"memfs",
"Failed to set up fs.watch (falling back to interval check)",
err,
);
}
})();
return () => {
if (watcher) {
watcher.close();
}
if (memfsWatcherRef.current) {
memfsWatcherRef.current = null;
}
};
}, [agentId]);
const handleMemorySyncConflictSubmit = useCallback(
async (answers: Record<string, string>) => {
if (!agentId || agentId === "loading" || !memorySyncConflicts) {
@@ -2051,7 +2154,7 @@ export default function App({
const commandId = memorySyncCommandIdRef.current;
const commandInput = memorySyncCommandInputRef.current;
memorySyncCommandIdRef.current = null;
memorySyncCommandInputRef.current = "/memory-sync";
memorySyncCommandInputRef.current = "/memfs-sync";
const resolutions: MemorySyncResolution[] = memorySyncConflicts.map(
(conflict) => {
@@ -2079,7 +2182,7 @@ export default function App({
if (result.conflicts.length > 0) {
setMemorySyncConflicts(result.conflicts);
setActiveOverlay("memory-sync");
setActiveOverlay("memfs-sync");
if (commandId) {
updateMemorySyncCommand(
commandId,
@@ -2135,7 +2238,7 @@ export default function App({
const commandId = memorySyncCommandIdRef.current;
const commandInput = memorySyncCommandInputRef.current;
memorySyncCommandIdRef.current = null;
memorySyncCommandInputRef.current = "/memory-sync";
memorySyncCommandInputRef.current = "/memfs-sync";
memorySyncInFlightRef.current = false;
setMemorySyncConflicts(null);
setActiveOverlay(null);
@@ -6102,8 +6205,8 @@ export default function App({
return { submitted: true };
}
// Special handling for /memory-sync command - sync filesystem memory
if (trimmed === "/memory-sync") {
// 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");
@@ -6207,7 +6310,7 @@ export default function App({
memorySyncCommandIdRef.current = cmdId;
memorySyncCommandInputRef.current = msg;
setMemorySyncConflicts(result.conflicts);
setActiveOverlay("memory-sync");
setActiveOverlay("memfs-sync");
updateMemorySyncCommand(
cmdId,
`Memory filesystem enabled with ${result.conflicts.length} conflict${result.conflicts.length === 1 ? "" : "s"} to resolve.`,
@@ -6262,7 +6365,7 @@ export default function App({
memorySyncCommandIdRef.current = cmdId;
memorySyncCommandInputRef.current = msg;
setMemorySyncConflicts(result.conflicts);
setActiveOverlay("memory-sync");
setActiveOverlay("memfs-sync");
updateMemorySyncCommand(
cmdId,
`Cannot disable: resolve ${result.conflicts.length} conflict${result.conflicts.length === 1 ? "" : "s"} first.`,
@@ -6832,6 +6935,45 @@ ${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
${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):
| Block | Status |
|-------|--------|
${conflictRows}
To see the full diff for each conflict, run:
\`\`\`bash
npx tsx <SKILL_DIR>/scripts/memfs-diff.ts $LETTA_AGENT_ID
\`\`\`
The diff will be written to a file for review. After reviewing, resolve all conflicts at once:
\`\`\`bash
npx tsx <SKILL_DIR>/scripts/memfs-resolve.ts $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;
}
// Build permission mode change alert if mode changed since last notification
let permissionModeAlert = "";
const currentMode = permissionMode.getMode();
@@ -6846,7 +6988,7 @@ ${SYSTEM_REMINDER_CLOSE}
lastNotifiedModeRef.current = currentMode;
}
// Combine reminders with content (session context first, then permission mode, then plan mode, then ralph mode, then skill unload, then bash commands, then hook feedback, then memory reminder)
// Combine reminders with content (session context first, then permission mode, then plan mode, then ralph mode, then skill unload, then bash commands, then hook feedback, then memory reminder, then memfs conflicts)
const allReminders =
sessionContextReminder +
permissionModeAlert +
@@ -6855,7 +6997,8 @@ ${SYSTEM_REMINDER_CLOSE}
skillUnloadReminder +
bashCommandPrefix +
userPromptSubmitHookFeedback +
memoryReminderContent;
memoryReminderContent +
memfsConflictReminder;
const messageContent =
allReminders && typeof contentParts === "string"
? allReminders + contentParts
@@ -10174,7 +10317,7 @@ Plan file path: ${planFilePath}`;
)}
{/* Memory Sync Conflict Resolver */}
{activeOverlay === "memory-sync" && memorySyncConflicts && (
{activeOverlay === "memfs-sync" && memorySyncConflicts && (
<InlineQuestionApproval
questions={memorySyncConflicts.map((conflict) => ({
header: "Memory sync",

View File

@@ -60,8 +60,8 @@ export const commands: Record<string, Command> = {
return "Opening memory viewer...";
},
},
"/memory-sync": {
desc: "Sync memory blocks with filesystem (requires memfs enabled)",
"/memfs-sync": {
desc: "Sync memory blocks with filesystem (requires memFS enabled)",
order: 15.5,
handler: () => {
// Handled specially in App.tsx to run filesystem sync

View File

@@ -24,6 +24,12 @@ export const SYSTEM_REMINDER_TAG = "system-reminder";
export const SYSTEM_REMINDER_OPEN = `<${SYSTEM_REMINDER_TAG}>`;
export const SYSTEM_REMINDER_CLOSE = `</${SYSTEM_REMINDER_TAG}>`;
/**
* How often (in turns) to check for memfs sync conflicts, even without
* filesystem change events. Catches block-only changes (e.g. ADE/API edits).
*/
export const MEMFS_CONFLICT_CHECK_INTERVAL = 5;
/**
* Header displayed before compaction summary when conversation context is truncated
*/

View File

@@ -86,6 +86,9 @@ const BUNDLED_READ_ONLY_SCRIPTS = [
// Source path (development): /path/to/src/skills/builtin/searching-messages/scripts/...
"/skills/builtin/searching-messages/scripts/search-messages.ts",
"/skills/builtin/searching-messages/scripts/get-messages.ts",
// Memfs status check is read-only
"/skills/syncing-memory-filesystem/scripts/memfs-status.ts",
"/skills/builtin/syncing-memory-filesystem/scripts/memfs-status.ts",
];
/**

View File

@@ -0,0 +1,100 @@
---
name: syncing-memory-filesystem
description: Manage memory filesystem sync conflicts with git-like commands. Load this skill when you receive a memFS conflict notification, need to check sync status, review diffs, or resolve conflicts between memory blocks and their filesystem counterparts.
---
# 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`.
**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.
## Scripts
Three scripts provide a git-like interface for managing sync status:
### 1. `memfs-status.ts` — Check sync status (like `git status`)
```bash
npx tsx <SKILL_DIR>/scripts/memfs-status.ts $LETTA_AGENT_ID
```
**Output**: JSON object with:
- `conflicts` — blocks where both file and block changed (need manual resolution)
- `pendingFromFile` — file changed, block didn't (resolved on next sync)
- `pendingFromBlock` — block changed, file didn't (resolved on next sync)
- `newFiles` — files without corresponding blocks
- `newBlocks` — blocks without corresponding files
- `isClean` — true if everything is in sync
- `lastSync` — timestamp of last sync
Read-only, safe to run anytime.
### 2. `memfs-diff.ts` — View conflict details (like `git diff`)
```bash
npx tsx <SKILL_DIR>/scripts/memfs-diff.ts $LETTA_AGENT_ID
```
**Output**: Writes a formatted markdown diff file showing both the file version and block version of each conflicting label. The path to the diff file is printed to stdout.
Use the `Read` tool to review the diff file content.
### 3. `memfs-resolve.ts` — Resolve conflicts (like `git merge`)
```bash
npx tsx <SKILL_DIR>/scripts/memfs-resolve.ts $LETTA_AGENT_ID --resolutions '<JSON>'
```
**Arguments**:
- `--resolutions` — JSON array of resolution objects
**Resolution format**:
```json
[
{"label": "persona/soul", "resolution": "block"},
{"label": "human/prefs", "resolution": "file"}
]
```
**Resolution options**:
- `"file"` — Overwrite the memory block with the file contents
- `"block"` — Overwrite the file with the memory block contents
All resolutions must be provided in a single call (stateless).
## Typical Workflow
1. You receive a system reminder about memFS conflicts
2. Run `memfs-diff.ts` to see the full content of both sides
3. Read the diff file to understand the changes
4. Decide for each conflict: keep the file version or the block version
5. Run `memfs-resolve.ts` with all resolutions at once
## Example
```bash
# Step 1: Check status (optional — the system reminder already tells you about conflicts)
npx tsx <SKILL_DIR>/scripts/memfs-status.ts $LETTA_AGENT_ID
# Step 2: View the diffs
npx tsx <SKILL_DIR>/scripts/memfs-diff.ts $LETTA_AGENT_ID
# Output: "Diff (2 conflicts) written to: /path/to/diff.md"
# Step 3: Read the diff file (use Read tool on the path from step 2)
# Step 4: Resolve all conflicts
npx tsx <SKILL_DIR>/scripts/memfs-resolve.ts $LETTA_AGENT_ID --resolutions '[{"label":"persona/soul","resolution":"block"},{"label":"human/prefs","resolution":"file"}]'
```
## How Conflicts Arise
- **User edits a `.md` file** in their editor or IDE while the corresponding block is also modified manually by the user via the API
- **Both sides diverge** from the last-synced state — neither can be resolved automatically without potentially losing changes
- The system detects this after each turn and notifies you via a system reminder
## 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
- After resolving, the sync state is updated so the same conflicts won't reappear

View File

@@ -0,0 +1,360 @@
#!/usr/bin/env npx tsx
/**
* Memory Filesystem Diff
*
* Shows the full content of conflicting blocks and files.
* Writes a formatted markdown diff to a file for review.
* Analogous to `git diff`.
*
* Usage:
* npx tsx memfs-diff.ts <agent-id>
*
* Output: Path to the diff file (or "No conflicts" message)
*/
import { createHash, randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { join, normalize, relative } from "node:path";
const require = createRequire(import.meta.url);
const Letta = require("@letta-ai/letta-client")
.default as typeof import("@letta-ai/letta-client").default;
function getApiKey(): string {
if (process.env.LETTA_API_KEY) {
return process.env.LETTA_API_KEY;
}
const settingsPath = join(homedir(), ".letta", "settings.json");
try {
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
if (settings.env?.LETTA_API_KEY) {
return settings.env.LETTA_API_KEY;
}
} catch {
// Settings file doesn't exist or is invalid
}
throw new Error(
"No LETTA_API_KEY found. Set the env var or run the Letta CLI to authenticate.",
);
}
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>;
lastSync: string | null;
};
function hashContent(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
function getMemoryRoot(agentId: string): string {
return join(homedir(), ".letta", "agents", agentId, "memory");
}
function loadSyncState(agentId: string): SyncState {
const statePath = join(getMemoryRoot(agentId), MEMORY_FS_STATE_FILE);
if (!existsSync(statePath)) {
return {
systemBlocks: {},
systemFiles: {},
userBlocks: {},
userFiles: {},
userBlockIds: {},
lastSync: null,
};
}
try {
const raw = readFileSync(statePath, "utf-8");
const parsed = JSON.parse(raw) as Partial<SyncState> & {
blocks?: Record<string, string>;
files?: Record<string, string>;
};
return {
systemBlocks: parsed.systemBlocks || parsed.blocks || {},
systemFiles: parsed.systemFiles || parsed.files || {},
userBlocks: parsed.userBlocks || {},
userFiles: parsed.userFiles || {},
userBlockIds: parsed.userBlockIds || {},
lastSync: parsed.lastSync || null,
};
} catch {
return {
systemBlocks: {},
systemFiles: {},
userBlocks: {},
userFiles: {},
userBlockIds: {},
lastSync: null,
};
}
}
async function scanMdFiles(dir: string, baseDir = dir): 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()) {
results.push(...(await scanMdFiles(fullPath, baseDir)));
} 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,
): Promise<Map<string, { content: string }>> {
const files = await scanMdFiles(dir);
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;
}
const MANAGED_LABELS = new Set([
"memory_filesystem",
"skills",
"loaded_skills",
]);
interface Conflict {
label: string;
fileContent: string;
blockContent: string;
}
/**
* Get the overflow directory following the same pattern as tool output overflow.
* Pattern: ~/.letta/projects/<project-path>/agent-tools/
*/
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");
}
async function findConflicts(agentId: string): Promise<Conflict[]> {
const baseUrl = process.env.LETTA_BASE_URL || "https://api.letta.com";
const client = new Letta({ apiKey: getApiKey(), baseUrl });
const root = getMemoryRoot(agentId);
const systemDir = join(root, "system");
const userDir = join(root, "user");
for (const dir of [root, systemDir, userDir]) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
const systemFiles = await readMemoryFiles(systemDir);
const userFiles = await readMemoryFiles(userDir);
systemFiles.delete("memory_filesystem");
const blocksResponse = await client.agents.blocks.list(agentId, {
limit: 1000,
});
const blocks = Array.isArray(blocksResponse)
? blocksResponse
: ((blocksResponse as { items?: unknown[] }).items as Array<{
label?: string;
value?: string;
}>) || [];
const systemBlockMap = new Map(
blocks
.filter((b: { label?: string }) => b.label)
.map((b: { label?: string; value?: string }) => [
b.label as string,
b.value || "",
]),
);
systemBlockMap.delete("memory_filesystem");
const lastState = loadSyncState(agentId);
const userBlockMap = new Map<string, string>();
for (const [label, blockId] of Object.entries(lastState.userBlockIds)) {
try {
const block = await client.blocks.retrieve(blockId);
userBlockMap.set(label, block.value || "");
} catch {
// Block no longer exists
}
}
const conflicts: Conflict[] = [];
function checkConflict(
label: string,
fileContent: string | null,
blockValue: string | null,
lastFileHash: string | null,
lastBlockHash: string | null,
) {
if (fileContent === null || blockValue === null) return;
const fileHash = hashContent(fileContent);
const blockHash = hashContent(blockValue);
if (fileHash === blockHash) return;
const fileChanged = fileHash !== lastFileHash;
const blockChanged = blockHash !== lastBlockHash;
if (fileChanged && blockChanged) {
conflicts.push({ label, fileContent, blockContent: blockValue });
}
}
// Check system labels
const systemLabels = new Set([
...systemFiles.keys(),
...systemBlockMap.keys(),
...Object.keys(lastState.systemBlocks),
...Object.keys(lastState.systemFiles),
]);
for (const label of [...systemLabels].sort()) {
if (MANAGED_LABELS.has(label)) continue;
checkConflict(
label,
systemFiles.get(label)?.content ?? null,
systemBlockMap.get(label) ?? null,
lastState.systemFiles[label] ?? null,
lastState.systemBlocks[label] ?? null,
);
}
// Check user labels
const userLabels = new Set([
...userFiles.keys(),
...userBlockMap.keys(),
...Object.keys(lastState.userBlocks),
...Object.keys(lastState.userFiles),
]);
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,
);
}
return conflicts;
}
function formatDiffFile(conflicts: Conflict[], agentId: string): string {
const lines: string[] = [
`# Memory Filesystem Diff`,
``,
`Agent: ${agentId}`,
`Generated: ${new Date().toISOString()}`,
`Conflicts: ${conflicts.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(``);
}
return lines.join("\n");
}
// CLI Entry Point
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
console.log(`
Usage: npx tsx memfs-diff.ts <agent-id>
Shows the full content of conflicting memory blocks and files.
Writes a formatted diff to a file for review.
Analogous to 'git diff'.
Arguments:
agent-id Agent ID to check (can use $LETTA_AGENT_ID)
Output: Path to the diff file, or a message if no conflicts exist.
`);
process.exit(0);
}
const agentId = args[0];
if (!agentId) {
console.error("Error: agent-id is required");
process.exit(1);
}
findConflicts(agentId)
.then((conflicts) => {
if (conflicts.length === 0) {
console.log("No conflicts found. Memory filesystem is clean.");
return;
}
const diffContent = formatDiffFile(conflicts, agentId);
// Write to overflow directory (same pattern as tool output overflow)
const overflowDir = getOverflowDirectory();
if (!existsSync(overflowDir)) {
mkdirSync(overflowDir, { recursive: true });
}
const filename = `memfs-diff-${randomUUID()}.md`;
const diffPath = join(overflowDir, filename);
writeFileSync(diffPath, diffContent, "utf-8");
console.log(
`Diff (${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}) written to: ${diffPath}`,
);
})
.catch((error) => {
console.error(
"Error generating memFS diff:",
error instanceof Error ? error.message : String(error),
);
process.exit(1);
});
}

View File

@@ -0,0 +1,408 @@
#!/usr/bin/env npx tsx
/**
* Memory Filesystem Conflict Resolver
*
* Resolves all memFS sync conflicts in a single stateless call.
* The agent provides all resolutions up front as JSON.
* Analogous to `git merge` / `git checkout --theirs/--ours`.
*
* Usage:
* npx tsx memfs-resolve.ts <agent-id> --resolutions '<JSON>'
*
* Example:
* npx tsx memfs-resolve.ts $LETTA_AGENT_ID --resolutions '[{"label":"persona/soul","resolution":"block"},{"label":"human/prefs","resolution":"file"}]'
*
* Resolution options per conflict:
* "file" — Overwrite the memory block with the file contents
* "block" — Overwrite the file with the memory block contents
*/
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { dirname, join, relative } from "node:path";
const require = createRequire(import.meta.url);
const Letta = require("@letta-ai/letta-client")
.default as typeof import("@letta-ai/letta-client").default;
function getApiKey(): string {
if (process.env.LETTA_API_KEY) {
return process.env.LETTA_API_KEY;
}
const settingsPath = join(homedir(), ".letta", "settings.json");
try {
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
if (settings.env?.LETTA_API_KEY) {
return settings.env.LETTA_API_KEY;
}
} catch {
// Settings file doesn't exist or is invalid
}
throw new Error(
"No LETTA_API_KEY found. Set the env var or run the Letta CLI to authenticate.",
);
}
interface Resolution {
label: string;
resolution: "file" | "block";
}
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>;
lastSync: string | null;
};
function hashContent(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
function getMemoryRoot(agentId: string): string {
return join(homedir(), ".letta", "agents", agentId, "memory");
}
function loadSyncState(agentId: string): SyncState {
const statePath = join(getMemoryRoot(agentId), MEMORY_FS_STATE_FILE);
if (!existsSync(statePath)) {
return {
systemBlocks: {},
systemFiles: {},
userBlocks: {},
userFiles: {},
userBlockIds: {},
lastSync: null,
};
}
try {
const raw = readFileSync(statePath, "utf-8");
const parsed = JSON.parse(raw) as Partial<SyncState> & {
blocks?: Record<string, string>;
files?: Record<string, string>;
};
return {
systemBlocks: parsed.systemBlocks || parsed.blocks || {},
systemFiles: parsed.systemFiles || parsed.files || {},
userBlocks: parsed.userBlocks || {},
userFiles: parsed.userFiles || {},
userBlockIds: parsed.userBlockIds || {},
lastSync: parsed.lastSync || null,
};
} catch {
return {
systemBlocks: {},
systemFiles: {},
userBlocks: {},
userFiles: {},
userBlockIds: {},
lastSync: null,
};
}
}
function saveSyncState(state: SyncState, agentId: string): void {
const statePath = join(getMemoryRoot(agentId), MEMORY_FS_STATE_FILE);
writeFileSync(statePath, JSON.stringify(state, null, 2), "utf-8");
}
async function scanMdFiles(dir: string, baseDir = dir): 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()) {
results.push(...(await scanMdFiles(fullPath, baseDir)));
} 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,
): Promise<Map<string, { content: string }>> {
const files = await scanMdFiles(dir);
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 writeMemoryFile(dir: string, label: string, content: string): void {
const filePath = join(dir, `${label}.md`);
const parent = dirname(filePath);
if (!existsSync(parent)) {
mkdirSync(parent, { recursive: true });
}
writeFileSync(filePath, content, "utf-8");
}
interface ResolveResult {
resolved: Array<{ label: string; resolution: string; action: string }>;
errors: Array<{ label: string; error: string }>;
}
async function resolveConflicts(
agentId: string,
resolutions: Resolution[],
): Promise<ResolveResult> {
const baseUrl = process.env.LETTA_BASE_URL || "https://api.letta.com";
const client = new Letta({ apiKey: getApiKey(), baseUrl });
const root = getMemoryRoot(agentId);
const systemDir = join(root, "system");
const userDir = join(root, "user");
for (const dir of [root, systemDir, userDir]) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
// Read current state
const systemFiles = await readMemoryFiles(systemDir);
const userFiles = await readMemoryFiles(userDir);
systemFiles.delete("memory_filesystem");
const blocksResponse = await client.agents.blocks.list(agentId, {
limit: 1000,
});
const blocks = Array.isArray(blocksResponse)
? blocksResponse
: ((blocksResponse as { items?: unknown[] }).items as Array<{
id?: string;
label?: string;
value?: string;
}>) || [];
const systemBlockMap = new Map(
blocks
.filter((b: { label?: string }) => b.label)
.map((b: { id?: string; label?: string; value?: string }) => [
b.label as string,
{ id: b.id || "", value: b.value || "" },
]),
);
const lastState = loadSyncState(agentId);
const userBlockMap = new Map<string, { id: string; value: string }>();
for (const [label, blockId] of Object.entries(lastState.userBlockIds)) {
try {
const block = await client.blocks.retrieve(blockId);
userBlockMap.set(label, { id: block.id || "", value: block.value || "" });
} catch {
// Block no longer exists
}
}
const result: ResolveResult = { resolved: [], errors: [] };
for (const { label, resolution } of resolutions) {
try {
// 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 block = systemBlock || userBlock;
const file = systemFile || userFile;
const dir =
systemBlock || systemFile
? systemDir
: userBlock || userFile
? userDir
: null;
if (!block || !file || !dir) {
result.errors.push({
label,
error: `Could not find both block and file for label "${label}"`,
});
continue;
}
if (resolution === "file") {
// Overwrite block with file content
await client.blocks.update(block.id, { value: file.content });
result.resolved.push({
label,
resolution: "file",
action: "Updated block with file content",
});
} else if (resolution === "block") {
// Overwrite file with block content
writeMemoryFile(dir, label, block.value);
result.resolved.push({
label,
resolution: "block",
action: "Updated file with block content",
});
}
} catch (error) {
result.errors.push({
label,
error: error instanceof Error ? error.message : String(error),
});
}
}
// 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);
updatedSystemFiles.delete("memory_filesystem");
const updatedBlocksResponse = await client.agents.blocks.list(agentId, {
limit: 1000,
});
const updatedBlocks = Array.isArray(updatedBlocksResponse)
? updatedBlocksResponse
: ((updatedBlocksResponse as { items?: unknown[] }).items as Array<{
label?: string;
value?: string;
}>) || [];
const systemBlockHashes: Record<string, string> = {};
const systemFileHashes: Record<string, string> = {};
const userBlockHashes: Record<string, string> = {};
const userFileHashes: Record<string, string> = {};
for (const block of updatedBlocks.filter(
(b: { label?: string }) => b.label && b.label !== "memory_filesystem",
)) {
systemBlockHashes[block.label as string] = hashContent(
(block as { value?: string }).value || "",
);
}
for (const [label, file] of updatedSystemFiles) {
systemFileHashes[label] = hashContent(file.content);
}
for (const [label, blockId] of Object.entries(lastState.userBlockIds)) {
try {
const block = await client.blocks.retrieve(blockId);
userBlockHashes[label] = hashContent(block.value || "");
} catch {
// Block gone
}
}
for (const [label, file] of updatedUserFiles) {
userFileHashes[label] = hashContent(file.content);
}
saveSyncState(
{
systemBlocks: systemBlockHashes,
systemFiles: systemFileHashes,
userBlocks: userBlockHashes,
userFiles: userFileHashes,
userBlockIds: lastState.userBlockIds,
lastSync: new Date().toISOString(),
},
agentId,
);
return result;
}
// CLI Entry Point
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
console.log(`
Usage: npx tsx memfs-resolve.ts <agent-id> --resolutions '<JSON>'
Resolves all memory filesystem sync conflicts in one call.
Analogous to 'git merge' with explicit resolution choices.
Arguments:
agent-id Agent ID (can use $LETTA_AGENT_ID)
--resolutions JSON array of resolutions
Resolution format:
[{"label": "persona/soul", "resolution": "block"}, {"label": "human/prefs", "resolution": "file"}]
Resolution options:
"file" — Overwrite the memory block with the file contents
"block" — Overwrite the file with the memory block contents
Example:
npx tsx memfs-resolve.ts $LETTA_AGENT_ID --resolutions '[{"label":"persona/soul","resolution":"block"}]'
`);
process.exit(0);
}
const agentId = args[0];
if (!agentId) {
console.error("Error: agent-id is required");
process.exit(1);
}
// Parse --resolutions flag
const resolutionsIdx = args.indexOf("--resolutions");
if (resolutionsIdx === -1 || resolutionsIdx + 1 >= args.length) {
console.error("Error: --resolutions '<JSON>' is required");
process.exit(1);
}
let resolutions: Resolution[];
try {
resolutions = JSON.parse(args[resolutionsIdx + 1]);
if (!Array.isArray(resolutions)) {
throw new Error("Resolutions must be a JSON array");
}
for (const r of resolutions) {
if (!r.label || !r.resolution) {
throw new Error(
`Each resolution must have "label" and "resolution" fields`,
);
}
if (r.resolution !== "file" && r.resolution !== "block") {
throw new Error(
`Resolution must be "file" or "block", got "${r.resolution}"`,
);
}
}
} catch (error) {
console.error(
"Error parsing resolutions:",
error instanceof Error ? error.message : String(error),
);
process.exit(1);
}
resolveConflicts(agentId, resolutions)
.then((result) => {
console.log(JSON.stringify(result, null, 2));
})
.catch((error) => {
console.error(
"Error resolving conflicts:",
error instanceof Error ? error.message : String(error),
);
process.exit(1);
});
}

View File

@@ -0,0 +1,353 @@
#!/usr/bin/env npx tsx
/**
* Memory Filesystem Status Check
*
* Read-only check of the current memFS sync status.
* Shows conflicts, pending changes, and overall sync health.
* Analogous to `git status`.
*
* Usage:
* npx tsx memfs-status.ts <agent-id>
*
* Output: JSON object with sync status
*/
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { join } from "node:path";
const require = createRequire(import.meta.url);
const Letta = require("@letta-ai/letta-client")
.default as typeof import("@letta-ai/letta-client").default;
function getApiKey(): string {
if (process.env.LETTA_API_KEY) {
return process.env.LETTA_API_KEY;
}
const settingsPath = join(homedir(), ".letta", "settings.json");
try {
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
if (settings.env?.LETTA_API_KEY) {
return settings.env.LETTA_API_KEY;
}
} catch {
// Settings file doesn't exist or is invalid
}
throw new Error(
"No LETTA_API_KEY found. Set the env var or run the Letta CLI to authenticate.",
);
}
// We can't import checkMemoryFilesystemStatus directly since it relies on
// getClient() which uses the CLI's auth chain. Instead, we reimplement the
// status check logic using the standalone client pattern.
// This keeps the script fully standalone and runnable outside the CLI process.
import { createHash } from "node:crypto";
import { existsSync, mkdirSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { relative } from "node:path";
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>;
lastSync: string | null;
};
function hashContent(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
function getMemoryRoot(agentId: string): string {
return join(homedir(), ".letta", "agents", agentId, "memory");
}
function loadSyncState(agentId: string): SyncState {
const statePath = join(getMemoryRoot(agentId), MEMORY_FS_STATE_FILE);
if (!existsSync(statePath)) {
return {
systemBlocks: {},
systemFiles: {},
userBlocks: {},
userFiles: {},
userBlockIds: {},
lastSync: null,
};
}
try {
const raw = readFileSync(statePath, "utf-8");
const parsed = JSON.parse(raw) as Partial<SyncState> & {
blocks?: Record<string, string>;
files?: Record<string, string>;
};
return {
systemBlocks: parsed.systemBlocks || parsed.blocks || {},
systemFiles: parsed.systemFiles || parsed.files || {},
userBlocks: parsed.userBlocks || {},
userFiles: parsed.userFiles || {},
userBlockIds: parsed.userBlockIds || {},
lastSync: parsed.lastSync || null,
};
} catch {
return {
systemBlocks: {},
systemFiles: {},
userBlocks: {},
userFiles: {},
userBlockIds: {},
lastSync: null,
};
}
}
async function scanMdFiles(dir: string, baseDir = dir): 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()) {
results.push(...(await scanMdFiles(fullPath, baseDir)));
} 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,
): Promise<Map<string, { content: string }>> {
const files = await scanMdFiles(dir);
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;
}
const MANAGED_LABELS = new Set([
"memory_filesystem",
"skills",
"loaded_skills",
]);
interface StatusResult {
conflicts: Array<{ label: string }>;
pendingFromFile: string[];
pendingFromBlock: string[];
newFiles: string[];
newBlocks: string[];
isClean: boolean;
lastSync: string | null;
}
async function checkStatus(agentId: string): Promise<StatusResult> {
const baseUrl = process.env.LETTA_BASE_URL || "https://api.letta.com";
const client = new Letta({ apiKey: getApiKey(), baseUrl });
const root = getMemoryRoot(agentId);
const systemDir = join(root, "system");
const userDir = join(root, "user");
// Ensure directories exist
for (const dir of [root, systemDir, userDir]) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
const systemFiles = await readMemoryFiles(systemDir);
const userFiles = await readMemoryFiles(userDir);
systemFiles.delete("memory_filesystem");
// Fetch attached blocks
const blocksResponse = await client.agents.blocks.list(agentId, {
limit: 1000,
});
const blocks = Array.isArray(blocksResponse)
? blocksResponse
: ((blocksResponse as { items?: unknown[] }).items as Array<{
label?: string;
value?: string;
}>) || [];
const systemBlockMap = new Map(
blocks
.filter((b: { label?: string }) => b.label)
.map((b: { label?: string; value?: string }) => [
b.label as string,
b.value || "",
]),
);
systemBlockMap.delete("memory_filesystem");
const lastState = loadSyncState(agentId);
const conflicts: Array<{ label: string }> = [];
const pendingFromFile: string[] = [];
const pendingFromBlock: string[] = [];
const newFiles: string[] = [];
const newBlocks: string[] = [];
// Fetch user blocks
const userBlockMap = new Map<string, string>();
for (const [label, blockId] of Object.entries(lastState.userBlockIds)) {
try {
const block = await client.blocks.retrieve(blockId);
userBlockMap.set(label, block.value || "");
} catch {
// Block no longer exists
}
}
function classify(
label: string,
fileContent: string | null,
blockValue: string | null,
lastFileHash: string | null,
lastBlockHash: string | null,
) {
const fileHash = fileContent !== null ? hashContent(fileContent) : null;
const blockHash = blockValue !== null ? hashContent(blockValue) : null;
const fileChanged = fileHash !== lastFileHash;
const blockChanged = blockHash !== lastBlockHash;
if (fileContent !== null && blockValue === null) {
if (lastBlockHash && !fileChanged) return;
newFiles.push(label);
return;
}
if (fileContent === null && blockValue !== null) {
if (lastFileHash && !blockChanged) return;
newBlocks.push(label);
return;
}
if (fileContent === null || blockValue === null) return;
if (fileHash === blockHash) return;
if (fileChanged && blockChanged) {
conflicts.push({ label });
return;
}
if (fileChanged && !blockChanged) {
pendingFromFile.push(label);
return;
}
if (!fileChanged && blockChanged) {
pendingFromBlock.push(label);
}
}
// Check system labels
const systemLabels = new Set([
...systemFiles.keys(),
...systemBlockMap.keys(),
...Object.keys(lastState.systemBlocks),
...Object.keys(lastState.systemFiles),
]);
for (const label of [...systemLabels].sort()) {
if (MANAGED_LABELS.has(label)) continue;
classify(
label,
systemFiles.get(label)?.content ?? null,
systemBlockMap.get(label) ?? null,
lastState.systemFiles[label] ?? null,
lastState.systemBlocks[label] ?? null,
);
}
// Check user labels
const userLabels = new Set([
...userFiles.keys(),
...userBlockMap.keys(),
...Object.keys(lastState.userBlocks),
...Object.keys(lastState.userFiles),
]);
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,
);
}
const isClean =
conflicts.length === 0 &&
pendingFromFile.length === 0 &&
pendingFromBlock.length === 0 &&
newFiles.length === 0 &&
newBlocks.length === 0;
return {
conflicts,
pendingFromFile,
pendingFromBlock,
newFiles,
newBlocks,
isClean,
lastSync: lastState.lastSync,
};
}
// CLI Entry Point
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
console.log(`
Usage: npx tsx memfs-status.ts <agent-id>
Shows the current memFS sync status (read-only).
Analogous to 'git status'.
Arguments:
agent-id Agent ID to check (can use $LETTA_AGENT_ID)
Output: JSON object with:
- conflicts: blocks where both file and block changed
- pendingFromFile: file changed, block didn't
- pendingFromBlock: block changed, file didn't
- newFiles: file exists without a block
- newBlocks: block exists without a file
- isClean: true if everything is in sync
- lastSync: timestamp of last sync
`);
process.exit(0);
}
const agentId = args[0];
if (!agentId) {
console.error("Error: agent-id is required");
process.exit(1);
}
checkStatus(agentId)
.then((status) => {
console.log(JSON.stringify(status, null, 2));
})
.catch((error) => {
console.error(
"Error checking memFS status:",
error instanceof Error ? error.message : String(error),
);
process.exit(1);
});
}