feat: refactor skill scripts into cli subcommands (#759)

This commit is contained in:
Charles Packer
2026-01-31 14:18:10 -08:00
committed by GitHub
parent fa3f48583c
commit 375e485874
38 changed files with 1834 additions and 4402 deletions

View File

@@ -7069,12 +7069,12 @@ ${conflictRows}
To see the full diff for each conflict, run:
\`\`\`bash
npx tsx <SKILL_DIR>/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 <SKILL_DIR>/scripts/memfs-resolve.ts $LETTA_AGENT_ID --resolutions '<JSON array of {label, resolution}>'
letta memfs resolve --agent $LETTA_AGENT_ID --resolutions '<JSON array of {label, resolution}>'
\`\`\`
Resolution options: \`"file"\` (overwrite block with file) or \`"block"\` (overwrite file with block).

View File

@@ -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
*

View File

@@ -70,7 +70,7 @@ function ProfileSelectionUI({
externalFreshRepoMode,
failedAgentMessage,
serverModelsForNewAgent,
defaultModelHandle,
defaultModelHandle: _defaultModelHandle,
serverBaseUrl,
onComplete,
}: {

View File

@@ -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 <name> Exact name match
--query <text> Fuzzy search by name
--tags <tag1,tag2> Filter by tags (comma-separated)
--match-all-tags Require ALL tags (default: ANY)
--include-blocks Include agent.blocks in response
--limit <n> 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<number> {
let parsed: ReturnType<typeof parseArgs>;
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;
}
}

View File

@@ -0,0 +1,350 @@
import { parseArgs } from "node:util";
import { getClient } from "../../agent/client";
function printUsage(): void {
console.log(
`
Usage:
letta blocks list --agent <id> [--limit <n>]
letta blocks copy --block-id <id> [--label <new-label>] [--agent <id>] [--override]
letta blocks attach --block-id <id> [--agent <id>] [--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<Awaited<ReturnType<typeof getClient>>["blocks"]["retrieve"]>
>;
newBlock: Awaited<
ReturnType<Awaited<ReturnType<typeof getClient>>["blocks"]["create"]>
>;
attachResult: Awaited<
ReturnType<
Awaited<ReturnType<typeof getClient>>["agents"]["blocks"]["attach"]
>
>;
detachedBlock?: Awaited<
ReturnType<Awaited<ReturnType<typeof getClient>>["blocks"]["retrieve"]>
>;
};
type AttachBlockResult = {
attachResult: Awaited<
ReturnType<
Awaited<ReturnType<typeof getClient>>["agents"]["blocks"]["attach"]
>
>;
detachedBlock?: Awaited<
ReturnType<Awaited<ReturnType<typeof getClient>>["blocks"]["retrieve"]>
>;
};
async function copyBlock(
client: Awaited<ReturnType<typeof getClient>>,
blockId: string,
options?: {
labelOverride?: string;
targetAgentId?: string;
override?: boolean;
},
): Promise<CopyBlockResult> {
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<ReturnType<typeof client.blocks.retrieve>>
| 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<typeof client.blocks.retrieve>
>;
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<ReturnType<typeof client.blocks.create>>;
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<ReturnType<typeof client.agents.blocks.attach>>;
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<ReturnType<typeof getClient>>,
blockId: string,
options?: { readOnly?: boolean; targetAgentId?: string; override?: boolean },
): Promise<AttachBlockResult> {
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<ReturnType<typeof client.blocks.retrieve>>
| 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<typeof client.blocks.retrieve>
>;
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<ReturnType<typeof client.agents.blocks.attach>>;
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<number> {
let parsed: ReturnType<typeof parseArgs>;
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 <id>.");
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 <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 <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;
}

View File

@@ -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<string, string>;
fileHashes: Record<string, string>;
blockIds: Record<string, string>;
lastSync: string | null;
};
function printUsage(): void {
console.log(
`
Usage:
letta memfs status [--agent <id>]
letta memfs diff [--agent <id>]
letta memfs resolve --resolutions '<JSON>' [--agent <id>]
letta memfs backup [--agent <id>]
letta memfs backups [--agent <id>]
letta memfs restore --from <backup> --force [--agent <id>]
letta memfs export --agent <id> --out <dir>
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<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);
}
async function scanMdFiles(
dir: string,
baseDir = dir,
excludeDirs: string[] = [],
): Promise<string[]> {
if (!existsSync(dir)) return [];
const entries = await readdir(dir, { withFileTypes: true });
const results: string[] = [];
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
if (excludeDirs.includes(entry.name)) continue;
results.push(...(await scanMdFiles(fullPath, baseDir, excludeDirs)));
} else if (entry.isFile() && entry.name.endsWith(".md")) {
results.push(relative(baseDir, fullPath));
}
}
return results;
}
function labelFromPath(relativePath: string): string {
return relativePath.replace(/\\/g, "/").replace(/\.md$/, "");
}
async function readMemoryFiles(
dir: string,
excludeDirs: string[] = [],
): Promise<Map<string, { content: string }>> {
const files = await scanMdFiles(dir, dir, excludeDirs);
const entries = new Map<string, { content: string }>();
for (const rel of files) {
const label = labelFromPath(rel);
const content = await readFile(join(dir, rel), "utf-8");
entries.set(label, { content });
}
return entries;
}
function getOverflowDirectory(): string {
const cwd = process.cwd();
const normalizedPath = normalize(cwd);
const sanitizedPath = normalizedPath
.replace(/^[/\\]/, "")
.replace(/[/\\:]/g, "_")
.replace(/\s+/g, "_");
return join(homedir(), ".letta", "projects", sanitizedPath, "agent-tools");
}
type Conflict = {
label: string;
fileContent: string;
blockContent: string;
};
type MetadataChange = {
label: string;
fileContent: string;
blockContent: string;
};
async function computeStatus(agentId: string) {
const client = await getClient();
const root = getMemoryRoot(agentId);
const systemDir = join(root, "system");
const detachedDir = root;
for (const dir of [root, systemDir]) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
const systemFiles = await readMemoryFiles(systemDir);
const detachedFiles = await readMemoryFiles(detachedDir, ["system", "user"]);
const blocksResponse = await client.agents.blocks.list(agentId, {
limit: 1000,
});
const attachedBlocks = Array.isArray(blocksResponse)
? blocksResponse
: ((blocksResponse as { items?: unknown[] }).items as Array<{
id?: string;
label?: string;
value?: string;
read_only?: boolean;
}>) || [];
const systemBlockMap = new Map<
string,
{ value: string; id: string; read_only?: boolean }
>();
for (const block of attachedBlocks) {
if (block.label && block.id) {
systemBlockMap.set(block.label, {
value: block.value || "",
id: block.id,
read_only: block.read_only,
});
}
}
const ownedBlocksResponse = await client.blocks.list({
tags: [`owner:${agentId}`],
limit: 1000,
});
const ownedBlocks = Array.isArray(ownedBlocksResponse)
? ownedBlocksResponse
: ((ownedBlocksResponse as { items?: unknown[] }).items as Array<{
id?: string;
label?: string;
value?: string;
read_only?: boolean;
}>) || [];
const attachedIds = new Set(attachedBlocks.map((b) => b.id));
const detachedBlockMap = new Map<
string,
{ value: string; id: string; read_only?: boolean }
>();
for (const block of ownedBlocks) {
if (block.label && block.id && !attachedIds.has(block.id)) {
if (!systemBlockMap.has(block.label)) {
detachedBlockMap.set(block.label, {
value: block.value || "",
id: block.id,
read_only: block.read_only,
});
}
}
}
const lastState = loadSyncState(agentId);
const conflicts: Array<{ label: string }> = [];
const pendingFromFile: string[] = [];
const pendingFromBlock: string[] = [];
const newFiles: string[] = [];
const newBlocks: string[] = [];
const locationMismatches: string[] = [];
const allLabels = new Set<string>([
...systemFiles.keys(),
...detachedFiles.keys(),
...systemBlockMap.keys(),
...detachedBlockMap.keys(),
...Object.keys(lastState.blockHashes),
...Object.keys(lastState.fileHashes),
]);
for (const label of [...allLabels].sort()) {
if (MEMFS_MANAGED_LABELS.has(label)) continue;
const systemFile = systemFiles.get(label);
const detachedFile = detachedFiles.get(label);
const attachedBlock = systemBlockMap.get(label);
const detachedBlock = detachedBlockMap.get(label);
const fileEntry = systemFile || detachedFile;
const fileInSystem = !!systemFile;
const blockEntry = attachedBlock || detachedBlock;
const isAttached = !!attachedBlock;
const effectiveReadOnly =
!!blockEntry?.read_only || READ_ONLY_LABELS.has(label);
if (fileEntry && blockEntry) {
const locationMismatch =
(fileInSystem && !isAttached) || (!fileInSystem && isAttached);
if (locationMismatch) locationMismatches.push(label);
}
const fileHash = fileEntry ? hashContent(fileEntry.content) : null;
const fileBodyHash = fileEntry ? hashFileBody(fileEntry.content) : null;
const blockHash = blockEntry ? hashContent(blockEntry.value) : null;
const lastFileHash = lastState.fileHashes[label] ?? null;
const lastBlockHash = lastState.blockHashes[label] ?? null;
const fileChanged = fileHash !== lastFileHash;
const blockChanged = blockHash !== lastBlockHash;
if (fileEntry && !blockEntry) {
if (READ_ONLY_LABELS.has(label)) continue;
if (lastBlockHash && !fileChanged) continue;
newFiles.push(label);
continue;
}
if (!fileEntry && blockEntry) {
if (effectiveReadOnly) {
pendingFromFile.push(label);
continue;
}
if (lastFileHash && !blockChanged) continue;
newBlocks.push(label);
continue;
}
if (!fileEntry || !blockEntry) continue;
if (effectiveReadOnly) {
if (blockChanged) pendingFromBlock.push(label);
continue;
}
if (fileBodyHash === blockHash) {
if (fileChanged) pendingFromFile.push(label);
continue;
}
if (fileChanged) {
pendingFromFile.push(label);
continue;
}
if (blockChanged) {
pendingFromBlock.push(label);
}
}
const isClean =
conflicts.length === 0 &&
pendingFromFile.length === 0 &&
pendingFromBlock.length === 0 &&
newFiles.length === 0 &&
newBlocks.length === 0 &&
locationMismatches.length === 0;
return {
conflicts,
pendingFromFile,
pendingFromBlock,
newFiles,
newBlocks,
locationMismatches,
isClean,
lastSync: lastState.lastSync,
};
}
async function computeDiff(agentId: string): Promise<{
conflicts: Conflict[];
metadataOnly: MetadataChange[];
}> {
const client = await getClient();
const root = getMemoryRoot(agentId);
const systemDir = join(root, "system");
const detachedDir = root;
for (const dir of [root, systemDir]) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
const systemFiles = await readMemoryFiles(systemDir);
const detachedFiles = await readMemoryFiles(detachedDir, ["system", "user"]);
const blocksResponse = await client.agents.blocks.list(agentId, {
limit: 1000,
});
const attachedBlocks = Array.isArray(blocksResponse)
? blocksResponse
: ((blocksResponse as { items?: unknown[] }).items as Array<{
id?: string;
label?: string;
value?: string;
read_only?: boolean;
}>) || [];
const systemBlockMap = new Map<
string,
{ value: string; id: string; read_only?: boolean }
>();
for (const block of attachedBlocks) {
if (block.label && block.id) {
systemBlockMap.set(block.label, {
value: block.value || "",
id: block.id,
read_only: block.read_only,
});
}
}
const ownedBlocksResponse = await client.blocks.list({
tags: [`owner:${agentId}`],
limit: 1000,
});
const ownedBlocks = Array.isArray(ownedBlocksResponse)
? ownedBlocksResponse
: ((ownedBlocksResponse as { items?: unknown[] }).items as Array<{
id?: string;
label?: string;
value?: string;
read_only?: boolean;
}>) || [];
const attachedIds = new Set(attachedBlocks.map((b) => b.id));
const detachedBlockMap = new Map<
string,
{ value: string; id: string; read_only?: boolean }
>();
for (const block of ownedBlocks) {
if (block.label && block.id && !attachedIds.has(block.id)) {
if (!systemBlockMap.has(block.label)) {
detachedBlockMap.set(block.label, {
value: block.value || "",
id: block.id,
read_only: block.read_only,
});
}
}
}
const lastState = loadSyncState(agentId);
const conflicts: Conflict[] = [];
const metadataOnly: MetadataChange[] = [];
const allLabels = new Set<string>([
...systemFiles.keys(),
...detachedFiles.keys(),
...systemBlockMap.keys(),
...detachedBlockMap.keys(),
...Object.keys(lastState.blockHashes),
...Object.keys(lastState.fileHashes),
]);
for (const label of [...allLabels].sort()) {
if (MEMFS_MANAGED_LABELS.has(label)) continue;
const systemFile = systemFiles.get(label);
const detachedFile = detachedFiles.get(label);
const attachedBlock = systemBlockMap.get(label);
const detachedBlock = detachedBlockMap.get(label);
const fileEntry = systemFile || detachedFile;
const blockEntry = attachedBlock || detachedBlock;
if (!fileEntry || !blockEntry) continue;
const effectiveReadOnly =
!!blockEntry.read_only || READ_ONLY_LABELS.has(label);
if (effectiveReadOnly) continue;
const fileHash = hashContent(fileEntry.content);
const fileBodyHash = hashFileBody(fileEntry.content);
const blockHash = hashContent(blockEntry.value);
const lastFileHash = lastState.fileHashes[label] ?? null;
const lastBlockHash = lastState.blockHashes[label] ?? null;
const fileChanged = fileHash !== lastFileHash;
const blockChanged = blockHash !== lastBlockHash;
if (fileBodyHash === blockHash) {
if (fileChanged) {
metadataOnly.push({
label,
fileContent: fileEntry.content,
blockContent: blockEntry.value,
});
}
continue;
}
if (fileChanged && blockChanged) {
conflicts.push({
label,
fileContent: fileEntry.content,
blockContent: blockEntry.value,
});
}
}
return { conflicts, metadataOnly };
}
function formatDiffFile(
conflicts: Conflict[],
metadataOnly: MetadataChange[],
agentId: string,
): string {
const lines: string[] = [
`# Memory Filesystem Diff`,
``,
`Agent: ${agentId}`,
`Generated: ${new Date().toISOString()}`,
`Conflicts: ${conflicts.length}`,
`Metadata-only changes: ${metadataOnly.length}`,
``,
`---`,
``,
];
for (const conflict of conflicts) {
lines.push(`## Conflict: ${conflict.label}`);
lines.push(``);
lines.push(`### File Version`);
lines.push(`\`\`\``);
lines.push(conflict.fileContent);
lines.push(`\`\`\``);
lines.push(``);
lines.push(`### Block Version`);
lines.push(`\`\`\``);
lines.push(conflict.blockContent);
lines.push(`\`\`\``);
lines.push(``);
lines.push(`---`);
lines.push(``);
}
if (metadataOnly.length > 0) {
lines.push(`## Metadata-only Changes`);
lines.push(``);
lines.push(
`Frontmatter changed while body content stayed the same (file wins).`,
);
lines.push(``);
for (const change of metadataOnly) {
lines.push(`### ${change.label}`);
lines.push(``);
lines.push(`#### File Version (with frontmatter)`);
lines.push(`\`\`\``);
lines.push(change.fileContent);
lines.push(`\`\`\``);
lines.push(``);
lines.push(`#### Block Version (body only)`);
lines.push(`\`\`\``);
lines.push(change.blockContent);
lines.push(`\`\`\``);
lines.push(``);
lines.push(`---`);
lines.push(``);
}
}
return lines.join("\n");
}
export async function runMemfsSubcommand(argv: string[]): Promise<number> {
let parsed: ReturnType<typeof parseArgs>;
try {
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 <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 as string | undefined;
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}. 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;
}

View File

@@ -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 <text> [options]
letta messages list [options]
letta messages start-conversation --agent <id> --message "<text>"
letta messages continue-conversation --conversation-id <id> --message "<text>"
Search options:
--query <text> Search query (required)
--mode <mode> Search mode: vector, fts, hybrid (default: hybrid)
--start-date <date> Filter messages after this date (ISO format)
--end-date <date> Filter messages before this date (ISO format)
--limit <n> Max results (default: 10)
--all-agents Search all agents, not just current agent
--agent <id> Explicit agent ID (overrides LETTA_AGENT_ID)
--agent-id <id> Alias for --agent
List options:
--agent <id> Agent ID (overrides LETTA_AGENT_ID)
--agent-id <id> Alias for --agent
--after <message-id> Cursor: get messages after this ID
--before <message-id> Cursor: get messages before this ID
--order <asc|desc> Sort order (default: desc = newest first)
--limit <n> Max results (default: 20)
--start-date <date> Client-side filter: after this date (ISO format)
--end-date <date> Client-side filter: before this date (ISO format)
Conversation options:
--agent <id> Target agent ID (start-conversation)
--message <text> Message to send
--conversation-id <id> Existing conversation ID (continue-conversation)
--timeout <ms> 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<unknown>,
): Promise<string> {
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<number> {
let parsed: ReturnType<typeof parseArgs>;
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 <text>.");
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 <text>.");
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 <conversation-id>.");
return 1;
}
const message = parsed.values.message;
if (!message || typeof message !== "string") {
console.error("Missing required --message <text>.");
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;
}

View File

@@ -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<number | null> {
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;
}
}