Files
letta-code/src/cli/subcommands/memfs.ts

281 lines
8.0 KiB
TypeScript

import { cpSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs";
import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { parseArgs } from "node:util";
import {
getMemoryGitStatus,
getMemoryRepoDir,
isGitRepo,
pullMemory,
} from "../../agent/memoryGit";
function printUsage(): void {
console.log(
`
Usage:
letta memfs status [--agent <id>]
letta memfs diff [--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 pull --agent agent-123
letta memfs backup --agent agent-123
letta memfs export --agent agent-123 --out /tmp/letta-memfs-agent-123
`.trim(),
);
}
function getAgentId(agentFromArgs?: string, agentIdFromArgs?: string): string {
return agentFromArgs || agentIdFromArgs || process.env.LETTA_AGENT_ID || "";
}
const MEMFS_OPTIONS = {
help: { type: "boolean", short: "h" },
agent: { type: "string" },
"agent-id": { type: "string" },
from: { type: "string" },
force: { type: "boolean" },
out: { type: "string" },
} as const;
function parseMemfsArgs(argv: string[]) {
return parseArgs({
args: argv,
options: MEMFS_OPTIONS,
strict: true,
allowPositionals: true,
});
}
function getMemoryRoot(agentId: string): string {
return join(homedir(), ".letta", "agents", agentId, "memory");
}
function getAgentRoot(agentId: string): string {
return join(homedir(), ".letta", "agents", agentId);
}
function formatBackupTimestamp(date = new Date()): string {
const pad = (value: number) => String(value).padStart(2, "0");
const yyyy = date.getFullYear();
const mm = pad(date.getMonth() + 1);
const dd = pad(date.getDate());
const hh = pad(date.getHours());
const min = pad(date.getMinutes());
const ss = pad(date.getSeconds());
return `${yyyy}${mm}${dd}-${hh}${min}${ss}`;
}
async function listBackups(
agentId: string,
): Promise<Array<{ name: string; path: string; createdAt: string | null }>> {
const agentRoot = getAgentRoot(agentId);
if (!existsSync(agentRoot)) {
return [];
}
const entries = await readdir(agentRoot, { withFileTypes: true });
const backups: Array<{
name: string;
path: string;
createdAt: string | null;
}> = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!entry.name.startsWith("memory-backup-")) continue;
const path = join(agentRoot, entry.name);
let createdAt: string | null = null;
try {
const stat = statSync(path);
createdAt = stat.mtime.toISOString();
} catch {
createdAt = null;
}
backups.push({ name: entry.name, path, createdAt });
}
backups.sort((a, b) => a.name.localeCompare(b.name));
return backups;
}
function resolveBackupPath(agentId: string, from: string): string {
if (from.startsWith("/") || /^[A-Za-z]:[/\\]/.test(from)) {
return from;
}
return join(getAgentRoot(agentId), from);
}
export async function runMemfsSubcommand(argv: string[]): Promise<number> {
let parsed: ReturnType<typeof parseMemfsArgs>;
try {
parsed = parseMemfsArgs(argv);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: ${message}`);
printUsage();
return 1;
}
const [action] = parsed.positionals;
if (parsed.values.help || !action || action === "help") {
printUsage();
return 0;
}
const agentId = getAgentId(parsed.values.agent, parsed.values["agent-id"]);
if (!agentId) {
console.error(
"Missing agent id. Set LETTA_AGENT_ID or pass --agent/--agent-id.",
);
return 1;
}
try {
if (action === "status") {
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.dirty || status.aheadOfRemote ? 2 : 0;
}
if (action === "diff") {
if (!isGitRepo(agentId)) {
console.error("Not a git repo. Enable git-backed memory first.");
return 1;
}
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;
}
console.log("No changes.");
return 0;
}
if (action === "pull") {
if (!isGitRepo(agentId)) {
console.error("Not a git repo. Enable git-backed memory first.");
return 1;
}
const result = await pullMemory(agentId);
console.log(JSON.stringify(result, null, 2));
return 0;
}
if (action === "backup") {
const root = getMemoryRoot(agentId);
if (!existsSync(root)) {
console.error(`Memory directory not found for agent ${agentId}.`);
return 1;
}
const agentRoot = getAgentRoot(agentId);
const backupName = `memory-backup-${formatBackupTimestamp()}`;
const backupPath = join(agentRoot, backupName);
if (existsSync(backupPath)) {
console.error(`Backup already exists at ${backupPath}`);
return 1;
}
cpSync(root, backupPath, { recursive: true });
console.log(JSON.stringify({ backupName, backupPath }, null, 2));
return 0;
}
if (action === "backups") {
const backups = await listBackups(agentId);
console.log(JSON.stringify({ backups }, null, 2));
return 0;
}
if (action === "restore") {
const from = parsed.values.from;
if (!from) {
console.error("Missing --from <backup>.");
return 1;
}
if (!parsed.values.force) {
console.error("Restore is destructive. Re-run with --force.");
return 1;
}
const backupPath = resolveBackupPath(agentId, from);
if (!existsSync(backupPath)) {
console.error(`Backup not found: ${backupPath}`);
return 1;
}
const stat = statSync(backupPath);
if (!stat.isDirectory()) {
console.error(`Backup path is not a directory: ${backupPath}`);
return 1;
}
const root = getMemoryRoot(agentId);
rmSync(root, { recursive: true, force: true });
cpSync(backupPath, root, { recursive: true });
console.log(JSON.stringify({ restoredFrom: backupPath }, null, 2));
return 0;
}
if (action === "export") {
const out = parsed.values.out;
if (!out) {
console.error("Missing --out <dir>.");
return 1;
}
const root = getMemoryRoot(agentId);
if (!existsSync(root)) {
console.error(`Memory directory not found for agent ${agentId}.`);
return 1;
}
if (existsSync(out)) {
const stat = statSync(out);
if (stat.isDirectory()) {
const contents = await readdir(out);
if (contents.length > 0) {
console.error(`Export directory not empty: ${out}`);
return 1;
}
} else {
console.error(`Export path is not a directory: ${out}`);
return 1;
}
} else {
mkdirSync(out, { recursive: true });
}
cpSync(root, out, { recursive: true });
console.log(
JSON.stringify(
{ exportedFrom: root, exportedTo: out, agentId },
null,
2,
),
);
return 0;
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
return 1;
}
console.error(`Unknown action: ${action}`);
printUsage();
return 1;
}