diff --git a/src/agent/prompts/system_prompt_memfs.txt b/src/agent/prompts/system_prompt_memfs.txt index 8b69dae..d325953 100644 --- a/src/agent/prompts/system_prompt_memfs.txt +++ b/src/agent/prompts/system_prompt_memfs.txt @@ -26,7 +26,7 @@ Your memory blocks are synchronized with a filesystem tree at `~/.letta/agents/< - **After memory edits**: Automatic sync after using memory tools - **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 +- **Agent-driven resolution**: If conflicts are detected, you'll receive a system reminder with the conflicting labels and instructions to resolve them using `letta memfs ...` (see the `syncing-memory-filesystem` skill docs) - **User fallback**: The user can also run `/memfs sync` to resolve conflicts manually via an interactive prompt ### How It Works diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 70b681f..2ef2c49 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -7069,12 +7069,12 @@ ${conflictRows} To see the full diff for each conflict, run: \`\`\`bash -npx tsx /scripts/memfs-diff.ts $LETTA_AGENT_ID +letta memfs diff --agent $LETTA_AGENT_ID \`\`\` The diff will be written to a file for review. After reviewing, resolve all conflicts at once: \`\`\`bash -npx tsx /scripts/memfs-resolve.ts $LETTA_AGENT_ID --resolutions '' +letta memfs resolve --agent $LETTA_AGENT_ID --resolutions '' \`\`\` Resolution options: \`"file"\` (overwrite block with file) or \`"block"\` (overwrite file with block). diff --git a/src/cli/components/InlineTaskApproval.tsx b/src/cli/components/InlineTaskApproval.tsx index 4753d51..a1af3d7 100644 --- a/src/cli/components/InlineTaskApproval.tsx +++ b/src/cli/components/InlineTaskApproval.tsx @@ -24,14 +24,6 @@ type Props = { // Horizontal line character for Claude Code style const SOLID_LINE = "─"; -/** - * Truncate text to max length with ellipsis - */ -function truncate(text: string, maxLength: number): string { - if (text.length <= maxLength) return text; - return `${text.slice(0, maxLength - 3)}...`; -} - /** * InlineTaskApproval - Renders Task tool approval UI inline with pretty formatting * diff --git a/src/cli/profile-selection.tsx b/src/cli/profile-selection.tsx index 2f64ab0..41ea9b7 100644 --- a/src/cli/profile-selection.tsx +++ b/src/cli/profile-selection.tsx @@ -70,7 +70,7 @@ function ProfileSelectionUI({ externalFreshRepoMode, failedAgentMessage, serverModelsForNewAgent, - defaultModelHandle, + defaultModelHandle: _defaultModelHandle, serverBaseUrl, onComplete, }: { diff --git a/src/cli/subcommands/agents.ts b/src/cli/subcommands/agents.ts new file mode 100644 index 0000000..bfcfd0a --- /dev/null +++ b/src/cli/subcommands/agents.ts @@ -0,0 +1,110 @@ +import { parseArgs } from "node:util"; +import type { AgentListParams } from "@letta-ai/letta-client/resources/agents/agents"; +import { getClient } from "../../agent/client"; + +function printUsage(): void { + console.log( + ` +Usage: + letta agents list [options] + +Options: + --name Exact name match + --query Fuzzy search by name + --tags Filter by tags (comma-separated) + --match-all-tags Require ALL tags (default: ANY) + --include-blocks Include agent.blocks in response + --limit Max results (default: 20) + +Notes: + - Output is JSON only. + - Uses CLI auth; override with LETTA_API_KEY/LETTA_BASE_URL if needed. +`.trim(), + ); +} + +function parseLimit(value: unknown, fallback: number): number { + if (typeof value !== "string" || value.length === 0) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? fallback : parsed; +} + +function parseTags(value: unknown): string[] | undefined { + if (typeof value !== "string") return undefined; + const tags = value + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); + return tags.length > 0 ? tags : undefined; +} + +export async function runAgentsSubcommand(argv: string[]): Promise { + let parsed: ReturnType; + try { + parsed = parseArgs({ + args: argv, + options: { + help: { type: "boolean", short: "h" }, + name: { type: "string" }, + query: { type: "string" }, + tags: { type: "string" }, + "match-all-tags": { type: "boolean" }, + "include-blocks": { type: "boolean" }, + limit: { type: "string" }, + }, + strict: true, + allowPositionals: true, + }); + } 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; + } + + if (action !== "list") { + console.error(`Unknown action: ${action}`); + printUsage(); + return 1; + } + + const params: AgentListParams = { + limit: parseLimit(parsed.values.limit, 20), + }; + + if (typeof parsed.values.name === "string") { + params.name = parsed.values.name; + } + + if (typeof parsed.values.query === "string") { + params.query_text = parsed.values.query; + } + + const tags = parseTags(parsed.values.tags); + if (tags) { + params.tags = tags; + if (parsed.values["match-all-tags"]) { + params.match_all_tags = true; + } + } + + if (parsed.values["include-blocks"]) { + params.include = ["agent.blocks"]; + } + + try { + const client = await getClient(); + const result = await client.agents.list(params); + console.log(JSON.stringify(result, null, 2)); + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} diff --git a/src/cli/subcommands/blocks.ts b/src/cli/subcommands/blocks.ts new file mode 100644 index 0000000..41e2a09 --- /dev/null +++ b/src/cli/subcommands/blocks.ts @@ -0,0 +1,350 @@ +import { parseArgs } from "node:util"; +import { getClient } from "../../agent/client"; + +function printUsage(): void { + console.log( + ` +Usage: + letta blocks list --agent [--limit ] + letta blocks copy --block-id [--label ] [--agent ] [--override] + letta blocks attach --block-id [--agent ] [--read-only] [--override] + +Notes: + - Output is JSON only. + - Uses CLI auth; override with LETTA_API_KEY/LETTA_BASE_URL if needed. + - Default target agent for copy/attach is LETTA_AGENT_ID. +`.trim(), + ); +} + +function parseLimit(value: unknown, fallback: number): number { + if (typeof value !== "string" || value.length === 0) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? fallback : parsed; +} + +function getAgentId(agentFromArgs?: string, agentIdFromArgs?: string): string { + return agentFromArgs || agentIdFromArgs || process.env.LETTA_AGENT_ID || ""; +} + +type CopyBlockResult = { + sourceBlock: Awaited< + ReturnType>["blocks"]["retrieve"]> + >; + newBlock: Awaited< + ReturnType>["blocks"]["create"]> + >; + attachResult: Awaited< + ReturnType< + Awaited>["agents"]["blocks"]["attach"] + > + >; + detachedBlock?: Awaited< + ReturnType>["blocks"]["retrieve"]> + >; +}; + +type AttachBlockResult = { + attachResult: Awaited< + ReturnType< + Awaited>["agents"]["blocks"]["attach"] + > + >; + detachedBlock?: Awaited< + ReturnType>["blocks"]["retrieve"]> + >; +}; + +async function copyBlock( + client: Awaited>, + blockId: string, + options?: { + labelOverride?: string; + targetAgentId?: string; + override?: boolean; + }, +): Promise { + const currentAgentId = getAgentId(options?.targetAgentId); + if (!currentAgentId) { + throw new Error( + "Missing agent id. Set LETTA_AGENT_ID or pass --agent/--agent-id.", + ); + } + + let detachedBlock: + | Awaited> + | undefined; + + const sourceBlock = await client.blocks.retrieve(blockId); + const targetLabel = + options?.labelOverride || sourceBlock.label || "migrated-block"; + + if (options?.override) { + const currentBlocksResponse = + await client.agents.blocks.list(currentAgentId); + const currentBlocks = Array.isArray(currentBlocksResponse) + ? currentBlocksResponse + : (currentBlocksResponse as { items?: unknown[] }).items || []; + const conflictingBlock = currentBlocks.find( + (b: { label?: string }) => b.label === targetLabel, + ); + + if (conflictingBlock) { + console.error( + `Detaching existing block with label "${targetLabel}" (${conflictingBlock.id})...`, + ); + detachedBlock = conflictingBlock as Awaited< + ReturnType + >; + try { + await client.agents.blocks.detach(conflictingBlock.id, { + agent_id: currentAgentId, + }); + } catch (detachError) { + throw new Error( + `Failed to detach existing block "${targetLabel}": ${ + detachError instanceof Error + ? detachError.message + : String(detachError) + }`, + ); + } + } + } + + let newBlock: Awaited>; + try { + newBlock = await client.blocks.create({ + label: targetLabel, + value: sourceBlock.value, + description: sourceBlock.description || undefined, + limit: sourceBlock.limit, + }); + } catch (createError) { + if (detachedBlock) { + console.error( + `Create failed, reattaching original block "${detachedBlock.label}"...`, + ); + try { + await client.agents.blocks.attach(detachedBlock.id, { + agent_id: currentAgentId, + }); + console.error("Original block reattached successfully."); + } catch { + console.error( + `WARNING: Failed to reattach original block! Block ID: ${detachedBlock.id}`, + ); + } + } + throw createError; + } + + let attachResult: Awaited>; + try { + attachResult = await client.agents.blocks.attach(newBlock.id, { + agent_id: currentAgentId, + }); + } catch (attachError) { + if (detachedBlock) { + console.error( + `Attach failed, reattaching original block "${detachedBlock.label}"...`, + ); + try { + await client.agents.blocks.attach(detachedBlock.id, { + agent_id: currentAgentId, + }); + console.error("Original block reattached successfully."); + } catch { + console.error( + `WARNING: Failed to reattach original block! Block ID: ${detachedBlock.id}`, + ); + } + } + throw attachError; + } + + return { sourceBlock, newBlock, attachResult, detachedBlock }; +} + +async function attachBlock( + client: Awaited>, + blockId: string, + options?: { readOnly?: boolean; targetAgentId?: string; override?: boolean }, +): Promise { + const currentAgentId = getAgentId(options?.targetAgentId); + if (!currentAgentId) { + throw new Error( + "Missing agent id. Set LETTA_AGENT_ID or pass --agent/--agent-id.", + ); + } + + let detachedBlock: + | Awaited> + | undefined; + + if (options?.override) { + const sourceBlock = await client.blocks.retrieve(blockId); + const sourceLabel = sourceBlock.label; + + const currentBlocksResponse = + await client.agents.blocks.list(currentAgentId); + const currentBlocks = Array.isArray(currentBlocksResponse) + ? currentBlocksResponse + : (currentBlocksResponse as { items?: unknown[] }).items || []; + const conflictingBlock = currentBlocks.find( + (b: { label?: string }) => b.label === sourceLabel, + ); + + if (conflictingBlock) { + console.error( + `Detaching existing block with label "${sourceLabel}" (${conflictingBlock.id})...`, + ); + detachedBlock = conflictingBlock as Awaited< + ReturnType + >; + try { + await client.agents.blocks.detach(conflictingBlock.id, { + agent_id: currentAgentId, + }); + } catch (detachError) { + throw new Error( + `Failed to detach existing block "${sourceLabel}": ${ + detachError instanceof Error + ? detachError.message + : String(detachError) + }`, + ); + } + } + } + + let attachResult: Awaited>; + try { + attachResult = await client.agents.blocks.attach(blockId, { + agent_id: currentAgentId, + }); + } catch (attachError) { + if (detachedBlock) { + console.error( + `Attach failed, reattaching original block "${detachedBlock.label}"...`, + ); + try { + await client.agents.blocks.attach(detachedBlock.id, { + agent_id: currentAgentId, + }); + console.error("Original block reattached successfully."); + } catch { + console.error( + `WARNING: Failed to reattach original block! Block ID: ${detachedBlock.id}`, + ); + } + } + throw attachError; + } + + if (options?.readOnly) { + console.warn( + "Note: read_only flag is set on the block itself, not per-agent. " + + "Use the block update API to set read_only if needed.", + ); + } + + return { attachResult, detachedBlock }; +} + +export async function runBlocksSubcommand(argv: string[]): Promise { + let parsed: ReturnType; + try { + parsed = parseArgs({ + args: argv, + options: { + help: { type: "boolean", short: "h" }, + agent: { type: "string" }, + "agent-id": { type: "string" }, + limit: { type: "string" }, + "block-id": { type: "string" }, + label: { type: "string" }, + override: { type: "boolean" }, + "read-only": { type: "boolean" }, + }, + strict: true, + allowPositionals: true, + }); + } 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; + } + + try { + const client = await getClient(); + + if (action === "list") { + const agentId = parsed.values.agent || parsed.values["agent-id"] || ""; + if (!agentId || typeof agentId !== "string") { + console.error("Missing required --agent ."); + return 1; + } + const result = await client.agents.blocks.list(agentId, { + limit: parseLimit(parsed.values.limit, 1000), + }); + console.log(JSON.stringify(result, null, 2)); + return 0; + } + + if (action === "copy") { + const blockId = parsed.values["block-id"]; + if (!blockId || typeof blockId !== "string") { + console.error("Missing required --block-id ."); + return 1; + } + const agentId = getAgentId( + parsed.values.agent as string | undefined, + parsed.values["agent-id"] as string | undefined, + ); + const result = await copyBlock(client, blockId, { + labelOverride: + typeof parsed.values.label === "string" + ? parsed.values.label + : undefined, + targetAgentId: agentId, + override: parsed.values.override === true, + }); + console.log(JSON.stringify(result, null, 2)); + return 0; + } + + if (action === "attach") { + const blockId = parsed.values["block-id"]; + if (!blockId || typeof blockId !== "string") { + console.error("Missing required --block-id ."); + return 1; + } + const agentId = getAgentId( + parsed.values.agent as string | undefined, + parsed.values["agent-id"] as string | undefined, + ); + const result = await attachBlock(client, blockId, { + readOnly: parsed.values["read-only"] === true, + override: parsed.values.override === true, + targetAgentId: agentId, + }); + console.log(JSON.stringify(result, 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; +} diff --git a/src/cli/subcommands/memfs.ts b/src/cli/subcommands/memfs.ts new file mode 100644 index 0000000..edf00cb --- /dev/null +++ b/src/cli/subcommands/memfs.ts @@ -0,0 +1,812 @@ +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 { homedir } from "node:os"; +import { join, normalize, relative } 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; + fileHashes: Record; + blockIds: Record; + lastSync: string | null; +}; + +function printUsage(): void { + console.log( + ` +Usage: + letta memfs status [--agent ] + letta memfs diff [--agent ] + letta memfs resolve --resolutions '' [--agent ] + letta memfs backup [--agent ] + letta memfs backups [--agent ] + letta memfs restore --from --force [--agent ] + letta memfs export --agent --out + +Notes: + - Requires agent id via --agent or LETTA_AGENT_ID. + - Output is JSON only. + +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 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(), + ); +} + +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"); +} + +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> { + 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); +} + +async function scanMdFiles( + dir: string, + baseDir = dir, + excludeDirs: string[] = [], +): Promise { + 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> { + const files = await scanMdFiles(dir, dir, excludeDirs); + const entries = new Map(); + 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([ + ...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([ + ...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 { + let parsed: ReturnType; + try { + parsed = parseArgs({ + args: argv, + options: { + help: { type: "boolean", short: "h" }, + agent: { type: "string" }, + "agent-id": { type: "string" }, + from: { type: "string" }, + force: { type: "boolean" }, + out: { type: "string" }, + resolutions: { type: "string" }, + }, + strict: true, + allowPositionals: true, + }); + } 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 as string | undefined, + parsed.values["agent-id"] as string | undefined, + ); + + if (!agentId) { + console.error( + "Missing agent id. Set LETTA_AGENT_ID or pass --agent/--agent-id.", + ); + return 1; + } + + try { + if (action === "status") { + ensureMemoryFilesystemDirs(agentId); + const status = await computeStatus(agentId); + console.log(JSON.stringify(status, null, 2)); + return status.isClean ? 0 : 2; + } + + 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; + } + + const diffContent = formatDiffFile(conflicts, metadataOnly, agentId); + 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( + JSON.stringify( + { conflicts, metadataOnly, diffPath, clean: false }, + null, + 2, + ), + ); + return conflicts.length > 0 ? 2 : 0; + } + + if (action === "resolve") { + ensureMemoryFilesystemDirs(agentId); + const resolutionsRaw = parsed.values.resolutions as string | undefined; + if (!resolutionsRaw) { + console.error("Missing --resolutions JSON."); + 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, + }); + + console.log(JSON.stringify(result, null, 2)); + return result.conflicts.length > 0 ? 2 : 0; + } + + if (action === "backup") { + const root = getMemoryRoot(agentId); + if (!existsSync(root)) { + console.error( + `Memory directory not found for agent ${agentId}. Run memfs sync first.`, + ); + 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 as string | undefined; + if (!from) { + console.error("Missing --from ."); + 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 as string | undefined; + if (!out) { + console.error("Missing --out ."); + return 1; + } + const root = getMemoryRoot(agentId); + if (!existsSync(root)) { + console.error( + `Memory directory not found for agent ${agentId}. Run memfs sync first.`, + ); + 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; +} diff --git a/src/cli/subcommands/messages.ts b/src/cli/subcommands/messages.ts new file mode 100644 index 0000000..e5c4f8b --- /dev/null +++ b/src/cli/subcommands/messages.ts @@ -0,0 +1,355 @@ +import { parseArgs } from "node:util"; +import { getClient } from "../../agent/client"; +import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants"; + +type SearchMode = "vector" | "fts" | "hybrid"; + +function printUsage(): void { + console.log( + ` +Usage: + letta messages search --query [options] + letta messages list [options] + letta messages start-conversation --agent --message "" + letta messages continue-conversation --conversation-id --message "" + +Search options: + --query Search query (required) + --mode Search mode: vector, fts, hybrid (default: hybrid) + --start-date Filter messages after this date (ISO format) + --end-date Filter messages before this date (ISO format) + --limit Max results (default: 10) + --all-agents Search all agents, not just current agent + --agent Explicit agent ID (overrides LETTA_AGENT_ID) + --agent-id Alias for --agent + +List options: + --agent Agent ID (overrides LETTA_AGENT_ID) + --agent-id Alias for --agent + --after Cursor: get messages after this ID + --before Cursor: get messages before this ID + --order Sort order (default: desc = newest first) + --limit Max results (default: 20) + --start-date Client-side filter: after this date (ISO format) + --end-date Client-side filter: before this date (ISO format) + +Conversation options: + --agent Target agent ID (start-conversation) + --message Message to send + --conversation-id Existing conversation ID (continue-conversation) + --timeout Max wait time (accepted for compatibility) + +Notes: + - Output is JSON only. + - Uses CLI auth; override with LETTA_API_KEY/LETTA_BASE_URL if needed. + - Sender agent ID is read from LETTA_AGENT_ID for conversation commands. +`.trim(), + ); +} + +function parseLimit(value: unknown, fallback: number): number { + if (typeof value !== "string" || value.length === 0) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? fallback : parsed; +} + +function parseMode(value: unknown): SearchMode | undefined { + if (typeof value !== "string") return undefined; + if (value === "vector" || value === "fts" || value === "hybrid") { + return value; + } + return undefined; +} + +function getAgentId(agentFromArgs?: string, agentIdFromArgs?: string): string { + return agentFromArgs || agentIdFromArgs || process.env.LETTA_AGENT_ID || ""; +} + +function buildSystemReminder( + senderAgentName: string, + senderAgentId: string, +): string { + return `${SYSTEM_REMINDER_OPEN} +This message is from "${senderAgentName}" (agent ID: ${senderAgentId}), an agent currently running inside the Letta Code CLI (docs.letta.com/letta-code). +The sender will only see the final message you generate (not tool calls or reasoning). +If you need to share detailed information, include it in your response text. +${SYSTEM_REMINDER_CLOSE} + +`; +} + +async function extractAssistantResponse( + stream: AsyncIterable, +): Promise { + let finalResponse = ""; + for await (const chunk of stream) { + if (process.env.DEBUG) { + console.error("Chunk:", JSON.stringify(chunk, null, 2)); + } + if ( + typeof chunk === "object" && + chunk !== null && + "message_type" in chunk && + (chunk as { message_type?: string }).message_type === "assistant_message" + ) { + const content = (chunk as { content?: unknown }).content; + if (typeof content === "string") { + finalResponse += content; + } else if (Array.isArray(content)) { + for (const part of content) { + if ( + typeof part === "object" && + part !== null && + "type" in part && + (part as { type?: string }).type === "text" && + "text" in part + ) { + finalResponse += (part as { text: string }).text; + } + } + } + } + } + return finalResponse; +} + +export async function runMessagesSubcommand(argv: string[]): Promise { + let parsed: ReturnType; + try { + parsed = parseArgs({ + args: argv, + options: { + help: { type: "boolean", short: "h" }, + query: { type: "string" }, + mode: { type: "string" }, + "start-date": { type: "string" }, + "end-date": { type: "string" }, + limit: { type: "string" }, + "all-agents": { type: "boolean" }, + agent: { type: "string" }, + "agent-id": { type: "string" }, + after: { type: "string" }, + before: { type: "string" }, + order: { type: "string" }, + message: { type: "string" }, + "conversation-id": { type: "string" }, + timeout: { type: "string" }, + }, + strict: true, + allowPositionals: true, + }); + } 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; + } + + try { + const client = await getClient(); + + if (action === "search") { + const query = parsed.values.query; + if (!query || typeof query !== "string") { + console.error("Missing required --query ."); + return 1; + } + + const allAgents = parsed.values["all-agents"] ?? false; + const agentId = getAgentId( + parsed.values.agent as string | undefined, + parsed.values["agent-id"] as string | undefined, + ); + if (!allAgents && !agentId) { + console.error( + "Missing agent id. Set LETTA_AGENT_ID or pass --agent/--agent-id.", + ); + return 1; + } + + const result = await client.messages.search({ + query, + agent_id: allAgents ? undefined : agentId, + search_mode: parseMode(parsed.values.mode) ?? "hybrid", + start_date: parsed.values["start-date"] as string | undefined, + end_date: parsed.values["end-date"] as string | undefined, + limit: parseLimit(parsed.values.limit, 10), + }); + + console.log(JSON.stringify(result, null, 2)); + return 0; + } + + if (action === "list") { + const agentId = getAgentId( + parsed.values.agent as string | undefined, + parsed.values["agent-id"] as string | undefined, + ); + if (!agentId) { + console.error( + "Missing agent id. Set LETTA_AGENT_ID or pass --agent/--agent-id.", + ); + return 1; + } + + const response = await client.agents.messages.list(agentId, { + limit: parseLimit(parsed.values.limit, 20), + after: parsed.values.after as string | undefined, + before: parsed.values.before as string | undefined, + order: parsed.values.order as "asc" | "desc" | undefined, + }); + + const messages = response.items ?? []; + const startDate = parsed.values["start-date"]; + const endDate = parsed.values["end-date"]; + + let filtered = messages; + if (startDate || endDate) { + const startTime = startDate + ? new Date(startDate as string).getTime() + : 0; + const endTime = endDate + ? new Date(endDate as string).getTime() + : Number.POSITIVE_INFINITY; + filtered = messages.filter((msg) => { + if (!("date" in msg) || !msg.date) return true; + const msgTime = new Date(msg.date).getTime(); + return msgTime >= startTime && msgTime <= endTime; + }); + } + + const sorted = [...filtered].sort((a, b) => { + const aDate = "date" in a && a.date ? new Date(a.date).getTime() : 0; + const bDate = "date" in b && b.date ? new Date(b.date).getTime() : 0; + return aDate - bDate; + }); + + console.log(JSON.stringify(sorted, null, 2)); + return 0; + } + + if (action === "start-conversation") { + const agentId = getAgentId( + parsed.values.agent as string | undefined, + parsed.values["agent-id"] as string | undefined, + ); + if (!agentId) { + console.error("Missing target agent id. Use --agent/--agent-id."); + return 1; + } + const message = parsed.values.message; + if (!message || typeof message !== "string") { + console.error("Missing required --message ."); + return 1; + } + + const senderAgentId = process.env.LETTA_AGENT_ID; + if (!senderAgentId) { + console.error( + "Missing LETTA_AGENT_ID for sender. Run inside a Letta Code session.", + ); + return 1; + } + + const targetAgent = await client.agents.retrieve(agentId); + const senderAgent = await client.agents.retrieve(senderAgentId); + const conversation = await client.conversations.create({ + agent_id: targetAgent.id, + }); + + const systemReminder = buildSystemReminder( + senderAgent.name, + senderAgentId, + ); + const fullMessage = systemReminder + message; + const stream = await client.conversations.messages.create( + conversation.id, + { + input: fullMessage, + streaming: true, + }, + ); + + const response = await extractAssistantResponse(stream); + console.log( + JSON.stringify( + { + conversation_id: conversation.id, + response, + agent_id: targetAgent.id, + agent_name: targetAgent.name, + }, + null, + 2, + ), + ); + return 0; + } + + if (action === "continue-conversation") { + const conversationId = parsed.values["conversation-id"]; + if (!conversationId || typeof conversationId !== "string") { + console.error("Missing required --conversation-id ."); + return 1; + } + const message = parsed.values.message; + if (!message || typeof message !== "string") { + console.error("Missing required --message ."); + return 1; + } + + const senderAgentId = process.env.LETTA_AGENT_ID; + if (!senderAgentId) { + console.error( + "Missing LETTA_AGENT_ID for sender. Run inside a Letta Code session.", + ); + return 1; + } + + const conversation = await client.conversations.retrieve(conversationId); + const targetAgent = await client.agents.retrieve(conversation.agent_id); + const senderAgent = await client.agents.retrieve(senderAgentId); + + const systemReminder = buildSystemReminder( + senderAgent.name, + senderAgentId, + ); + const fullMessage = systemReminder + message; + const stream = await client.conversations.messages.create( + conversationId, + { + input: fullMessage, + streaming: true, + }, + ); + + const response = await extractAssistantResponse(stream); + console.log( + JSON.stringify( + { + conversation_id: conversationId, + response, + agent_id: targetAgent.id, + agent_name: targetAgent.name, + }, + 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; +} diff --git a/src/cli/subcommands/router.ts b/src/cli/subcommands/router.ts new file mode 100644 index 0000000..0edfdd2 --- /dev/null +++ b/src/cli/subcommands/router.ts @@ -0,0 +1,25 @@ +import { runAgentsSubcommand } from "./agents"; +import { runBlocksSubcommand } from "./blocks"; +import { runMemfsSubcommand } from "./memfs"; +import { runMessagesSubcommand } from "./messages"; + +export async function runSubcommand(argv: string[]): Promise { + const [command, ...rest] = argv; + + if (!command) { + return null; + } + + switch (command) { + case "memfs": + return runMemfsSubcommand(rest); + case "agents": + return runAgentsSubcommand(rest); + case "messages": + return runMessagesSubcommand(rest); + case "blocks": + return runBlocksSubcommand(rest); + default: + return null; + } +} diff --git a/src/index.ts b/src/index.ts index 720644e..5935463 100755 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { LETTA_CLOUD_API_URL } from "./auth/oauth"; import { ConversationSelector } from "./cli/components/ConversationSelector"; import type { ApprovalRequest } from "./cli/helpers/stream"; import { ProfileSelectionInline } from "./cli/profile-selection"; +import { runSubcommand } from "./cli/subcommands/router"; import { permissionMode } from "./permissions/mode"; import { settingsManager } from "./settings-manager"; import { telemetry } from "./telemetry"; @@ -48,6 +49,10 @@ USAGE # maintenance letta update Manually check for updates and install if available + letta memfs ... Memory filesystem subcommands (JSON-only) + letta agents ... Agents subcommands (JSON-only) + letta messages ... Messages subcommands (JSON-only) + letta blocks ... Blocks subcommands (JSON-only) OPTIONS -h, --help Show this help and exit @@ -77,6 +82,23 @@ OPTIONS --memfs Enable memory filesystem for this agent --no-memfs Disable memory filesystem for this agent +SUBCOMMANDS (JSON-only) + letta memfs status --agent + letta memfs diff --agent + letta memfs resolve --agent --resolutions '' + letta memfs backup --agent + letta memfs backups --agent + letta memfs restore --agent --from --force + letta memfs export --agent --out + letta agents list [--query | --name | --tags ] + letta messages search --query [--all-agents] + letta messages list [--agent ] + letta messages start-conversation --agent --message "" + letta messages continue-conversation --conversation-id --message "" + letta blocks list --agent + letta blocks copy --block-id [--label