From 2f21893ef50bc87a98999fb54eebf26d079d2410 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Mon, 15 Dec 2025 11:13:43 -0800 Subject: [PATCH] feat: profile-based persistence with startup selector (#212) Co-authored-by: Letta --- src/agent/create.ts | 151 +---------- src/cli/App.tsx | 147 +++++++---- src/cli/commands/profile.ts | 171 +++++++++++- src/cli/commands/registry.ts | 28 +- src/cli/components/CommandPreview.tsx | 22 +- src/cli/components/ProfileSelector.tsx | 124 ++++----- src/cli/components/ResumeSelector.tsx | 2 +- src/cli/components/WelcomeScreen.tsx | 44 +--- src/cli/profile-selection.tsx | 349 +++++++++++++++++++++++++ src/headless.ts | 2 - src/index.ts | 97 ++++--- src/settings-manager.ts | 213 ++++++++++++++- src/tests/agent/init-blocks.test.ts | 103 -------- 13 files changed, 957 insertions(+), 496 deletions(-) create mode 100644 src/cli/profile-selection.tsx delete mode 100644 src/tests/agent/init-blocks.test.ts diff --git a/src/agent/create.ts b/src/agent/create.ts index 51808ed..39ea618 100644 --- a/src/agent/create.ts +++ b/src/agent/create.ts @@ -7,14 +7,9 @@ import type { AgentState, AgentType, } from "@letta-ai/letta-client/resources/agents/agents"; -import type { - BlockResponse, - CreateBlock, -} from "@letta-ai/letta-client/resources/blocks/blocks"; -import { settingsManager } from "../settings-manager"; import { getToolNames } from "../tools/manager"; import { getClient } from "./client"; -import { getDefaultMemoryBlocks, isProjectBlock } from "./memory"; +import { getDefaultMemoryBlocks } from "./memory"; import { formatAvailableModels, getModelUpdateArgs, @@ -38,7 +33,6 @@ export interface BlockProvenance { */ export interface AgentProvenance { isNew: true; - freshBlocks: boolean; blocks: BlockProvenance[]; } @@ -55,7 +49,6 @@ export async function createAgent( model?: string, embeddingModel = "openai/text-embedding-3-small", updateArgs?: Record, - forceNewBlocks = false, skillsDirectory?: string, parallelToolCalls = true, enableSleeptime = false, @@ -156,9 +149,6 @@ export async function createAgent( ? defaultMemoryBlocks.filter((b) => allowedBlockLabels.has(b.label)) : defaultMemoryBlocks; - // Cache the formatted skills block value so we can update an existing block - let skillsBlockValue: string | undefined; - // Resolve absolute path for skills directory const resolvedSkillsDirectory = skillsDirectory || join(process.cwd(), SKILLS_DIR); @@ -180,7 +170,6 @@ export async function createAgent( if (skillsBlock) { const formatted = formatSkillsForMemory(skills, resolvedSkillsDirectory); skillsBlock.value = formatted; - skillsBlockValue = formatted; } } catch (error) { console.warn( @@ -188,145 +177,26 @@ export async function createAgent( ); } - // Load global shared memory blocks from user settings - const settings = settingsManager.getSettings(); - const globalSharedBlockIds = settings.globalSharedBlockIds; - - // Load project-local shared blocks from project settings - await settingsManager.loadProjectSettings(); - const projectSettings = settingsManager.getProjectSettings(); - const localSharedBlockIds = projectSettings.localSharedBlockIds; - - // Retrieve existing blocks (both global and local) and match them with defaults - const existingBlocks = new Map(); - // Track provenance: which blocks came from which source + // Track provenance: which blocks were created + // Note: We no longer reuse shared blocks - each agent gets fresh blocks const blockProvenance: BlockProvenance[] = []; - const globalBlockLabels = new Set(); - const projectBlockLabels = new Set(); - - // Only load existing blocks if we're not forcing new blocks - if (!forceNewBlocks) { - // Load global blocks (persona, human) - for (const [label, blockId] of Object.entries(globalSharedBlockIds)) { - if (allowedBlockLabels && !allowedBlockLabels.has(label)) { - continue; - } - try { - const block = await client.blocks.retrieve(blockId); - existingBlocks.set(label, block); - globalBlockLabels.add(label); - } catch { - // Block no longer exists, will create new one - console.warn( - `Global block ${label} (${blockId}) not found, will create new one`, - ); - } - } - - // Load local blocks (project, skills) - for (const [label, blockId] of Object.entries(localSharedBlockIds)) { - if (allowedBlockLabels && !allowedBlockLabels.has(label)) { - continue; - } - try { - const block = await client.blocks.retrieve(blockId); - existingBlocks.set(label, block); - projectBlockLabels.add(label); - } catch { - // Block no longer exists, will create new one - console.warn( - `Local block ${label} (${blockId}) not found, will create new one`, - ); - } - } - } - - // Separate blocks into existing (reuse) and new (create) const blockIds: string[] = []; - const blocksToCreate: Array<{ block: CreateBlock; label: string }> = []; - for (const defaultBlock of filteredMemoryBlocks) { - const existingBlock = existingBlocks.get(defaultBlock.label); - if (existingBlock?.id) { - // Reuse existing global/shared block, but refresh skills content if it changed - if (defaultBlock.label === "skills" && skillsBlockValue !== undefined) { - try { - await client.blocks.update(existingBlock.id, { - value: skillsBlockValue, - }); - } catch (error) { - console.warn( - `Failed to update skills block ${existingBlock.id}:`, - error instanceof Error ? error.message : String(error), - ); - } - } - blockIds.push(existingBlock.id); - // Record provenance based on where it came from - if (globalBlockLabels.has(defaultBlock.label)) { - blockProvenance.push({ label: defaultBlock.label, source: "global" }); - } else if (projectBlockLabels.has(defaultBlock.label)) { - blockProvenance.push({ label: defaultBlock.label, source: "project" }); - } - } else { - // Need to create this block - blocksToCreate.push({ - block: defaultBlock, - label: defaultBlock.label, - }); - } - } - - // Create new blocks and collect their IDs - const newGlobalBlockIds: Record = {}; - const newLocalBlockIds: Record = {}; - - for (const { block, label } of blocksToCreate) { + // Create all blocks fresh for the new agent + for (const block of filteredMemoryBlocks) { try { const createdBlock = await client.blocks.create(block); if (!createdBlock.id) { - throw new Error(`Created block ${label} has no ID`); + throw new Error(`Created block ${block.label} has no ID`); } blockIds.push(createdBlock.id); - - // Categorize based on block type defined in memory.ts - if (isProjectBlock(label)) { - newLocalBlockIds[label] = createdBlock.id; - } else { - newGlobalBlockIds[label] = createdBlock.id; - } - - // Record as newly created - blockProvenance.push({ label, source: "new" }); + blockProvenance.push({ label: block.label, source: "new" }); } catch (error) { - console.error(`Failed to create block ${label}:`, error); + console.error(`Failed to create block ${block.label}:`, error); throw error; } } - // Save newly created global block IDs to user settings - if (Object.keys(newGlobalBlockIds).length > 0) { - settingsManager.updateSettings({ - globalSharedBlockIds: { - ...globalSharedBlockIds, - ...newGlobalBlockIds, - }, - }); - } - - // Save newly created local block IDs to project settings - if (Object.keys(newLocalBlockIds).length > 0) { - settingsManager.updateProjectSettings( - { - localSharedBlockIds: { - ...localSharedBlockIds, - ...newLocalBlockIds, - }, - }, - process.cwd(), - ); - } - // Get the model's context window from its configuration const modelUpdateArgs = getModelUpdateArgs(modelHandle); const contextWindow = (modelUpdateArgs?.context_window as number) || 200_000; @@ -371,8 +241,8 @@ export async function createAgent( include: ["agent.managed_group"], }); - // Update persona block for sleeptime agent (only if persona was newly created, not shared) - if (enableSleeptime && newGlobalBlockIds.persona && fullAgent.managed_group) { + // Update persona block for sleeptime agent + if (enableSleeptime && fullAgent.managed_group) { // Find the sleeptime agent in the managed group by checking agent_type for (const groupAgentId of fullAgent.managed_group.agent_ids) { try { @@ -399,7 +269,6 @@ export async function createAgent( // Build provenance info const provenance: AgentProvenance = { isNew: true, - freshBlocks: forceNewBlocks, blocks: blockProvenance, }; diff --git a/src/cli/App.tsx b/src/cli/App.tsx index acb1759..54b3075 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -23,6 +23,7 @@ import { SessionStats } from "../agent/stats"; import type { ApprovalContext } from "../permissions/analyzer"; import { permissionMode } from "../permissions/mode"; import { updateProjectSettings } from "../settings"; +import { settingsManager } from "../settings-manager"; import type { ToolExecutionResult } from "../tools/manager"; import { analyzeToolApproval, @@ -32,9 +33,11 @@ import { } from "../tools/manager"; import { addCommandResult, + handlePin, handleProfileDelete, handleProfileSave, handleProfileUsage, + handleUnpin, type ProfileCommandContext, validateProfileLoad, } from "./commands/profile"; @@ -568,23 +571,27 @@ export default function App({ // Use backfillBuffers to properly populate the transcript from history backfillBuffers(buffersRef.current, messageHistory); - // Inject "showing N messages" status at the START of backfilled history - // Add status line showing resumed agent info - const backfillStatusId = `status-backfill-${Date.now().toString(36)}`; + // Add combined status at the END so user sees it without scrolling + const statusId = `status-resumed-${Date.now().toString(36)}`; + const cwd = process.cwd(); + const shortCwd = cwd.startsWith(process.env.HOME || "") + ? `~${cwd.slice((process.env.HOME || "").length)}` + : cwd; const agentUrl = agentState?.id ? `https://app.letta.com/agents/${agentState.id}` : null; - const backfillLines = [ - "Resumed agent", + const statusLines = [ + `Connecting to last used agent in ${shortCwd}`, + agentState?.name ? `→ Agent: ${agentState.name}` : "", agentUrl ? `→ ${agentUrl}` : "", + "→ Use /pinned or /resume to switch agents", ].filter(Boolean); - buffersRef.current.byId.set(backfillStatusId, { + buffersRef.current.byId.set(statusId, { kind: "status", - id: backfillStatusId, - lines: backfillLines, + id: statusId, + lines: statusLines, }); - // Insert at the beginning of the order array - buffersRef.current.order.unshift(backfillStatusId); + buffersRef.current.order.push(statusId); refreshDerived(); commitEligibleLines(buffersRef.current); @@ -1269,21 +1276,18 @@ export default function App({ }, [streaming]); const handleAgentSelect = useCallback( - async (targetAgentId: string, opts?: { profileName?: string }) => { + async (targetAgentId: string, _opts?: { profileName?: string }) => { setAgentSelectorOpen(false); // Skip if already on this agent if (targetAgentId === agentId) { - const isProfileLoad = !!opts?.profileName; - const label = isProfileLoad ? opts.profileName : targetAgentId; + const label = agentName || targetAgentId.slice(0, 12); const cmdId = uid("cmd"); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, - input: isProfileLoad - ? `/profile load ${opts.profileName}` - : `/resume ${targetAgentId}`, - output: `Already on "${agentName || label}"`, + input: "/pinned", + output: `Already on "${label}"`, phase: "finished", success: true, }); @@ -1292,10 +1296,7 @@ export default function App({ return; } - const isProfileLoad = !!opts?.profileName; - const inputCmd = isProfileLoad - ? `/profile load ${opts.profileName}` - : `/resume ${targetAgentId}`; + const inputCmd = "/pinned"; setCommandRunning(true); @@ -1328,9 +1329,7 @@ export default function App({ // Build success command const agentUrl = `https://app.letta.com/projects/default-project/agents/${targetAgentId}`; - const successOutput = isProfileLoad - ? `Loaded "${agent.name || targetAgentId}"\n⎿ ${agentUrl}` - : `Resumed "${agent.name || targetAgentId}"\n⎿ ${agentUrl}`; + const successOutput = `Resumed "${agent.name || targetAgentId}"\n⎿ ${agentUrl}`; const successItem: StaticItem = { kind: "command", id: uid("cmd"), @@ -1882,7 +1881,7 @@ export default function App({ } // Special handling for /resume command - show session resume selector - if (msg.trim() === "/resume") { + if (msg.trim() === "/agents" || msg.trim() === "/resume") { setResumeSelectorOpen(true); return { submitted: true }; } @@ -1903,6 +1902,7 @@ export default function App({ buffersRef, refreshDerived, agentId, + agentName: agentName || "", setCommandRunning, setAgentName, }; @@ -1968,6 +1968,42 @@ export default function App({ return { submitted: true }; } + // Special handling for /profiles and /pinned commands - open pinned agents selector + if (msg.trim() === "/profiles" || msg.trim() === "/pinned") { + setProfileSelectorOpen(true); + return { submitted: true }; + } + + // Special handling for /pin command - pin current agent to project (or globally with -g) + if (msg.trim() === "/pin" || msg.trim().startsWith("/pin ")) { + const profileCtx: ProfileCommandContext = { + buffersRef, + refreshDerived, + agentId, + agentName: agentName || "", + setCommandRunning, + setAgentName, + }; + const argsStr = msg.trim().slice(4).trim(); + await handlePin(profileCtx, msg, argsStr); + return { submitted: true }; + } + + // Special handling for /unpin command - unpin current agent from project (or globally with -g) + if (msg.trim() === "/unpin" || msg.trim().startsWith("/unpin ")) { + const profileCtx: ProfileCommandContext = { + buffersRef, + refreshDerived, + agentId, + agentName: agentName || "", + setCommandRunning, + setAgentName, + }; + const argsStr = msg.trim().slice(6).trim(); + handleUnpin(profileCtx, msg, argsStr); + return { submitted: true }; + } + // Special handling for /bashes command - show background shell processes if (msg.trim() === "/bashes") { const { backgroundProcesses } = await import( @@ -2511,6 +2547,7 @@ ${recentCommits} processConversation, refreshDerived, agentId, + agentName, handleExit, isExecutingTool, queuedApprovalResults, @@ -3468,8 +3505,21 @@ Plan file path: ${planFilePath}`; agentState, agentProvenance, ); + // For resumed agents, show the agent name if it has one (profile name) + const resumedMessage = continueSession + ? agentState?.name + ? `Resumed **${agentState.name}**` + : "Resumed agent" + : "Created a new agent (use /pin to save, /pinned or /resume to switch)"; + + const agentNameLine = + !continueSession && agentState?.name + ? `→ Agent: ${agentState.name} (use /name to rename)` + : ""; + const statusLines = [ - continueSession ? "Resumed agent" : "Created new agent", + resumedMessage, + agentNameLine, agentUrl ? `→ ${agentUrl}` : "", ...hints, ].filter(Boolean); @@ -3663,39 +3713,24 @@ Plan file path: ${planFilePath}`; {profileSelectorOpen && ( { + onSelect={async (id) => { setProfileSelectorOpen(false); - await handleAgentSelect(id, { profileName }); + await handleAgentSelect(id); }} - onSave={async (profileName) => { + onUnpin={(unpinAgentId) => { setProfileSelectorOpen(false); - const profileCtx: ProfileCommandContext = { - buffersRef, - refreshDerived, - agentId, - setCommandRunning, - setAgentName, - }; - await handleProfileSave( - profileCtx, - `/profile save ${profileName}`, - profileName, - ); - }} - onDelete={(profileName) => { - setProfileSelectorOpen(false); - const profileCtx: ProfileCommandContext = { - buffersRef, - refreshDerived, - agentId, - setCommandRunning, - setAgentName, - }; - handleProfileDelete( - profileCtx, - `/profile delete ${profileName}`, - profileName, - ); + settingsManager.unpinBoth(unpinAgentId); + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: "/pinned", + output: `Unpinned agent ${unpinAgentId.slice(0, 12)}`, + phase: "finished", + success: true, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); }} onCancel={() => setProfileSelectorOpen(false)} /> diff --git a/src/cli/commands/profile.ts b/src/cli/commands/profile.ts index ffbe01d..fed4ee3 100644 --- a/src/cli/commands/profile.ts +++ b/src/cli/commands/profile.ts @@ -19,6 +19,7 @@ export interface ProfileCommandContext { buffersRef: { current: Buffers }; refreshDerived: () => void; agentId: string; + agentName: string; setCommandRunning: (running: boolean) => void; setAgentName: (name: string) => void; } @@ -69,10 +70,15 @@ export function updateCommandResult( refreshDerived(); } -// Get profiles from local settings +// Get all profiles (merged from global + local, local takes precedence) export function getProfiles(): Record { - const localSettings = settingsManager.getLocalProjectSettings(); - return localSettings.profiles || {}; + const merged = settingsManager.getMergedProfiles(); + // Convert array format back to Record + const result: Record = {}; + for (const profile of merged) { + result[profile.name] = profile.agentId; + } + return result; } // Check if a profile exists, returns error message if not found @@ -159,19 +165,15 @@ export async function handleProfileSave( await client.agents.update(ctx.agentId, { name: profileName }); ctx.setAgentName(profileName); - // Save profile to local settings - const profiles = getProfiles(); - const updatedProfiles = { ...profiles, [profileName]: ctx.agentId }; - settingsManager.updateLocalProjectSettings({ - profiles: updatedProfiles, - }); + // Save profile to BOTH local and global settings + settingsManager.saveProfile(profileName, ctx.agentId); updateCommandResult( ctx.buffersRef, ctx.refreshDerived, cmdId, msg, - `Saved profile "${profileName}" (agent ${ctx.agentId})`, + `Pinned "${profileName}" locally and globally.`, true, ); } catch (error) { @@ -294,3 +296,152 @@ export function handleProfileUsage( false, ); } + +// Parse /pin or /unpin args: [-l|--local] [name] +// Default is global, use -l for local-only +function parsePinArgs(argsStr: string): { local: boolean; name?: string } { + const parts = argsStr.trim().split(/\s+/).filter(Boolean); + let local = false; + let name: string | undefined; + + for (const part of parts) { + if (part === "-l" || part === "--local") { + local = true; + } else if (!name) { + name = part; + } + } + + return { local, name }; +} + +// /pin [-l] [name] - Pin the current agent globally (or locally with -l) +// If name is provided, renames the agent first +export async function handlePin( + ctx: ProfileCommandContext, + msg: string, + argsStr: string, +): Promise { + const { local, name } = parsePinArgs(argsStr); + const localPinned = settingsManager.getLocalPinnedAgents(); + const globalPinned = settingsManager.getGlobalPinnedAgents(); + + // If user provided a name, rename the agent first + if (name && name !== ctx.agentName) { + try { + const { getClient } = await import("../../agent/client"); + const client = await getClient(); + await client.agents.update(ctx.agentId, { name }); + ctx.setAgentName(name); + } catch (error) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + `Failed to rename agent: ${error}`, + false, + ); + return; + } + } + + const displayName = name || ctx.agentName || ctx.agentId.slice(0, 12); + + if (local) { + // Pin locally only + if (localPinned.includes(ctx.agentId)) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "This agent is already pinned to this project.", + false, + ); + return; + } + settingsManager.pinLocal(ctx.agentId); + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + `Pinned "${displayName}" to this project.`, + true, + ); + } else { + // Pin globally (default) + if (globalPinned.includes(ctx.agentId)) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "This agent is already pinned globally.", + false, + ); + return; + } + settingsManager.pinGlobal(ctx.agentId); + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + `Pinned "${displayName}" globally.`, + true, + ); + } +} + +// /unpin [-l] - Unpin the current agent globally (or locally with -l) +export function handleUnpin( + ctx: ProfileCommandContext, + msg: string, + argsStr: string, +): void { + const { local } = parsePinArgs(argsStr); + const localPinned = settingsManager.getLocalPinnedAgents(); + const globalPinned = settingsManager.getGlobalPinnedAgents(); + const displayName = ctx.agentName || ctx.agentId.slice(0, 12); + + if (local) { + // Unpin locally only + if (!localPinned.includes(ctx.agentId)) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "This agent isn't pinned to this project.", + false, + ); + return; + } + + settingsManager.unpinLocal(ctx.agentId); + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + `Unpinned "${displayName}" from this project.`, + true, + ); + } else { + // Unpin globally (default) + if (!globalPinned.includes(ctx.agentId)) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "This agent isn't pinned globally.", + false, + ); + return; + } + + settingsManager.unpinGlobal(ctx.agentId); + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + `Unpinned "${displayName}" globally.`, + true, + ); + } +} diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index 6f586ab..b720fa7 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -122,10 +122,10 @@ export const commands: Record = { }, }, "/resume": { - desc: "Resume a previous agent session", + desc: "Browse and switch to another agent", handler: () => { - // Handled specially in App.tsx to show resume selector - return "Opening session selector..."; + // Handled specially in App.tsx to show agent selector + return "Opening agent selector..."; }, }, "/search": { @@ -135,11 +135,25 @@ export const commands: Record = { return "Opening message search..."; }, }, - "/profile": { - desc: "Manage local profiles (save/load/delete)", + "/pin": { + desc: "Pin current agent globally (use -l for local only)", handler: () => { - // Handled specially in App.tsx for profile management - return "Managing profiles..."; + // Handled specially in App.tsx + return "Pinning agent..."; + }, + }, + "/unpin": { + desc: "Unpin current agent globally (use -l for local only)", + handler: () => { + // Handled specially in App.tsx + return "Unpinning agent..."; + }, + }, + "/pinned": { + desc: "Show pinned agents", + handler: () => { + // Handled specially in App.tsx to open pinned agents selector + return "Opening pinned agents..."; }, }, }; diff --git a/src/cli/components/CommandPreview.tsx b/src/cli/components/CommandPreview.tsx index 5cf594e..40fd546 100644 --- a/src/cli/components/CommandPreview.tsx +++ b/src/cli/components/CommandPreview.tsx @@ -1,7 +1,7 @@ import { Box, Text } from "ink"; import Link from "ink-link"; import { useMemo } from "react"; -import { getProfiles } from "../commands/profile"; +import { settingsManager } from "../../settings-manager"; import { commands } from "../commands/registry"; import { colors } from "./colors"; @@ -26,14 +26,12 @@ export function CommandPreview({ agentName?: string | null; serverUrl?: string; }) { - // Look up if current agent is saved as a profile - const profileName = useMemo(() => { - if (!agentId) return null; - const profiles = getProfiles(); - for (const [name, id] of Object.entries(profiles)) { - if (id === agentId) return name; - } - return null; + // Check if current agent is pinned + const isPinned = useMemo(() => { + if (!agentId) return false; + const localPinned = settingsManager.getLocalPinnedAgents(); + const globalPinned = settingsManager.getGlobalPinnedAgents(); + return localPinned.includes(agentId) || globalPinned.includes(agentId); }, [agentId]); if (!currentInput.startsWith("/")) { @@ -62,10 +60,10 @@ export function CommandPreview({ Current agent: {agentName || "Unnamed"} - {profileName ? ( - (profile: {profileName} ✓) + {isPinned ? ( + (pinned ✓) ) : ( - (type /profile to pin agent) + (type /pin to pin agent) )} diff --git a/src/cli/components/ProfileSelector.tsx b/src/cli/components/ProfileSelector.tsx index 59ea5c3..79fc86f 100644 --- a/src/cli/components/ProfileSelector.tsx +++ b/src/cli/components/ProfileSelector.tsx @@ -2,15 +2,14 @@ import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents" import { Box, Text, useInput } from "ink"; import { memo, useCallback, useEffect, useState } from "react"; import { getClient } from "../../agent/client"; -import { getProfiles } from "../commands/profile"; +import { settingsManager } from "../../settings-manager"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; interface ProfileSelectorProps { currentAgentId: string; - onSelect: (agentId: string, profileName: string) => void; - onSave: (profileName: string) => void; - onDelete: (profileName: string) => void; + onSelect: (agentId: string) => void; + onUnpin: (agentId: string) => void; onCancel: () => void; } @@ -19,6 +18,7 @@ interface ProfileData { agentId: string; agent: AgentState | null; error: string | null; + isPinned: boolean; } const DISPLAY_PAGE_SIZE = 5; @@ -71,13 +71,12 @@ function formatModel(agent: AgentState): string { return "unknown"; } -type Mode = "browsing" | "saving" | "confirming-delete"; +type Mode = "browsing" | "confirming-delete"; export const ProfileSelector = memo(function ProfileSelector({ currentAgentId, onSelect, - onSave, - onDelete, + onUnpin, onCancel, }: ProfileSelectorProps) { const terminalWidth = useTerminalWidth(); @@ -86,17 +85,16 @@ export const ProfileSelector = memo(function ProfileSelector({ const [selectedIndex, setSelectedIndex] = useState(0); const [currentPage, setCurrentPage] = useState(0); const [mode, setMode] = useState("browsing"); - const [saveInput, setSaveInput] = useState(""); const [deleteConfirmIndex, setDeleteConfirmIndex] = useState(0); - // Load profiles and fetch agent data + // Load pinned agents and fetch agent data const loadProfiles = useCallback(async () => { setLoading(true); try { - const profilesMap = getProfiles(); - const profileNames = Object.keys(profilesMap).sort(); + const mergedPinned = settingsManager.getMergedPinnedAgents(); + const localPinned = settingsManager.getLocalPinnedAgents(); - if (profileNames.length === 0) { + if (mergedPinned.length === 0) { setProfiles([]); setLoading(false); return; @@ -104,16 +102,23 @@ export const ProfileSelector = memo(function ProfileSelector({ const client = await getClient(); - // Fetch agent data for each profile - const profileDataPromises = profileNames.map(async (name) => { - const agentId = profilesMap[name] as string; + // Fetch agent data for each pinned agent + const profileDataPromises = mergedPinned.map(async ({ agentId }) => { + const isPinned = localPinned.includes(agentId); try { const agent = await client.agents.retrieve(agentId, { include: ["agent.blocks"], }); - return { name, agentId, agent, error: null }; + // Use agent name from server + return { name: agent.name, agentId, agent, error: null, isPinned }; } catch (_err) { - return { name, agentId, agent: null, error: "Agent not found" }; + return { + name: agentId.slice(0, 12), + agentId, + agent: null, + error: "Agent not found", + isPinned, + }; } }); @@ -144,31 +149,14 @@ export const ProfileSelector = memo(function ProfileSelector({ useInput((input, key) => { if (loading) return; - // Handle save mode - capture text input inline (like ResumeSelector) - if (mode === "saving") { - if (key.return && saveInput.trim()) { - // onSave closes the selector - onSave(saveInput.trim()); - return; - } else if (key.escape) { - setMode("browsing"); - setSaveInput(""); - } else if (key.backspace || key.delete) { - setSaveInput((prev) => prev.slice(0, -1)); - } else if (input && !key.ctrl && !key.meta) { - setSaveInput((prev) => prev + input); - } - return; - } - // Handle delete confirmation mode if (mode === "confirming-delete") { if (key.upArrow || key.downArrow) { setDeleteConfirmIndex((prev) => (prev === 0 ? 1 : 0)); } else if (key.return) { if (deleteConfirmIndex === 0 && selectedProfile) { - // Yes - delete (onDelete closes the selector) - onDelete(selectedProfile.name); + // Yes - unpin (onUnpin closes the selector) + onUnpin(selectedProfile.agentId); return; } else { // No - cancel @@ -187,13 +175,10 @@ export const ProfileSelector = memo(function ProfileSelector({ setSelectedIndex((prev) => Math.min(pageProfiles.length - 1, prev + 1)); } else if (key.return) { if (selectedProfile?.agent) { - onSelect(selectedProfile.agentId, selectedProfile.name); + onSelect(selectedProfile.agentId); } } else if (key.escape) { onCancel(); - } else if (input === "s" || input === "S") { - setMode("saving"); - setSaveInput(""); } else if (input === "d" || input === "D") { if (selectedProfile) { setMode("confirming-delete"); @@ -211,44 +196,35 @@ export const ProfileSelector = memo(function ProfileSelector({ setCurrentPage((prev) => prev + 1); setSelectedIndex(0); } + } else if (input === "p" || input === "P") { + if (selectedProfile) { + // Toggle pin/unpin for selected profile + if (selectedProfile.isPinned) { + settingsManager.unpinLocal(selectedProfile.agentId); + } else { + settingsManager.pinLocal(selectedProfile.agentId); + } + } else { + // No profiles - pin the current agent + settingsManager.pinLocal(currentAgentId); + } + // Reload profiles to reflect change + loadProfiles(); } }); - // Save mode UI - if (mode === "saving") { - return ( - - - - Save Current Agent as Profile - - - - Enter profile name (Esc to cancel): - - > - {saveInput} - - - - - ); - } - - // Delete confirmation UI + // Unpin confirmation UI if (mode === "confirming-delete" && selectedProfile) { - const options = ["Yes, delete", "No, cancel"]; + const options = ["Yes, unpin", "No, cancel"]; return ( - Delete Profile + Unpin Agent - - Are you sure you want to delete profile "{selectedProfile.name}"? - + Unpin "{selectedProfile.name}" from all locations? {options.map((option, index) => { @@ -276,22 +252,22 @@ export const ProfileSelector = memo(function ProfileSelector({ - Profiles + Pinned Agents {/* Loading state */} {loading && ( - Loading profiles... + Loading pinned agents... )} {/* Empty state */} {!loading && profiles.length === 0 && ( - No profiles saved. - Press S to save the current agent as a profile. + No agents pinned. + Press P to pin the current agent. Esc to close @@ -335,6 +311,9 @@ export const ProfileSelector = memo(function ProfileSelector({ > {profile.name} + {profile.isPinned && ( + (pinned) + )} · {displayId} {isCurrent && ( (current) @@ -381,8 +360,7 @@ export const ProfileSelector = memo(function ProfileSelector({ )} - ↑↓ navigate · Enter load · S save · D delete · J/K page · Esc - close + ↑↓ navigate · Enter load · P pin/unpin · D unpin all · Esc close diff --git a/src/cli/components/ResumeSelector.tsx b/src/cli/components/ResumeSelector.tsx index 1d9722d..53ecd7b 100644 --- a/src/cli/components/ResumeSelector.tsx +++ b/src/cli/components/ResumeSelector.tsx @@ -237,7 +237,7 @@ export function ResumeSelector({ - Resume Session (showing most recent agents) + Browsing Agents (sorting by last run) diff --git a/src/cli/components/WelcomeScreen.tsx b/src/cli/components/WelcomeScreen.tsx index fadc840..3a642ea 100644 --- a/src/cli/components/WelcomeScreen.tsx +++ b/src/cli/components/WelcomeScreen.tsx @@ -84,46 +84,10 @@ export function getAgentStatusHints( return hints; } - // For new agents with provenance, show block sources - if (agentProvenance) { - // Blocks reused from existing storage - const reusedGlobalBlocks = agentProvenance.blocks - .filter((b) => b.source === "global") - .map((b) => b.label); - const reusedProjectBlocks = agentProvenance.blocks - .filter((b) => b.source === "project") - .map((b) => b.label); - - // New blocks - categorize by where they'll be stored - // (project/skills → .letta/, others → ~/.letta/) - const newBlocks = agentProvenance.blocks.filter((b) => b.source === "new"); - const newGlobalBlocks = newBlocks - .filter((b) => b.label !== "project" && b.label !== "skills") - .map((b) => b.label); - const newProjectBlocks = newBlocks - .filter((b) => b.label === "project" || b.label === "skills") - .map((b) => b.label); - - if (reusedGlobalBlocks.length > 0) { - hints.push( - `→ Reusing from global (~/.letta/): ${reusedGlobalBlocks.join(", ")}`, - ); - } - if (newGlobalBlocks.length > 0) { - hints.push( - `→ Created in global (~/.letta/): ${newGlobalBlocks.join(", ")}`, - ); - } - if (reusedProjectBlocks.length > 0) { - hints.push( - `→ Reusing from project (.letta/): ${reusedProjectBlocks.join(", ")}`, - ); - } - if (newProjectBlocks.length > 0) { - hints.push( - `→ Created in project (.letta/): ${newProjectBlocks.join(", ")}`, - ); - } + // For new agents, just show memory block labels + if (agentProvenance && agentProvenance.blocks.length > 0) { + const blockLabels = agentProvenance.blocks.map((b) => b.label).join(", "); + hints.push(`→ Memory blocks: ${blockLabels}`); } return hints; diff --git a/src/cli/profile-selection.tsx b/src/cli/profile-selection.tsx new file mode 100644 index 0000000..5378161 --- /dev/null +++ b/src/cli/profile-selection.tsx @@ -0,0 +1,349 @@ +/** + * Profile selection flow - runs before main app starts + * Similar pattern to auth/setup.ts + */ + +import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; +import { Box, Text, useInput } from "ink"; +import React, { useCallback, useEffect, useState } from "react"; +import { getClient } from "../agent/client"; +import { settingsManager } from "../settings-manager"; +import { colors } from "./components/colors"; +import { WelcomeScreen } from "./components/WelcomeScreen"; + +interface ProfileOption { + name: string | null; + agentId: string; + isLocal: boolean; + isLru: boolean; + agent: AgentState | null; +} + +interface ProfileSelectionResult { + type: "select" | "new" | "exit"; + agentId?: string; + profileName?: string | null; +} + +const MAX_DISPLAY = 3; + +function formatRelativeTime(dateStr: string | null | undefined): string { + if (!dateStr) return "Never"; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) + return `${diffMins} minute${diffMins === 1 ? "" : "s"} ago`; + if (diffHours < 24) + return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`; + if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; + return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) === 1 ? "" : "s"} ago`; +} + +function formatModel(agent: AgentState): string { + if (agent.model) { + const parts = agent.model.split("/"); + return parts[parts.length - 1] || agent.model; + } + return agent.llm_config?.model || "unknown"; +} + +function getLabel(option: ProfileOption): string { + const parts: string[] = []; + if (option.isLru) parts.push("last used"); + if (option.isLocal) parts.push("pinned"); + else if (!option.isLru) parts.push("global"); // Pinned globally but not locally + return parts.length > 0 ? ` (${parts.join(", ")})` : ""; +} + +function ProfileSelectionUI({ + lruAgentId, + externalLoading, + onComplete, +}: { + lruAgentId: string | null; + externalLoading?: boolean; + onComplete: (result: ProfileSelectionResult) => void; +}) { + const [options, setOptions] = useState([]); + const [internalLoading, setInternalLoading] = useState(true); + const loading = externalLoading || internalLoading; + const [selectedIndex, setSelectedIndex] = useState(0); + const [showAll, setShowAll] = useState(false); + + const loadOptions = useCallback(async () => { + setInternalLoading(true); + try { + const mergedPinned = settingsManager.getMergedPinnedAgents(); + const client = await getClient(); + + const optionsToFetch: ProfileOption[] = []; + const seenAgentIds = new Set(); + + // First: LRU agent + if (lruAgentId) { + const matchingPinned = mergedPinned.find( + (p) => p.agentId === lruAgentId, + ); + optionsToFetch.push({ + name: null, // Will be fetched from server + agentId: lruAgentId, + isLocal: matchingPinned?.isLocal || false, + isLru: true, + agent: null, + }); + seenAgentIds.add(lruAgentId); + } + + // Then: Other pinned agents + for (const pinned of mergedPinned) { + if (!seenAgentIds.has(pinned.agentId)) { + optionsToFetch.push({ + name: null, // Will be fetched from server + agentId: pinned.agentId, + isLocal: pinned.isLocal, + isLru: false, + agent: null, + }); + seenAgentIds.add(pinned.agentId); + } + } + + // Fetch agent data + const fetchedOptions = await Promise.all( + optionsToFetch.map(async (opt) => { + try { + const agent = await client.agents.retrieve(opt.agentId); + return { ...opt, agent }; + } catch { + return { ...opt, agent: null }; + } + }), + ); + + setOptions(fetchedOptions.filter((opt) => opt.agent !== null)); + } catch { + setOptions([]); + } finally { + setInternalLoading(false); + } + }, [lruAgentId]); + + useEffect(() => { + loadOptions(); + }, [loadOptions]); + + const displayOptions = showAll ? options : options.slice(0, MAX_DISPLAY); + const hasMore = options.length > MAX_DISPLAY; + const totalItems = displayOptions.length + 1 + (hasMore && !showAll ? 1 : 0); + + useInput((_input, key) => { + if (loading) return; + + if (key.upArrow) { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedIndex((prev) => Math.min(totalItems - 1, prev + 1)); + } else if (key.return) { + if (selectedIndex < displayOptions.length) { + const selected = displayOptions[selectedIndex]; + if (selected) { + onComplete({ + type: "select", + agentId: selected.agentId, + profileName: selected.name, + }); + } + } else if ( + hasMore && + !showAll && + selectedIndex === displayOptions.length + ) { + setShowAll(true); + setSelectedIndex(0); + } else { + onComplete({ type: "new" }); + } + } else if (key.escape) { + onComplete({ type: "exit" }); + } + }); + + const hasLocalDir = settingsManager.hasLocalLettaDir(); + const contextMessage = hasLocalDir + ? "Existing `.letta` folder detected." + : `${options.length} agent profile${options.length !== 1 ? "s" : ""} detected.`; + + return ( + + {/* Welcome Screen */} + + + + {loading ? ( + Loading pinned agents... + ) : ( + + {contextMessage} + Which agent would you like to use? + + + {displayOptions.map((option, index) => { + const isSelected = index === selectedIndex; + const displayName = + option.agent?.name || option.agentId.slice(0, 20); + const label = getLabel(option); + + return ( + + + + {isSelected ? "→ " : " "} + + + Resume{" "} + + + {displayName} + + {label} + + {option.agent && ( + + + {formatRelativeTime(option.agent.last_run_completion)} ·{" "} + {option.agent.memory?.blocks?.length || 0} memory blocks + · {formatModel(option.agent)} + + + )} + + ); + })} + + {hasMore && !showAll && ( + + + {selectedIndex === displayOptions.length ? "→ " : " "} + View all {options.length} profiles + + + )} + + + + {selectedIndex === totalItems - 1 ? "→ " : " "} + Create a new agent + + (--new) + + + + + ↑↓ navigate · Enter select · Esc exit + + + )} + + ); +} + +/** + * Inline profile selection component - used within LoadingApp + */ +export function ProfileSelectionInline({ + lruAgentId, + loading: externalLoading, + onSelect, + onCreateNew, + onExit, +}: { + lruAgentId: string | null; + loading?: boolean; + onSelect: (agentId: string) => void; + onCreateNew: () => void; + onExit: () => void; +}) { + const handleComplete = (result: ProfileSelectionResult) => { + if (result.type === "exit") { + onExit(); + } else if (result.type === "select" && result.agentId) { + onSelect(result.agentId); + } else { + onCreateNew(); + } + }; + + return React.createElement(ProfileSelectionUI, { + lruAgentId, + externalLoading, + onComplete: handleComplete, + }); +} + +/** + * Check if profile selection is needed + */ +export async function shouldShowProfileSelection( + forceNew: boolean, + agentIdArg: string | null, + fromAfFile: string | undefined, +): Promise<{ show: boolean; lruAgentId: string | null }> { + // Skip for explicit flags + if (forceNew || agentIdArg || fromAfFile) { + return { show: false, lruAgentId: null }; + } + + // Load settings + await settingsManager.loadLocalProjectSettings(); + const localSettings = settingsManager.getLocalProjectSettings(); + const globalProfiles = settingsManager.getGlobalProfiles(); + const localProfiles = localSettings.profiles || {}; + + const hasProfiles = + Object.keys(globalProfiles).length > 0 || + Object.keys(localProfiles).length > 0; + const lru = localSettings.lastAgent || null; + + // Show selector if there are choices + return { + show: hasProfiles || !!lru, + lruAgentId: lru, + }; +} diff --git a/src/headless.ts b/src/headless.ts index 6f25552..e051b9b 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -195,7 +195,6 @@ export async function handleHeadlessCommand( model, undefined, updateArgs, - forceNew, skillsDirectory, true, // parallelToolCalls always enabled sleeptimeFlag ?? settings.enableSleeptime, @@ -240,7 +239,6 @@ export async function handleHeadlessCommand( model, undefined, updateArgs, - false, skillsDirectory, true, // parallelToolCalls always enabled sleeptimeFlag ?? settings.enableSleeptime, diff --git a/src/index.ts b/src/index.ts index 3f94118..cea8f69 100755 --- a/src/index.ts +++ b/src/index.ts @@ -17,10 +17,8 @@ Letta Code is a general purpose CLI for interacting with Letta agents USAGE # interactive TUI - letta Auto-resume project agent (from .letta/settings.local.json) - letta --new Create a new agent (reuses global persona/human blocks) - letta --fresh-blocks Create a new agent with all new memory blocks - letta --continue Resume global last agent (deprecated, use project-based) + letta Resume from profile or create new agent (shows selector) + letta --new Create a new agent directly (skip profile selector) letta --agent Open a specific agent by ID # headless @@ -32,11 +30,9 @@ USAGE OPTIONS -h, --help Show this help and exit -v, --version Print version and exit - --new Create new agent (reuses global blocks like persona/human) - --fresh-blocks Force create all new memory blocks (isolate from other agents) + --new Create new agent directly (skip profile selection) --init-blocks Comma-separated memory blocks to initialize when using --new (e.g., "persona,skills") --base-tools Comma-separated base tools to attach when using --new (e.g., "memory,web_search,conversation_search") - -c, --continue Resume previous session (uses global lastAgent, deprecated) -a, --agent Use a specific agent ID -m, --model Model ID or handle (e.g., "opus-4.5" or "anthropic/claude-opus-4-5") -s, --system System prompt ID (e.g., "codex", "gpt-5.1", "review") @@ -48,30 +44,31 @@ OPTIONS --sleeptime Enable sleeptime memory management (only for new agents) --from-af Create agent from an AgentFile (.af) template - BEHAVIOR - By default, letta auto-resumes the last agent used in the current directory - (stored in .letta/settings.local.json). + On startup, Letta Code checks for saved profiles: + - If profiles exist, you'll be prompted to select one or create a new agent + - Profiles can be "pinned" to specific projects for quick access + - Use /profile save to bookmark your current agent - Memory blocks (persona, human, project, skills) are shared between agents: - - Global blocks (persona, human) are shared across all agents - - Local blocks (project, skills) are shared within the current directory - - Use --new to create a new agent that reuses your global persona/human blocks. - Use --fresh-blocks to create a completely isolated agent with new blocks. + Profiles are stored in: + - Global: ~/.letta/settings.json (available everywhere) + - Local: .letta/settings.local.json (pinned to project) If no credentials are configured, you'll be prompted to authenticate via Letta Cloud OAuth on first run. EXAMPLES # when installed as an executable - letta # Auto-resume project agent or create new - letta --new # New agent, keeps your persona/human blocks - letta --fresh-blocks # New agent, all blocks fresh (full isolation) - letta --agent agent_123 + letta # Show profile selector or create new + letta --new # Create new agent directly + letta --agent agent_123 # Open specific agent # inside the interactive session - /logout # Clear credentials and exit + /profile save MyAgent # Save current agent as profile + /profiles # Open profile selector + /pin # Pin current profile to project + /unpin # Unpin profile from project + /logout # Clear credentials and exit # headless with JSON output (includes stats) letta -p "hello" --output-format json @@ -126,7 +123,6 @@ async function main() { version: { type: "boolean", short: "v" }, continue: { type: "boolean", short: "c" }, new: { type: "boolean" }, - "fresh-blocks": { type: "boolean" }, "init-blocks": { type: "string" }, "base-tools": { type: "string" }, agent: { type: "string", short: "a" }, @@ -193,7 +189,6 @@ async function main() { const shouldContinue = (values.continue as boolean | undefined) ?? false; const forceNew = (values.new as boolean | undefined) ?? false; - const freshBlocks = (values["fresh-blocks"] as boolean | undefined) ?? false; const initBlocksRaw = values["init-blocks"] as string | undefined; const baseToolsRaw = values["base-tools"] as string | undefined; const specifiedAgentId = (values.agent as string | undefined) ?? null; @@ -453,7 +448,6 @@ async function main() { function LoadingApp({ continueSession, forceNew, - freshBlocks, initBlocks, baseTools, agentIdArg, @@ -465,7 +459,6 @@ async function main() { }: { continueSession: boolean; forceNew: boolean; - freshBlocks: boolean; initBlocks?: string[]; baseTools?: string[]; agentIdArg: string | null; @@ -476,6 +469,7 @@ async function main() { fromAfFile?: string; }) { const [loadingState, setLoadingState] = useState< + | "selecting" | "assembling" | "upserting" | "linking" @@ -484,7 +478,7 @@ async function main() { | "initializing" | "checking" | "ready" - >("assembling"); + >("selecting"); const [agentId, setAgentId] = useState(null); const [agentState, setAgentState] = useState(null); const [resumeData, setResumeData] = useState(null); @@ -492,9 +486,21 @@ async function main() { const [agentProvenance, setAgentProvenance] = useState(null); + // Initialize on mount - no selector, just start immediately useEffect(() => { - async function init() { + async function checkAndStart() { + // Load settings + await settingsManager.loadLocalProjectSettings(); setLoadingState("assembling"); + } + checkAndStart(); + }, []); + + // Main initialization effect - runs after profile selection + useEffect(() => { + if (loadingState !== "assembling") return; + + async function init() { const client = await getClient(); // Determine which agent we'll be using (before loading tools) @@ -510,10 +516,8 @@ async function main() { } } - // Priority 2: Skip resume if --new flag + // Priority 2: LRU from local settings (if not --new) if (!resumingAgentId && !forceNew) { - // Priority 3: Try project settings - await settingsManager.loadLocalProjectSettings(); const localProjectSettings = settingsManager.getLocalProjectSettings(); if (localProjectSettings?.lastAgent) { @@ -521,11 +525,11 @@ async function main() { await client.agents.retrieve(localProjectSettings.lastAgent); resumingAgentId = localProjectSettings.lastAgent; } catch { - // Agent no longer exists + // Agent no longer exists, will create new } } - // Priority 4: Try global settings if --continue flag + // Priority 3: Try global settings if --continue flag if (!resumingAgentId && continueSession && settings.lastAgent) { try { await client.agents.retrieve(settings.lastAgent); @@ -611,7 +615,6 @@ async function main() { agent = result.agent; setAgentProvenance({ isNew: true, - freshBlocks: true, blocks: [], }); } @@ -633,16 +636,14 @@ async function main() { } } - // Priority 2: Check if --new flag was passed (skip all resume logic) + // Priority 3: Check if --new flag was passed - create new agent if (!agent && forceNew) { - // Create new agent (reuses global blocks unless --fresh-blocks passed) const updateArgs = getModelUpdateArgs(model); const result = await createAgent( undefined, model, undefined, updateArgs, - freshBlocks, // Only create new blocks if --fresh-blocks passed skillsDirectory, true, // parallelToolCalls always enabled sleeptimeFlag ?? settings.enableSleeptime, @@ -654,7 +655,7 @@ async function main() { setAgentProvenance(result.provenance); } - // Priority 3: Try to resume from project settings (.letta/settings.local.json) + // Priority 4: Try to resume from project settings LRU (.letta/settings.local.json) if (!agent) { await settingsManager.loadLocalProjectSettings(); const localProjectSettings = @@ -673,7 +674,7 @@ async function main() { } } - // Priority 4: Try to reuse global lastAgent if --continue flag is passed + // Priority 6: Try to reuse global lastAgent if --continue flag is passed if (!agent && continueSession && settings.lastAgent) { try { agent = await client.agents.retrieve(settings.lastAgent); @@ -685,7 +686,7 @@ async function main() { } } - // Priority 5: Create a new agent + // Priority 7: Create a new agent if (!agent) { const updateArgs = getModelUpdateArgs(model); const result = await createAgent( @@ -693,7 +694,6 @@ async function main() { model, undefined, updateArgs, - false, // Don't force new blocks when auto-creating (reuse shared blocks) skillsDirectory, true, // parallelToolCalls always enabled sleeptimeFlag ?? settings.enableSleeptime, @@ -757,11 +757,7 @@ async function main() { } // Check if we're resuming an existing agent - const localProjectSettings = settingsManager.getLocalProjectSettings(); - const isResumingProject = - !forceNew && - localProjectSettings?.lastAgent && - agent.id === localProjectSettings.lastAgent; + const isResumingProject = !forceNew && !!resumingAgentId; const resuming = !!(continueSession || agentIdArg || isResumingProject); setIsResumingSession(resuming); @@ -781,17 +777,21 @@ async function main() { }, [ continueSession, forceNew, - freshBlocks, agentIdArg, model, system, fromAfFile, + loadingState, ]); + // Profile selector is no longer shown at startup + // Users can access it via /pinned or /agents commands + if (!agentId) { return React.createElement(App, { agentId: "loading", - loadingState, + loadingState: + loadingState === "selecting" ? "assembling" : loadingState, continueSession: isResumingSession, startupApproval: resumeData?.pendingApproval ?? null, startupApprovals: resumeData?.pendingApprovals ?? [], @@ -804,7 +804,7 @@ async function main() { return React.createElement(App, { agentId, agentState, - loadingState, + loadingState: loadingState === "selecting" ? "assembling" : loadingState, continueSession: isResumingSession, startupApproval: resumeData?.pendingApproval ?? null, startupApprovals: resumeData?.pendingApprovals ?? [], @@ -818,7 +818,6 @@ async function main() { React.createElement(LoadingApp, { continueSession: shouldContinue, forceNew: forceNew, - freshBlocks: freshBlocks, initBlocks: initBlocks, baseTools: baseTools, agentIdArg: specifiedAgentId, diff --git a/src/settings-manager.ts b/src/settings-manager.ts index 18f64d3..a80600d 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -10,7 +10,9 @@ export interface Settings { lastAgent: string | null; tokenStreaming: boolean; enableSleeptime: boolean; - globalSharedBlockIds: Record; + globalSharedBlockIds: Record; // DEPRECATED: kept for backwards compat + profiles?: Record; // DEPRECATED: old format, kept for migration + pinnedAgents?: string[]; // Array of agent IDs pinned globally permissions?: PermissionRules; env?: Record; // OAuth token management @@ -26,7 +28,8 @@ export interface ProjectSettings { export interface LocalProjectSettings { lastAgent: string | null; permissions?: PermissionRules; - profiles?: Record; // profileName -> agentId + profiles?: Record; // DEPRECATED: old format, kept for migration + pinnedAgents?: string[]; // Array of agent IDs pinned locally } const DEFAULT_SETTINGS: Settings = { @@ -384,6 +387,212 @@ class SettingsManager { } } + // ===================================================================== + // Profile Management Helpers + // ===================================================================== + + /** + * Get globally pinned agent IDs from ~/.letta/settings.json + * Migrates from old profiles format if needed. + */ + getGlobalPinnedAgents(): string[] { + const settings = this.getSettings(); + // Migrate from old format if needed + if (settings.profiles && !settings.pinnedAgents) { + const agentIds = Object.values(settings.profiles); + this.updateSettings({ pinnedAgents: agentIds, profiles: undefined }); + return agentIds; + } + return settings.pinnedAgents || []; + } + + /** + * Get locally pinned agent IDs from .letta/settings.local.json + * Migrates from old profiles format if needed. + */ + getLocalPinnedAgents(workingDirectory: string = process.cwd()): string[] { + const localSettings = this.getLocalProjectSettings(workingDirectory); + // Migrate from old format if needed + if (localSettings.profiles && !localSettings.pinnedAgents) { + const agentIds = Object.values(localSettings.profiles); + this.updateLocalProjectSettings( + { pinnedAgents: agentIds, profiles: undefined }, + workingDirectory, + ); + return agentIds; + } + return localSettings.pinnedAgents || []; + } + + /** + * Get merged pinned agents (local + global), deduped. + * Returns array of { agentId, isLocal }. + */ + getMergedPinnedAgents( + workingDirectory: string = process.cwd(), + ): Array<{ agentId: string; isLocal: boolean }> { + const globalAgents = this.getGlobalPinnedAgents(); + const localAgents = this.getLocalPinnedAgents(workingDirectory); + + const result: Array<{ agentId: string; isLocal: boolean }> = []; + const seenAgentIds = new Set(); + + // Add local agents first (they take precedence) + for (const agentId of localAgents) { + result.push({ agentId, isLocal: true }); + seenAgentIds.add(agentId); + } + + // Add global agents that aren't also local + for (const agentId of globalAgents) { + if (!seenAgentIds.has(agentId)) { + result.push({ agentId, isLocal: false }); + seenAgentIds.add(agentId); + } + } + + return result; + } + + // DEPRECATED: Keep for backwards compatibility + getGlobalProfiles(): Record { + return this.getSettings().profiles || {}; + } + + // DEPRECATED: Keep for backwards compatibility + getLocalProfiles( + workingDirectory: string = process.cwd(), + ): Record { + const localSettings = this.getLocalProjectSettings(workingDirectory); + return localSettings.profiles || {}; + } + + // DEPRECATED: Keep for backwards compatibility + getMergedProfiles( + workingDirectory: string = process.cwd(), + ): Array<{ name: string; agentId: string; isLocal: boolean }> { + const merged = this.getMergedPinnedAgents(workingDirectory); + return merged.map(({ agentId, isLocal }) => ({ + name: "", // Name will be fetched from server + agentId, + isLocal, + })); + } + + /** + * Pin an agent to both local AND global settings + */ + pinBoth(agentId: string, workingDirectory: string = process.cwd()): void { + // Update global + const globalAgents = this.getGlobalPinnedAgents(); + if (!globalAgents.includes(agentId)) { + this.updateSettings({ pinnedAgents: [...globalAgents, agentId] }); + } + + // Update local + const localAgents = this.getLocalPinnedAgents(workingDirectory); + if (!localAgents.includes(agentId)) { + this.updateLocalProjectSettings( + { pinnedAgents: [...localAgents, agentId] }, + workingDirectory, + ); + } + } + + // DEPRECATED: Keep for backwards compatibility + saveProfile( + _name: string, + agentId: string, + workingDirectory: string = process.cwd(), + ): void { + this.pinBoth(agentId, workingDirectory); + } + + /** + * Pin an agent locally (to this project) + */ + pinLocal(agentId: string, workingDirectory: string = process.cwd()): void { + const localAgents = this.getLocalPinnedAgents(workingDirectory); + if (!localAgents.includes(agentId)) { + this.updateLocalProjectSettings( + { pinnedAgents: [...localAgents, agentId] }, + workingDirectory, + ); + } + } + + /** + * Unpin an agent locally (from this project only) + */ + unpinLocal(agentId: string, workingDirectory: string = process.cwd()): void { + const localAgents = this.getLocalPinnedAgents(workingDirectory); + this.updateLocalProjectSettings( + { pinnedAgents: localAgents.filter((id) => id !== agentId) }, + workingDirectory, + ); + } + + /** + * Pin an agent globally + */ + pinGlobal(agentId: string): void { + const globalAgents = this.getGlobalPinnedAgents(); + if (!globalAgents.includes(agentId)) { + this.updateSettings({ pinnedAgents: [...globalAgents, agentId] }); + } + } + + /** + * Unpin an agent globally + */ + unpinGlobal(agentId: string): void { + const globalAgents = this.getGlobalPinnedAgents(); + this.updateSettings({ + pinnedAgents: globalAgents.filter((id) => id !== agentId), + }); + } + + /** + * Unpin an agent from both local and global settings + */ + unpinBoth(agentId: string, workingDirectory: string = process.cwd()): void { + this.unpinLocal(agentId, workingDirectory); + this.unpinGlobal(agentId); + } + + // DEPRECATED: Keep for backwards compatibility + deleteProfile( + _name: string, + _workingDirectory: string = process.cwd(), + ): void { + // This no longer makes sense with the new model + // Would need an agentId to unpin + console.warn("deleteProfile is deprecated, use unpinBoth(agentId) instead"); + } + + // DEPRECATED: Keep for backwards compatibility + pinProfile( + _name: string, + agentId: string, + workingDirectory: string = process.cwd(), + ): void { + this.pinLocal(agentId, workingDirectory); + } + + // DEPRECATED: Keep for backwards compatibility + unpinProfile(_name: string, _workingDirectory: string = process.cwd()): void { + // This no longer makes sense with the new model + console.warn("unpinProfile is deprecated, use unpinLocal(agentId) instead"); + } + + /** + * Check if local .letta directory exists (indicates existing project) + */ + hasLocalLettaDir(workingDirectory: string = process.cwd()): boolean { + const dirPath = join(workingDirectory, ".letta"); + return exists(dirPath); + } + /** * Wait for all pending writes to complete. * Useful in tests to ensure writes finish before cleanup. diff --git a/src/tests/agent/init-blocks.test.ts b/src/tests/agent/init-blocks.test.ts deleted file mode 100644 index 3ee9bb5..0000000 --- a/src/tests/agent/init-blocks.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { getClient } from "../../agent/client"; -import { createAgent } from "../../agent/create"; -import { settingsManager } from "../../settings-manager"; - -// Skip these integration tests if LETTA_API_KEY is not set -const shouldSkip = !process.env.LETTA_API_KEY; -const describeOrSkip = shouldSkip ? describe.skip : describe; - -describeOrSkip("createAgent init-blocks filtering", () => { - let originalGlobalSharedBlockIds: Record; - let originalLocalSharedBlockIds: Record; - let createdAgentId: string | null = null; - - beforeAll(async () => { - const apiKey = process.env.LETTA_API_KEY; - if (!apiKey) { - throw new Error("LETTA_API_KEY must be set to run this test"); - } - - await settingsManager.initialize(); - - const settings = settingsManager.getSettings(); - await settingsManager.loadProjectSettings(); - const projectSettings = settingsManager.getProjectSettings(); - - originalGlobalSharedBlockIds = { ...settings.globalSharedBlockIds }; - originalLocalSharedBlockIds = { ...projectSettings.localSharedBlockIds }; - }); - - afterAll(async () => { - const client = await getClient(); - - if (createdAgentId) { - try { - await client.agents.delete(createdAgentId); - } catch { - // Ignore cleanup errors - } - } - - // Restore original shared block mappings to avoid polluting user settings - settingsManager.updateSettings({ - globalSharedBlockIds: originalGlobalSharedBlockIds, - }); - settingsManager.updateProjectSettings( - { - localSharedBlockIds: originalLocalSharedBlockIds, - }, - process.cwd(), - ); - }); - - test( - "only requested memory blocks are created/registered", - async () => { - const { agent } = await createAgent( - "init-blocks-test", - undefined, - "openai/text-embedding-3-small", - undefined, - true, // force new blocks instead of reusing shared ones - undefined, - true, - false, - undefined, - ["persona", "skills"], - undefined, - ); - createdAgentId = agent.id; - - const settings = settingsManager.getSettings(); - await settingsManager.loadProjectSettings(); - const projectSettings = settingsManager.getProjectSettings(); - - const globalIds = settings.globalSharedBlockIds; - const localIds = projectSettings.localSharedBlockIds; - - // Requested blocks must be present - expect(globalIds.persona).toBeDefined(); - expect(localIds.skills).toBeDefined(); - - // No new GLOBAL shared blocks outside of the allowed set - const newGlobalLabels = Object.keys(globalIds).filter( - (label) => !(label in originalGlobalSharedBlockIds), - ); - const disallowedGlobalLabels = newGlobalLabels.filter( - (label) => label !== "persona", - ); - expect(disallowedGlobalLabels.length).toBe(0); - - // No new LOCAL shared blocks outside of the allowed set - const newLocalLabels = Object.keys(localIds).filter( - (label) => !(label in originalLocalSharedBlockIds), - ); - const disallowedLocalLabels = newLocalLabels.filter( - (label) => label !== "skills", - ); - expect(disallowedLocalLabels.length).toBe(0); - }, - { timeout: 90000 }, - ); -});