feat: profile-based persistence with startup selector (#212)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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<string, unknown>,
|
||||
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<string, BlockResponse>();
|
||||
// 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<string>();
|
||||
const projectBlockLabels = new Set<string>();
|
||||
|
||||
// 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<string, string> = {};
|
||||
const newLocalBlockIds: Record<string, string> = {};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
|
||||
147
src/cli/App.tsx
147
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 && (
|
||||
<ProfileSelector
|
||||
currentAgentId={agentId}
|
||||
onSelect={async (id, profileName) => {
|
||||
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)}
|
||||
/>
|
||||
|
||||
@@ -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<string, string> {
|
||||
const localSettings = settingsManager.getLocalProjectSettings();
|
||||
return localSettings.profiles || {};
|
||||
const merged = settingsManager.getMergedProfiles();
|
||||
// Convert array format back to Record
|
||||
const result: Record<string, string> = {};
|
||||
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<void> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,10 +122,10 @@ export const commands: Record<string, Command> = {
|
||||
},
|
||||
},
|
||||
"/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<string, Command> = {
|
||||
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...";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
<Box>
|
||||
<Text color="gray">Current agent: </Text>
|
||||
<Text bold>{agentName || "Unnamed"}</Text>
|
||||
{profileName ? (
|
||||
<Text color="green"> (profile: {profileName} ✓)</Text>
|
||||
{isPinned ? (
|
||||
<Text color="green"> (pinned ✓)</Text>
|
||||
) : (
|
||||
<Text color="gray"> (type /profile to pin agent)</Text>
|
||||
<Text color="gray"> (type /pin to pin agent)</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
|
||||
@@ -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<Mode>("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 (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Save Current Agent as Profile
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text>Enter profile name (Esc to cancel):</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>> </Text>
|
||||
<Text>{saveInput}</Text>
|
||||
<Text>█</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Delete confirmation UI
|
||||
// Unpin confirmation UI
|
||||
if (mode === "confirming-delete" && selectedProfile) {
|
||||
const options = ["Yes, delete", "No, cancel"];
|
||||
const options = ["Yes, unpin", "No, cancel"];
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Delete Profile
|
||||
Unpin Agent
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>
|
||||
Are you sure you want to delete profile "{selectedProfile.name}"?
|
||||
</Text>
|
||||
<Text>Unpin "{selectedProfile.name}" from all locations?</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{options.map((option, index) => {
|
||||
@@ -276,22 +252,22 @@ export const ProfileSelector = memo(function ProfileSelector({
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Profiles
|
||||
Pinned Agents
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<Box>
|
||||
<Text dimColor>Loading profiles...</Text>
|
||||
<Text dimColor>Loading pinned agents...</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && profiles.length === 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>No profiles saved.</Text>
|
||||
<Text dimColor>Press S to save the current agent as a profile.</Text>
|
||||
<Text dimColor>No agents pinned.</Text>
|
||||
<Text dimColor>Press P to pin the current agent.</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Esc to close</Text>
|
||||
</Box>
|
||||
@@ -335,6 +311,9 @@ export const ProfileSelector = memo(function ProfileSelector({
|
||||
>
|
||||
{profile.name}
|
||||
</Text>
|
||||
{profile.isPinned && (
|
||||
<Text color={colors.selector.itemCurrent}> (pinned)</Text>
|
||||
)}
|
||||
<Text dimColor> · {displayId}</Text>
|
||||
{isCurrent && (
|
||||
<Text color={colors.selector.itemCurrent}> (current)</Text>
|
||||
@@ -381,8 +360,7 @@ export const ProfileSelector = memo(function ProfileSelector({
|
||||
)}
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
↑↓ navigate · Enter load · S save · D delete · J/K page · Esc
|
||||
close
|
||||
↑↓ navigate · Enter load · P pin/unpin · D unpin all · Esc close
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -237,7 +237,7 @@ export function ResumeSelector({
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Resume Session (showing most recent agents)
|
||||
Browsing Agents (sorting by last run)
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
349
src/cli/profile-selection.tsx
Normal file
349
src/cli/profile-selection.tsx
Normal file
@@ -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<ProfileOption[]>([]);
|
||||
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<string>();
|
||||
|
||||
// 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 (
|
||||
<Box flexDirection="column">
|
||||
{/* Welcome Screen */}
|
||||
<WelcomeScreen
|
||||
loadingState="ready"
|
||||
continueSession={false}
|
||||
agentState={null}
|
||||
agentProvenance={null}
|
||||
/>
|
||||
<Box height={1} />
|
||||
|
||||
{loading ? (
|
||||
<Text dimColor>Loading pinned agents...</Text>
|
||||
) : (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text dimColor>{contextMessage}</Text>
|
||||
<Text bold>Which agent would you like to use?</Text>
|
||||
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{displayOptions.map((option, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const displayName =
|
||||
option.agent?.name || option.agentId.slice(0, 20);
|
||||
const label = getLabel(option);
|
||||
|
||||
return (
|
||||
<Box key={option.agentId} flexDirection="column">
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
>
|
||||
{isSelected ? "→ " : " "}
|
||||
</Text>
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
>
|
||||
Resume{" "}
|
||||
</Text>
|
||||
<Text
|
||||
bold
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text dimColor>{label}</Text>
|
||||
</Box>
|
||||
{option.agent && (
|
||||
<Box marginLeft={4}>
|
||||
<Text dimColor>
|
||||
{formatRelativeTime(option.agent.last_run_completion)} ·{" "}
|
||||
{option.agent.memory?.blocks?.length || 0} memory blocks
|
||||
· {formatModel(option.agent)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasMore && !showAll && (
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
selectedIndex === displayOptions.length
|
||||
? colors.selector.itemHighlighted
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{selectedIndex === displayOptions.length ? "→ " : " "}
|
||||
View all {options.length} profiles
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
selectedIndex === totalItems - 1
|
||||
? colors.selector.itemHighlighted
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{selectedIndex === totalItems - 1 ? "→ " : " "}
|
||||
Create a new agent
|
||||
</Text>
|
||||
<Text dimColor> (--new)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>↑↓ navigate · Enter select · Esc exit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
97
src/index.ts
97
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 <id> 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 <list> Comma-separated memory blocks to initialize when using --new (e.g., "persona,skills")
|
||||
--base-tools <list> 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 <id> Use a specific agent ID
|
||||
-m, --model <id> Model ID or handle (e.g., "opus-4.5" or "anthropic/claude-opus-4-5")
|
||||
-s, --system <id> 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 <path> 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 <name> 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<string | null>(null);
|
||||
const [agentState, setAgentState] = useState<AgentState | null>(null);
|
||||
const [resumeData, setResumeData] = useState<ResumeData | null>(null);
|
||||
@@ -492,9 +486,21 @@ async function main() {
|
||||
const [agentProvenance, setAgentProvenance] =
|
||||
useState<AgentProvenance | null>(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,
|
||||
|
||||
@@ -10,7 +10,9 @@ export interface Settings {
|
||||
lastAgent: string | null;
|
||||
tokenStreaming: boolean;
|
||||
enableSleeptime: boolean;
|
||||
globalSharedBlockIds: Record<string, string>;
|
||||
globalSharedBlockIds: Record<string, string>; // DEPRECATED: kept for backwards compat
|
||||
profiles?: Record<string, string>; // DEPRECATED: old format, kept for migration
|
||||
pinnedAgents?: string[]; // Array of agent IDs pinned globally
|
||||
permissions?: PermissionRules;
|
||||
env?: Record<string, string>;
|
||||
// OAuth token management
|
||||
@@ -26,7 +28,8 @@ export interface ProjectSettings {
|
||||
export interface LocalProjectSettings {
|
||||
lastAgent: string | null;
|
||||
permissions?: PermissionRules;
|
||||
profiles?: Record<string, string>; // profileName -> agentId
|
||||
profiles?: Record<string, string>; // 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<string>();
|
||||
|
||||
// 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<string, string> {
|
||||
return this.getSettings().profiles || {};
|
||||
}
|
||||
|
||||
// DEPRECATED: Keep for backwards compatibility
|
||||
getLocalProfiles(
|
||||
workingDirectory: string = process.cwd(),
|
||||
): Record<string, string> {
|
||||
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.
|
||||
|
||||
@@ -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<string, string>;
|
||||
let originalLocalSharedBlockIds: Record<string, string>;
|
||||
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 },
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user