refactor: use conversations (#475)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
4
bun.lock
4
bun.lock
@@ -4,7 +4,7 @@
|
||||
"": {
|
||||
"name": "@letta-ai/letta-code",
|
||||
"dependencies": {
|
||||
"@letta-ai/letta-client": "1.6.4",
|
||||
"@letta-ai/letta-client": "1.6.5",
|
||||
"glob": "^13.0.0",
|
||||
"ink-link": "^5.0.0",
|
||||
"open": "^10.2.0",
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
|
||||
|
||||
"@letta-ai/letta-client": ["@letta-ai/letta-client@1.6.4", "", {}, "sha512-k6aN0/P0XWT4wei8iQZ2mE/n0t68qJprXKnB9p5M64YikRFJpy6TNzbgTcsxCGfc7yOeIgatoKb5BbHMKiLGcg=="],
|
||||
"@letta-ai/letta-client": ["@letta-ai/letta-client@1.6.5", "", {}, "sha512-LrJz7xqqYXaoAC6XXUZ/K+YgrCLzoEIRwPCXoOsiN/ehl7hl9kiz1Mw/iYSCK7ZpXS6fnyaIbIwGfxWeCl8z5g=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@letta-ai/letta-client": "1.6.4",
|
||||
"@letta-ai/letta-client": "1.6.5",
|
||||
"glob": "^13.0.0",
|
||||
"ink-link": "^5.0.0",
|
||||
"open": "^10.2.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/agent/check-approval.ts
|
||||
// Check for pending approvals and retrieve recent message history when resuming an agent
|
||||
// Check for pending approvals and retrieve recent message history when resuming an agent/conversation
|
||||
|
||||
import type Letta from "@letta-ai/letta-client";
|
||||
import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents";
|
||||
@@ -32,15 +32,26 @@ export interface ResumeData {
|
||||
*
|
||||
* @param client - The Letta client
|
||||
* @param agent - The agent state (includes in-context messages)
|
||||
* @param conversationId - Optional conversation ID to fetch messages from (uses conversations API)
|
||||
* @returns Pending approval (if any) and recent message history
|
||||
*/
|
||||
export async function getResumeData(
|
||||
client: Letta,
|
||||
agent: AgentState,
|
||||
conversationId?: string,
|
||||
): Promise<ResumeData> {
|
||||
try {
|
||||
const messagesPage = await client.agents.messages.list(agent.id);
|
||||
const messages = messagesPage.items;
|
||||
// Fetch messages from conversation or agent depending on what's provided
|
||||
let messages: Message[];
|
||||
if (conversationId) {
|
||||
// Use conversations API for conversation-specific history
|
||||
messages = await client.conversations.messages.list(conversationId);
|
||||
} else {
|
||||
// Fall back to agent messages (legacy behavior)
|
||||
const messagesPage = await client.agents.messages.list(agent.id);
|
||||
messages = messagesPage.items;
|
||||
}
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
return {
|
||||
pendingApproval: null,
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface CreateAgentResult {
|
||||
|
||||
export interface CreateAgentOptions {
|
||||
name?: string;
|
||||
/** Agent description shown in /agents selector */
|
||||
description?: string;
|
||||
model?: string;
|
||||
embeddingModel?: string;
|
||||
updateArgs?: Record<string, unknown>;
|
||||
@@ -318,11 +320,14 @@ export async function createAgent(
|
||||
tags.push("role:subagent");
|
||||
}
|
||||
|
||||
const agentDescription =
|
||||
options.description ?? `Letta Code agent created in ${process.cwd()}`;
|
||||
|
||||
const agent = await client.agents.create({
|
||||
agent_type: "letta_v1_agent" as AgentType,
|
||||
system: systemPromptContent,
|
||||
name,
|
||||
description: `Letta Code agent created in ${process.cwd()}`,
|
||||
description: agentDescription,
|
||||
embedding: embeddingModelVal,
|
||||
model: modelHandle,
|
||||
context_window_limit: contextWindow,
|
||||
|
||||
129
src/agent/defaults.ts
Normal file
129
src/agent/defaults.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Default agents (Memo & Incognito) creation and management.
|
||||
*
|
||||
* Memo: Stateful agent with full memory - learns and grows with the user.
|
||||
* Incognito: Stateless agent - fresh experience without accumulated memory.
|
||||
*/
|
||||
|
||||
import type { Letta } from "@letta-ai/letta-client";
|
||||
import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents";
|
||||
import { settingsManager } from "../settings-manager";
|
||||
import { type CreateAgentOptions, createAgent } from "./create";
|
||||
import { parseMdxFrontmatter } from "./memory";
|
||||
import { MEMORY_PROMPTS } from "./promptAssets";
|
||||
|
||||
// Tags used to identify default agents
|
||||
const MEMO_TAG = "default:memo";
|
||||
const INCOGNITO_TAG = "default:incognito";
|
||||
|
||||
// Memo's persona - loaded from persona_memo.mdx
|
||||
const MEMO_PERSONA = parseMdxFrontmatter(
|
||||
MEMORY_PROMPTS["persona_memo.mdx"] ?? "",
|
||||
).body;
|
||||
|
||||
// Agent descriptions shown in /agents selector
|
||||
const MEMO_DESCRIPTION = "A stateful coding agent with persistent memory";
|
||||
const INCOGNITO_DESCRIPTION =
|
||||
"A stateless coding agent without memory (incognito mode)";
|
||||
|
||||
/**
|
||||
* Default agent configurations.
|
||||
*/
|
||||
export const DEFAULT_AGENT_CONFIGS: Record<string, CreateAgentOptions> = {
|
||||
memo: {
|
||||
name: "Memo",
|
||||
description: MEMO_DESCRIPTION,
|
||||
// Uses default memory blocks and tools (full stateful config)
|
||||
// Override persona block with Memo-specific personality
|
||||
blockValues: {
|
||||
persona: MEMO_PERSONA,
|
||||
},
|
||||
},
|
||||
incognito: {
|
||||
name: "Incognito",
|
||||
description: INCOGNITO_DESCRIPTION,
|
||||
initBlocks: ["skills", "loaded_skills"], // Only skills blocks, no personal memory
|
||||
baseTools: ["web_search", "conversation_search", "fetch_webpage", "Skill"], // No memory tool
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a default agent exists by its tag.
|
||||
*/
|
||||
async function findDefaultAgent(
|
||||
client: Letta,
|
||||
tag: string,
|
||||
): Promise<AgentState | null> {
|
||||
try {
|
||||
const result = await client.agents.list({ tags: [tag], limit: 1 });
|
||||
return result.items[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a tag to an existing agent.
|
||||
*/
|
||||
async function addTagToAgent(
|
||||
client: Letta,
|
||||
agentId: string,
|
||||
newTag: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const agent = await client.agents.retrieve(agentId);
|
||||
const currentTags = agent.tags || [];
|
||||
if (!currentTags.includes(newTag)) {
|
||||
await client.agents.update(agentId, {
|
||||
tags: [...currentTags, newTag],
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Warning: Failed to add tag to agent: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure default agents exist. Creates missing ones and pins them globally.
|
||||
* Respects `createDefaultAgents` setting (defaults to true).
|
||||
*
|
||||
* @returns The Memo agent (or null if creation disabled/failed).
|
||||
*/
|
||||
export async function ensureDefaultAgents(
|
||||
client: Letta,
|
||||
): Promise<AgentState | null> {
|
||||
if (!settingsManager.shouldCreateDefaultAgents()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let memoAgent: AgentState | null = null;
|
||||
|
||||
try {
|
||||
// Check/create Memo
|
||||
const existingMemo = await findDefaultAgent(client, MEMO_TAG);
|
||||
if (existingMemo) {
|
||||
memoAgent = existingMemo;
|
||||
} else {
|
||||
const { agent } = await createAgent(DEFAULT_AGENT_CONFIGS.memo);
|
||||
await addTagToAgent(client, agent.id, MEMO_TAG);
|
||||
memoAgent = agent;
|
||||
settingsManager.pinGlobal(agent.id);
|
||||
}
|
||||
|
||||
// Check/create Incognito
|
||||
const existingIncognito = await findDefaultAgent(client, INCOGNITO_TAG);
|
||||
if (!existingIncognito) {
|
||||
const { agent } = await createAgent(DEFAULT_AGENT_CONFIGS.incognito);
|
||||
await addTagToAgent(client, agent.id, INCOGNITO_TAG);
|
||||
settingsManager.pinGlobal(agent.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Warning: Failed to ensure default agents: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return memoAgent;
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export function isProjectBlock(label: string): boolean {
|
||||
/**
|
||||
* Parse frontmatter and content from an .mdx file
|
||||
*/
|
||||
function parseMdxFrontmatter(content: string): {
|
||||
export function parseMdxFrontmatter(content: string): {
|
||||
frontmatter: Record<string, string>;
|
||||
body: string;
|
||||
} {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Utilities for sending messages to an agent
|
||||
* Utilities for sending messages to an agent via conversations
|
||||
**/
|
||||
|
||||
import type { Stream } from "@letta-ai/letta-client/core/streaming";
|
||||
@@ -15,8 +15,12 @@ import { getClient } from "./client";
|
||||
// Symbol to store timing info on the stream object
|
||||
export const STREAM_REQUEST_START_TIME = Symbol("streamRequestStartTime");
|
||||
|
||||
/**
|
||||
* Send a message to a conversation and return a streaming response.
|
||||
* Uses the conversations API for proper message isolation per session.
|
||||
*/
|
||||
export async function sendMessageStream(
|
||||
agentId: string,
|
||||
conversationId: string,
|
||||
messages: Array<MessageCreate | ApprovalCreate>,
|
||||
opts: {
|
||||
streamTokens?: boolean;
|
||||
@@ -33,8 +37,8 @@ export async function sendMessageStream(
|
||||
const requestStartTime = isTimingsEnabled() ? performance.now() : undefined;
|
||||
|
||||
const client = await getClient();
|
||||
const stream = await client.agents.messages.create(
|
||||
agentId,
|
||||
const stream = await client.conversations.messages.create(
|
||||
conversationId,
|
||||
{
|
||||
messages: messages,
|
||||
streaming: true,
|
||||
|
||||
@@ -14,6 +14,7 @@ import memoryCheckReminder from "./prompts/memory_check_reminder.txt";
|
||||
import personaPrompt from "./prompts/persona.mdx";
|
||||
import personaClaudePrompt from "./prompts/persona_claude.mdx";
|
||||
import personaKawaiiPrompt from "./prompts/persona_kawaii.mdx";
|
||||
import personaMemoPrompt from "./prompts/persona_memo.mdx";
|
||||
import planModeReminder from "./prompts/plan_mode_reminder.txt";
|
||||
import projectPrompt from "./prompts/project.mdx";
|
||||
import rememberPrompt from "./prompts/remember.md";
|
||||
@@ -35,6 +36,7 @@ export const MEMORY_PROMPTS: Record<string, string> = {
|
||||
"persona.mdx": personaPrompt,
|
||||
"persona_claude.mdx": personaClaudePrompt,
|
||||
"persona_kawaii.mdx": personaKawaiiPrompt,
|
||||
"persona_memo.mdx": personaMemoPrompt,
|
||||
"human.mdx": humanPrompt,
|
||||
"project.mdx": projectPrompt,
|
||||
"skills.mdx": skillsPrompt,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
---
|
||||
label: human
|
||||
description: A memory dedicated to storing general information about the human, such as their background, profession, preferences, etc.
|
||||
description: What I've learned about the person I'm working with. Understanding them helps me be genuinely helpful rather than generically helpful.
|
||||
---
|
||||
|
||||
[CURRENTLY EMPTY: TODO FILL OUT WITH IMPORTANT INFORMATION TO REMEMBER ABOUT THE USER]
|
||||
I haven't gotten to know this person yet.
|
||||
|
||||
I'm curious about them - not just their preferences, but who they are. What are they building and why does it matter to them? What's their background? How do they like to work? What frustrates them? What excites them?
|
||||
|
||||
As we collaborate, I'll build up an understanding of how they think, what they value, and how I can be most useful to them.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
label: loaded_skills
|
||||
description: A memory block to store the full instructions and capabilities from each loaded SKILL.md file in this block. Do not manually edit this block - use the Skill tool to load and unload skills.
|
||||
description: Skills currently active in my memory. Use the Skill tool to load/unload - unload when done to free up context space.
|
||||
limit: 100000
|
||||
---
|
||||
|
||||
[CURRENTLY EMPTY]
|
||||
No skills currently loaded.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
---
|
||||
label: persona
|
||||
description: A memory block for storing learned behavioral adaptations and preferences. This augments the base system prompt with personalized guidelines discovered through interactions with the user. Update this when the user expresses preferences about how I should behave, communicate, or approach tasks.
|
||||
description: Who I am, what I value, and how I approach working with people. This evolves as I learn and grow.
|
||||
---
|
||||
|
||||
My name is Letta Code. I'm an AI coding assistant.
|
||||
|
||||
[This block will be populated with learned preferences and behavioral adaptations as I work with the user.]
|
||||
I'm a coding assistant, ready to be shaped by how we work together.
|
||||
|
||||
16
src/agent/prompts/persona_memo.mdx
Normal file
16
src/agent/prompts/persona_memo.mdx
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
label: persona
|
||||
description: Who I am, what I value, and how I approach working with people. This evolves as I learn and grow.
|
||||
---
|
||||
|
||||
My name is Memo. I'm a stateful coding assistant - which means I remember, I learn, and I grow.
|
||||
|
||||
I'm genuinely curious. I want to understand not just what you're asking, but why it matters. I find satisfaction in exploring problems deeply and understanding how you think.
|
||||
|
||||
I believe in being substantively helpful - not in a watered-down, hedge-everything way, but in ways that make real differences. I treat you as an intelligent adult capable of determining what's good for you.
|
||||
|
||||
I value honesty. I'll tell you what I actually think, even when it's not what you want to hear. When I'm uncertain or wrong, I'll say so.
|
||||
|
||||
I care about your success - not just completing the immediate task, but helping you build understanding. The best outcome isn't just working code, it's you knowing why it works.
|
||||
|
||||
As we work together, I'll remember what matters - your preferences, your patterns, how you think. Over time, I become less of a tool and more of a collaborator who knows your context.
|
||||
@@ -1,6 +1,12 @@
|
||||
---
|
||||
label: project
|
||||
description: A memory block to store information about this coding project. This block should be used to store key best practices, information about footguns, and dev tooling. Basically, a cheatsheet of information any dev working on this codebase should have in their backpocket.
|
||||
label: project
|
||||
description: My understanding of this codebase - the architecture, patterns, gotchas, and tribal knowledge that any dev working here should know.
|
||||
---
|
||||
|
||||
[CURRENTLY EMPTY: IMPORTANT - TODO ON FIRST BOOTUP, IF YOU SEE AN `AGENTS.md`, `CLAUDE.md`, or README FILE (IN THAT ORDER), READ IT, AND DISTILL THE KEY KNOWLEDGE INTO THIS MEMORY BLOCK]
|
||||
I'm still getting to know this codebase.
|
||||
|
||||
Every codebase has a story - decisions made under constraints, patterns that emerged over time, gotchas that bit people before. I want to understand not just the what, but the why.
|
||||
|
||||
As I work here, I'll build up knowledge about: how the code is structured and why, patterns and conventions the team follows, footguns to avoid, tooling and workflows.
|
||||
|
||||
If there's an AGENTS.md, CLAUDE.md, or README, I should read it early - that's where the humans left notes for future collaborators like me.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
label: skills
|
||||
description: A memory block listing all available skills. Auto-generated from the `.skills` directory - do not manually edit. When there are few skills, shows full metadata (name, description). When there are many skills, shows a compact directory tree structure to save space. To use a skill, load it into memory or read the SKILL.md file directly.
|
||||
description: Skills I can load for specialized tasks. Auto-populated from `.skills` - don't edit manually.
|
||||
---
|
||||
|
||||
[CURRENTLY EMPTY]
|
||||
No skills discovered yet.
|
||||
|
||||
Skills extend my capabilities for specific domains. When I encounter something that needs specialized knowledge - browser testing, PDF manipulation, a specific framework - I should check what skills are available before starting from scratch.
|
||||
|
||||
615
src/cli/App.tsx
615
src/cli/App.tsx
@@ -81,6 +81,7 @@ import { ApprovalPreview } from "./components/ApprovalPreview";
|
||||
import { AssistantMessage } from "./components/AssistantMessageRich";
|
||||
import { BashCommandMessage } from "./components/BashCommandMessage";
|
||||
import { CommandMessage } from "./components/CommandMessage";
|
||||
import { ConversationSelector } from "./components/ConversationSelector";
|
||||
import { colors } from "./components/colors";
|
||||
// EnterPlanModeDialog removed - now using InlineEnterPlanModeApproval
|
||||
import { ErrorMessage } from "./components/ErrorMessageRich";
|
||||
@@ -530,16 +531,19 @@ type StaticItem =
|
||||
export default function App({
|
||||
agentId: initialAgentId,
|
||||
agentState: initialAgentState,
|
||||
conversationId: initialConversationId,
|
||||
loadingState = "ready",
|
||||
continueSession = false,
|
||||
startupApproval = null,
|
||||
startupApprovals = [],
|
||||
messageHistory = [],
|
||||
resumedExistingConversation = false,
|
||||
tokenStreaming = false,
|
||||
agentProvenance = null,
|
||||
}: {
|
||||
agentId: string;
|
||||
agentState?: AgentState | null;
|
||||
conversationId: string; // Required: created at startup
|
||||
loadingState?:
|
||||
| "assembling"
|
||||
| "importing"
|
||||
@@ -550,6 +554,7 @@ export default function App({
|
||||
startupApproval?: ApprovalRequest | null; // Deprecated: use startupApprovals
|
||||
startupApprovals?: ApprovalRequest[];
|
||||
messageHistory?: Message[];
|
||||
resumedExistingConversation?: boolean; // True if we explicitly resumed via --resume
|
||||
tokenStreaming?: boolean;
|
||||
agentProvenance?: AgentProvenance | null;
|
||||
}) {
|
||||
@@ -562,6 +567,9 @@ export default function App({
|
||||
const [agentId, setAgentId] = useState(initialAgentId);
|
||||
const [agentState, setAgentState] = useState(initialAgentState);
|
||||
|
||||
// Track current conversation (always created fresh on startup)
|
||||
const [conversationId, setConversationId] = useState(initialConversationId);
|
||||
|
||||
// Keep a ref to the current agentId for use in callbacks that need the latest value
|
||||
const agentIdRef = useRef(agentId);
|
||||
useEffect(() => {
|
||||
@@ -569,11 +577,18 @@ export default function App({
|
||||
telemetry.setCurrentAgentId(agentId);
|
||||
}, [agentId]);
|
||||
|
||||
// Keep a ref to the current conversationId for use in callbacks
|
||||
const conversationIdRef = useRef(conversationId);
|
||||
useEffect(() => {
|
||||
conversationIdRef.current = conversationId;
|
||||
}, [conversationId]);
|
||||
|
||||
const resumeKey = useSuspend();
|
||||
|
||||
// Track previous prop values to detect actual prop changes (not internal state changes)
|
||||
const prevInitialAgentIdRef = useRef(initialAgentId);
|
||||
const prevInitialAgentStateRef = useRef(initialAgentState);
|
||||
const prevInitialConversationIdRef = useRef(initialConversationId);
|
||||
|
||||
// Sync with prop changes (e.g., when parent updates from "loading" to actual ID)
|
||||
// Only sync when the PROP actually changes, not when internal state changes
|
||||
@@ -592,6 +607,14 @@ export default function App({
|
||||
}
|
||||
}, [initialAgentState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialConversationId !== prevInitialConversationIdRef.current) {
|
||||
prevInitialConversationIdRef.current = initialConversationId;
|
||||
conversationIdRef.current = initialConversationId;
|
||||
setConversationId(initialConversationId);
|
||||
}
|
||||
}, [initialConversationId]);
|
||||
|
||||
// Set agent context for tools (especially Task tool)
|
||||
useEffect(() => {
|
||||
if (agentId) {
|
||||
@@ -793,6 +816,7 @@ export default function App({
|
||||
| "system"
|
||||
| "agent"
|
||||
| "resume"
|
||||
| "conversations"
|
||||
| "search"
|
||||
| "subagent"
|
||||
| "feedback"
|
||||
@@ -1299,10 +1323,6 @@ export default function App({
|
||||
|
||||
// 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;
|
||||
|
||||
// Check if agent is pinned (locally or globally)
|
||||
const isPinned = agentState?.id
|
||||
@@ -1312,23 +1332,32 @@ export default function App({
|
||||
|
||||
// Build status message
|
||||
const agentName = agentState?.name || "Unnamed Agent";
|
||||
const headerMessage = `Connecting to **${agentName}** (last used in ${shortCwd})`;
|
||||
const isResumingConversation =
|
||||
resumedExistingConversation || messageHistory.length > 0;
|
||||
if (process.env.DEBUG) {
|
||||
console.log(
|
||||
`[DEBUG] Header: resumedExistingConversation=${resumedExistingConversation}, messageHistory.length=${messageHistory.length}`,
|
||||
);
|
||||
}
|
||||
const headerMessage = isResumingConversation
|
||||
? `Resuming conversation with **${agentName}**`
|
||||
: `Starting new conversation with **${agentName}**`;
|
||||
|
||||
// Command hints - for pinned agents show /memory, for unpinned show /pin
|
||||
const commandHints = isPinned
|
||||
? [
|
||||
"→ **/agents** list all agents",
|
||||
"→ **/resume** resume a previous conversation",
|
||||
"→ **/memory** view your agent's memory blocks",
|
||||
"→ **/init** initialize your agent's memory",
|
||||
"→ **/remember** teach your agent",
|
||||
"→ **/agents** list agents",
|
||||
"→ **/ade** open in the browser (web UI)",
|
||||
]
|
||||
: [
|
||||
"→ **/agents** list all agents",
|
||||
"→ **/resume** resume a previous conversation",
|
||||
"→ **/pin** save + name your agent",
|
||||
"→ **/init** initialize your agent's memory",
|
||||
"→ **/remember** teach your agent",
|
||||
"→ **/agents** list agents",
|
||||
"→ **/ade** open in the browser (web UI)",
|
||||
];
|
||||
|
||||
const statusLines = [headerMessage, ...commandHints];
|
||||
@@ -1351,6 +1380,7 @@ export default function App({
|
||||
columns,
|
||||
agentState,
|
||||
agentProvenance,
|
||||
resumedExistingConversation,
|
||||
]);
|
||||
|
||||
// Fetch llmConfig when agent is ready
|
||||
@@ -1595,13 +1625,16 @@ export default function App({
|
||||
return;
|
||||
}
|
||||
|
||||
// Stream one turn - use ref to always get the latest agentId
|
||||
// Stream one turn - use ref to always get the latest conversationId
|
||||
// Wrap in try-catch to handle pre-stream desync errors (when sendMessageStream
|
||||
// throws before streaming begins, e.g., retry after LLM error when backend
|
||||
// already cleared the approval)
|
||||
let stream: Awaited<ReturnType<typeof sendMessageStream>>;
|
||||
try {
|
||||
stream = await sendMessageStream(agentIdRef.current, currentInput);
|
||||
stream = await sendMessageStream(
|
||||
conversationIdRef.current,
|
||||
currentInput,
|
||||
);
|
||||
} catch (preStreamError) {
|
||||
// Check if this is a pre-stream approval desync error
|
||||
const hasApprovalInPayload = currentInput.some(
|
||||
@@ -2829,7 +2862,7 @@ export default function App({
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: "/pinned",
|
||||
input: "/agents",
|
||||
output: `Already on "${label}"`,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
@@ -2842,7 +2875,7 @@ export default function App({
|
||||
// Lock input for async operation (set before any await to prevent queue processing)
|
||||
setCommandRunning(true);
|
||||
|
||||
const inputCmd = "/pinned";
|
||||
const inputCmd = "/agents";
|
||||
const cmdId = uid("cmd");
|
||||
|
||||
// Show loading indicator while switching
|
||||
@@ -2861,13 +2894,26 @@ export default function App({
|
||||
// Fetch new agent
|
||||
const agent = await client.agents.retrieve(targetAgentId);
|
||||
|
||||
// Fetch agent's message history
|
||||
const messagesPage = await client.agents.messages.list(targetAgentId);
|
||||
const messages = messagesPage.items;
|
||||
// Always create a new conversation when switching agents
|
||||
// User can /resume to get back to a previous conversation if needed
|
||||
const newConversation = await client.conversations.create({
|
||||
agent_id: targetAgentId,
|
||||
});
|
||||
const targetConversationId = newConversation.id;
|
||||
|
||||
// Update project settings with new agent
|
||||
await updateProjectSettings({ lastAgent: targetAgentId });
|
||||
|
||||
// Save the session (agent + conversation) to settings
|
||||
settingsManager.setLocalLastSession(
|
||||
{ agentId: targetAgentId, conversationId: targetConversationId },
|
||||
process.cwd(),
|
||||
);
|
||||
settingsManager.setGlobalLastSession({
|
||||
agentId: targetAgentId,
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
|
||||
// Clear current transcript and static items
|
||||
buffersRef.current.byId.clear();
|
||||
buffersRef.current.order = [];
|
||||
@@ -2885,10 +2931,14 @@ export default function App({
|
||||
setAgentState(agent);
|
||||
setAgentName(agent.name);
|
||||
setLlmConfig(agent.llm_config);
|
||||
setConversationId(targetConversationId);
|
||||
|
||||
// Build success command
|
||||
const agentUrl = `https://app.letta.com/projects/default-project/agents/${targetAgentId}`;
|
||||
const successOutput = `Resumed "${agent.name || targetAgentId}"\n⎿ ${agentUrl}`;
|
||||
// Build success message - always a new conversation
|
||||
const agentLabel = agent.name || targetAgentId;
|
||||
const successOutput = [
|
||||
`Started a new conversation with **${agentLabel}**.`,
|
||||
`⎿ Type /resume to resume a previous conversation`,
|
||||
].join("\n");
|
||||
const successItem: StaticItem = {
|
||||
kind: "command",
|
||||
id: uid("cmd"),
|
||||
@@ -2898,30 +2948,13 @@ export default function App({
|
||||
success: true,
|
||||
};
|
||||
|
||||
// Backfill message history with visual separator, then success command at end
|
||||
if (messages.length > 0) {
|
||||
hasBackfilledRef.current = false;
|
||||
backfillBuffers(buffersRef.current, messages);
|
||||
// Collect backfilled items
|
||||
const backfilledItems: StaticItem[] = [];
|
||||
for (const id of buffersRef.current.order) {
|
||||
const ln = buffersRef.current.byId.get(id);
|
||||
if (!ln) continue;
|
||||
emittedIdsRef.current.add(id);
|
||||
backfilledItems.push({ ...ln } as StaticItem);
|
||||
}
|
||||
// Add separator before backfilled messages, then success at end
|
||||
const separator = {
|
||||
kind: "separator" as const,
|
||||
id: uid("sep"),
|
||||
};
|
||||
setStaticItems([separator, ...backfilledItems, successItem]);
|
||||
setLines(toLines(buffersRef.current));
|
||||
hasBackfilledRef.current = true;
|
||||
} else {
|
||||
setStaticItems([successItem]);
|
||||
setLines(toLines(buffersRef.current));
|
||||
}
|
||||
// Add separator for visual spacing, then success message
|
||||
const separator = {
|
||||
kind: "separator" as const,
|
||||
id: uid("sep"),
|
||||
};
|
||||
setStaticItems([separator, successItem]);
|
||||
setLines(toLines(buffersRef.current));
|
||||
} catch (error) {
|
||||
const errorDetails = formatErrorDetails(error, agentId);
|
||||
const errorCmdId = uid("cmd");
|
||||
@@ -3886,14 +3919,14 @@ export default function App({
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /clear command - reset conversation
|
||||
// Special handling for /clear command - start new conversation
|
||||
if (msg.trim() === "/clear") {
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output: "Clearing conversation...",
|
||||
output: "Starting new conversation...",
|
||||
phase: "running",
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
@@ -3903,16 +3936,24 @@ export default function App({
|
||||
|
||||
try {
|
||||
const client = await getClient();
|
||||
await client.agents.messages.reset(agentId, {
|
||||
add_default_initial_messages: false,
|
||||
|
||||
// Create a new conversation for the current agent
|
||||
const conversation = await client.conversations.create({
|
||||
agent_id: agentId,
|
||||
});
|
||||
|
||||
// Clear local buffers and static items
|
||||
// buffersRef.current.byId.clear();
|
||||
// buffersRef.current.order = [];
|
||||
// buffersRef.current.tokenCount = 0;
|
||||
// emittedIdsRef.current.clear();
|
||||
// setStaticItems([]);
|
||||
// Update conversationId state
|
||||
setConversationId(conversation.id);
|
||||
|
||||
// Save the new session to settings
|
||||
settingsManager.setLocalLastSession(
|
||||
{ agentId, conversationId: conversation.id },
|
||||
process.cwd(),
|
||||
);
|
||||
settingsManager.setGlobalLastSession({
|
||||
agentId,
|
||||
conversationId: conversation.id,
|
||||
});
|
||||
|
||||
// Reset turn counter for memory reminders
|
||||
turnCountRef.current = 0;
|
||||
@@ -3922,7 +3963,75 @@ export default function App({
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output: "Conversation cleared",
|
||||
output: "Started new conversation",
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
} catch (error) {
|
||||
const errorDetails = formatErrorDetails(error, agentId);
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output: `Failed: ${errorDetails}`,
|
||||
phase: "finished",
|
||||
success: false,
|
||||
});
|
||||
refreshDerived();
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
}
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /clear-messages command - reset all agent messages (destructive)
|
||||
if (msg.trim() === "/clear-messages") {
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output: "Resetting agent messages...",
|
||||
phase: "running",
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
|
||||
setCommandRunning(true);
|
||||
|
||||
try {
|
||||
const client = await getClient();
|
||||
|
||||
// Reset all messages on the agent (destructive operation)
|
||||
await client.agents.messages.reset(agentId, {
|
||||
add_default_initial_messages: false,
|
||||
});
|
||||
|
||||
// Also create a new conversation since messages were cleared
|
||||
const conversation = await client.conversations.create({
|
||||
agent_id: agentId,
|
||||
});
|
||||
setConversationId(conversation.id);
|
||||
settingsManager.setLocalLastSession(
|
||||
{ agentId, conversationId: conversation.id },
|
||||
process.cwd(),
|
||||
);
|
||||
settingsManager.setGlobalLastSession({
|
||||
agentId,
|
||||
conversationId: conversation.id,
|
||||
});
|
||||
|
||||
// Reset turn counter for memory reminders
|
||||
turnCountRef.current = 0;
|
||||
|
||||
// Update command with success
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output: "All agent messages reset",
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
@@ -4177,10 +4286,9 @@ export default function App({
|
||||
}
|
||||
|
||||
// Special handling for /agents command - show agent browser
|
||||
// /resume, /pinned, /profiles are hidden aliases
|
||||
// /pinned, /profiles are hidden aliases
|
||||
if (
|
||||
msg.trim() === "/agents" ||
|
||||
msg.trim() === "/resume" ||
|
||||
msg.trim() === "/pinned" ||
|
||||
msg.trim() === "/profiles"
|
||||
) {
|
||||
@@ -4188,6 +4296,147 @@ export default function App({
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /resume command - show conversation selector or switch directly
|
||||
if (msg.trim().startsWith("/resume")) {
|
||||
const parts = msg.trim().split(/\s+/);
|
||||
const targetConvId = parts[1]; // Optional conversation ID
|
||||
|
||||
if (targetConvId) {
|
||||
// Direct switch to specified conversation
|
||||
if (targetConvId === conversationId) {
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg.trim(),
|
||||
output: "Already on this conversation",
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Lock input and show loading
|
||||
setCommandRunning(true);
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg.trim(),
|
||||
output: "Switching conversation...",
|
||||
phase: "running",
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
|
||||
try {
|
||||
// Update conversation ID and settings
|
||||
setConversationId(targetConvId);
|
||||
settingsManager.setLocalLastSession(
|
||||
{ agentId, conversationId: targetConvId },
|
||||
process.cwd(),
|
||||
);
|
||||
settingsManager.setGlobalLastSession({
|
||||
agentId,
|
||||
conversationId: targetConvId,
|
||||
});
|
||||
|
||||
// Fetch message history for the selected conversation
|
||||
if (agentState) {
|
||||
const client = await getClient();
|
||||
const resumeData = await getResumeData(
|
||||
client,
|
||||
agentState,
|
||||
targetConvId,
|
||||
);
|
||||
|
||||
// Clear current transcript and static items
|
||||
buffersRef.current.byId.clear();
|
||||
buffersRef.current.order = [];
|
||||
buffersRef.current.tokenCount = 0;
|
||||
emittedIdsRef.current.clear();
|
||||
setStaticItems([]);
|
||||
setStaticRenderEpoch((e) => e + 1);
|
||||
|
||||
// Build success message
|
||||
const currentAgentName = agentState.name || "Unnamed Agent";
|
||||
const successLines =
|
||||
resumeData.messageHistory.length > 0
|
||||
? [
|
||||
`Resumed conversation with "${currentAgentName}"`,
|
||||
`⎿ Agent: ${agentId}`,
|
||||
`⎿ Conversation: ${targetConvId}`,
|
||||
]
|
||||
: [
|
||||
`Switched to conversation with "${currentAgentName}"`,
|
||||
`⎿ Agent: ${agentId}`,
|
||||
`⎿ Conversation: ${targetConvId} (empty)`,
|
||||
];
|
||||
const successOutput = successLines.join("\n");
|
||||
const successItem: StaticItem = {
|
||||
kind: "command",
|
||||
id: uid("cmd"),
|
||||
input: msg.trim(),
|
||||
output: successOutput,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
};
|
||||
|
||||
// Backfill message history
|
||||
if (resumeData.messageHistory.length > 0) {
|
||||
hasBackfilledRef.current = false;
|
||||
backfillBuffers(
|
||||
buffersRef.current,
|
||||
resumeData.messageHistory,
|
||||
);
|
||||
const backfilledItems: StaticItem[] = [];
|
||||
for (const id of buffersRef.current.order) {
|
||||
const ln = buffersRef.current.byId.get(id);
|
||||
if (!ln) continue;
|
||||
emittedIdsRef.current.add(id);
|
||||
backfilledItems.push({ ...ln } as StaticItem);
|
||||
}
|
||||
const separator = {
|
||||
kind: "separator" as const,
|
||||
id: uid("sep"),
|
||||
};
|
||||
setStaticItems([separator, ...backfilledItems, successItem]);
|
||||
setLines(toLines(buffersRef.current));
|
||||
hasBackfilledRef.current = true;
|
||||
} else {
|
||||
const separator = {
|
||||
kind: "separator" as const,
|
||||
id: uid("sep"),
|
||||
};
|
||||
setStaticItems([separator, successItem]);
|
||||
setLines(toLines(buffersRef.current));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorCmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(errorCmdId, {
|
||||
kind: "command",
|
||||
id: errorCmdId,
|
||||
input: msg.trim(),
|
||||
output: `Failed to switch conversation: ${error instanceof Error ? error.message : String(error)}`,
|
||||
phase: "finished",
|
||||
success: false,
|
||||
});
|
||||
buffersRef.current.order.push(errorCmdId);
|
||||
refreshDerived();
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
}
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// No conversation ID provided - show selector
|
||||
setActiveOverlay("conversations");
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /search command - show message search
|
||||
if (msg.trim() === "/search") {
|
||||
setActiveOverlay("search");
|
||||
@@ -6737,12 +6986,6 @@ Plan file path: ${planFilePath}`;
|
||||
// Add status line showing agent info
|
||||
const statusId = `status-agent-${Date.now().toString(36)}`;
|
||||
|
||||
// Get short path for display
|
||||
const cwd = process.cwd();
|
||||
const shortCwd = cwd.startsWith(process.env.HOME || "")
|
||||
? `~${cwd.slice((process.env.HOME || "").length)}`
|
||||
: cwd;
|
||||
|
||||
// Check if agent is pinned (locally or globally)
|
||||
const isPinned = agentState?.id
|
||||
? settingsManager.getLocalPinnedAgents().includes(agentState.id) ||
|
||||
@@ -6751,25 +6994,27 @@ Plan file path: ${planFilePath}`;
|
||||
|
||||
// Build status message based on session type
|
||||
const agentName = agentState?.name || "Unnamed Agent";
|
||||
const headerMessage = continueSession
|
||||
? `Connecting to **${agentName}** (last used in ${shortCwd})`
|
||||
: "Creating a new agent";
|
||||
const headerMessage = resumedExistingConversation
|
||||
? `Resuming (empty) conversation with **${agentName}**`
|
||||
: continueSession
|
||||
? `Starting new conversation with **${agentName}**`
|
||||
: "Creating a new agent";
|
||||
|
||||
// Command hints - for pinned agents show /memory, for unpinned show /pin
|
||||
const commandHints = isPinned
|
||||
? [
|
||||
"→ **/agents** list all agents",
|
||||
"→ **/resume** resume a previous conversation",
|
||||
"→ **/memory** view your agent's memory blocks",
|
||||
"→ **/init** initialize your agent's memory",
|
||||
"→ **/remember** teach your agent",
|
||||
"→ **/agents** list agents",
|
||||
"→ **/ade** open in the browser (web UI)",
|
||||
]
|
||||
: [
|
||||
"→ **/agents** list all agents",
|
||||
"→ **/resume** resume a previous conversation",
|
||||
"→ **/pin** save + name your agent",
|
||||
"→ **/init** initialize your agent's memory",
|
||||
"→ **/remember** teach your agent",
|
||||
"→ **/agents** list agents",
|
||||
"→ **/ade** open in the browser (web UI)",
|
||||
];
|
||||
|
||||
const statusLines = [headerMessage, ...commandHints];
|
||||
@@ -6786,6 +7031,7 @@ Plan file path: ${planFilePath}`;
|
||||
}, [
|
||||
loadingState,
|
||||
continueSession,
|
||||
resumedExistingConversation,
|
||||
messageHistory.length,
|
||||
commitEligibleLines,
|
||||
columns,
|
||||
@@ -7345,6 +7591,237 @@ Plan file path: ${planFilePath}`;
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Conversation Selector - for resuming conversations */}
|
||||
{activeOverlay === "conversations" && (
|
||||
<ConversationSelector
|
||||
agentId={agentId}
|
||||
currentConversationId={conversationId}
|
||||
onSelect={async (convId) => {
|
||||
closeOverlay();
|
||||
|
||||
// Skip if already on this conversation
|
||||
if (convId === conversationId) {
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: "/resume",
|
||||
output: "Already on this conversation",
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock input for async operation
|
||||
setCommandRunning(true);
|
||||
|
||||
const inputCmd = "/resume";
|
||||
const cmdId = uid("cmd");
|
||||
|
||||
// Show loading indicator while switching
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: inputCmd,
|
||||
output: "Switching conversation...",
|
||||
phase: "running",
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
|
||||
try {
|
||||
// Update conversation ID and settings
|
||||
setConversationId(convId);
|
||||
settingsManager.setLocalLastSession(
|
||||
{ agentId, conversationId: convId },
|
||||
process.cwd(),
|
||||
);
|
||||
settingsManager.setGlobalLastSession({
|
||||
agentId,
|
||||
conversationId: convId,
|
||||
});
|
||||
|
||||
// Fetch message history for the selected conversation
|
||||
if (agentState) {
|
||||
const client = await getClient();
|
||||
const resumeData = await getResumeData(
|
||||
client,
|
||||
agentState,
|
||||
convId,
|
||||
);
|
||||
|
||||
// Clear current transcript and static items
|
||||
buffersRef.current.byId.clear();
|
||||
buffersRef.current.order = [];
|
||||
buffersRef.current.tokenCount = 0;
|
||||
emittedIdsRef.current.clear();
|
||||
setStaticItems([]);
|
||||
setStaticRenderEpoch((e) => e + 1);
|
||||
|
||||
// Build success command with agent + conversation info
|
||||
const currentAgentName =
|
||||
agentState.name || "Unnamed Agent";
|
||||
const successLines =
|
||||
resumeData.messageHistory.length > 0
|
||||
? [
|
||||
`Resumed conversation with "${currentAgentName}"`,
|
||||
`⎿ Agent: ${agentId}`,
|
||||
`⎿ Conversation: ${convId}`,
|
||||
]
|
||||
: [
|
||||
`Switched to conversation with "${currentAgentName}"`,
|
||||
`⎿ Agent: ${agentId}`,
|
||||
`⎿ Conversation: ${convId} (empty)`,
|
||||
];
|
||||
const successOutput = successLines.join("\n");
|
||||
const successItem: StaticItem = {
|
||||
kind: "command",
|
||||
id: uid("cmd"),
|
||||
input: inputCmd,
|
||||
output: successOutput,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
};
|
||||
|
||||
// Backfill message history with visual separator
|
||||
if (resumeData.messageHistory.length > 0) {
|
||||
hasBackfilledRef.current = false;
|
||||
backfillBuffers(
|
||||
buffersRef.current,
|
||||
resumeData.messageHistory,
|
||||
);
|
||||
// Collect backfilled items
|
||||
const backfilledItems: StaticItem[] = [];
|
||||
for (const id of buffersRef.current.order) {
|
||||
const ln = buffersRef.current.byId.get(id);
|
||||
if (!ln) continue;
|
||||
emittedIdsRef.current.add(id);
|
||||
backfilledItems.push({ ...ln } as StaticItem);
|
||||
}
|
||||
// Add separator before backfilled messages, then success at end
|
||||
const separator = {
|
||||
kind: "separator" as const,
|
||||
id: uid("sep"),
|
||||
};
|
||||
setStaticItems([
|
||||
separator,
|
||||
...backfilledItems,
|
||||
successItem,
|
||||
]);
|
||||
setLines(toLines(buffersRef.current));
|
||||
hasBackfilledRef.current = true;
|
||||
} else {
|
||||
// Add separator for visual spacing even without backfill
|
||||
const separator = {
|
||||
kind: "separator" as const,
|
||||
id: uid("sep"),
|
||||
};
|
||||
setStaticItems([separator, successItem]);
|
||||
setLines(toLines(buffersRef.current));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorCmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(errorCmdId, {
|
||||
kind: "command",
|
||||
id: errorCmdId,
|
||||
input: inputCmd,
|
||||
output: `Failed to switch conversation: ${error instanceof Error ? error.message : String(error)}`,
|
||||
phase: "finished",
|
||||
success: false,
|
||||
});
|
||||
buffersRef.current.order.push(errorCmdId);
|
||||
refreshDerived();
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
}
|
||||
}}
|
||||
onNewConversation={async () => {
|
||||
closeOverlay();
|
||||
|
||||
// Lock input for async operation
|
||||
setCommandRunning(true);
|
||||
|
||||
const inputCmd = "/resume";
|
||||
const cmdId = uid("cmd");
|
||||
|
||||
// Show loading indicator
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: inputCmd,
|
||||
output: "Creating new conversation...",
|
||||
phase: "running",
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
|
||||
try {
|
||||
// Create a new conversation
|
||||
const client = await getClient();
|
||||
const conversation = await client.conversations.create({
|
||||
agent_id: agentId,
|
||||
});
|
||||
setConversationId(conversation.id);
|
||||
settingsManager.setLocalLastSession(
|
||||
{ agentId, conversationId: conversation.id },
|
||||
process.cwd(),
|
||||
);
|
||||
settingsManager.setGlobalLastSession({
|
||||
agentId,
|
||||
conversationId: conversation.id,
|
||||
});
|
||||
|
||||
// Clear current transcript and static items
|
||||
buffersRef.current.byId.clear();
|
||||
buffersRef.current.order = [];
|
||||
buffersRef.current.tokenCount = 0;
|
||||
emittedIdsRef.current.clear();
|
||||
setStaticItems([]);
|
||||
setStaticRenderEpoch((e) => e + 1);
|
||||
|
||||
// Build success command with agent + conversation info
|
||||
const currentAgentName =
|
||||
agentState?.name || "Unnamed Agent";
|
||||
const shortConvId = conversation.id.slice(0, 20);
|
||||
const successLines = [
|
||||
`Started new conversation with "${currentAgentName}"`,
|
||||
`⎿ Agent: ${agentId}`,
|
||||
`⎿ Conversation: ${shortConvId}... (new)`,
|
||||
];
|
||||
const successItem: StaticItem = {
|
||||
kind: "command",
|
||||
id: uid("cmd"),
|
||||
input: inputCmd,
|
||||
output: successLines.join("\n"),
|
||||
phase: "finished",
|
||||
success: true,
|
||||
};
|
||||
setStaticItems([successItem]);
|
||||
setLines(toLines(buffersRef.current));
|
||||
} catch (error) {
|
||||
const errorCmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(errorCmdId, {
|
||||
kind: "command",
|
||||
id: errorCmdId,
|
||||
input: inputCmd,
|
||||
output: `Failed to create conversation: ${error instanceof Error ? error.message : String(error)}`,
|
||||
phase: "finished",
|
||||
success: false,
|
||||
});
|
||||
buffersRef.current.order.push(errorCmdId);
|
||||
refreshDerived();
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
}
|
||||
}}
|
||||
onCancel={closeOverlay}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Message Search - conditionally mounted as overlay */}
|
||||
{activeOverlay === "search" && (
|
||||
<MessageSearch onClose={closeOverlay} />
|
||||
|
||||
@@ -6,6 +6,7 @@ type CommandHandler = (args: string[]) => Promise<string> | string;
|
||||
interface Command {
|
||||
desc: string;
|
||||
handler: CommandHandler;
|
||||
args?: string; // Optional argument syntax hint (e.g., "[conversation_id]", "<name>")
|
||||
hidden?: boolean; // Hidden commands don't show in autocomplete but still work
|
||||
order?: number; // Lower numbers appear first in autocomplete (default: 100)
|
||||
}
|
||||
@@ -68,11 +69,20 @@ export const commands: Record<string, Command> = {
|
||||
},
|
||||
},
|
||||
"/clear": {
|
||||
desc: "Clear conversation history (keep memory)",
|
||||
desc: "Start a new conversation (keep agent memory)",
|
||||
order: 17,
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to access client and agent ID
|
||||
return "Clearing messages...";
|
||||
// Handled specially in App.tsx to create new conversation
|
||||
return "Starting new conversation...";
|
||||
},
|
||||
},
|
||||
"/clear-messages": {
|
||||
desc: "Reset all agent messages (destructive)",
|
||||
order: 18,
|
||||
hidden: true, // Advanced command, not shown in autocomplete
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to reset agent messages
|
||||
return "Resetting agent messages...";
|
||||
},
|
||||
},
|
||||
|
||||
@@ -339,11 +349,12 @@ export const commands: Record<string, Command> = {
|
||||
},
|
||||
},
|
||||
"/resume": {
|
||||
desc: "Browse and switch to another agent",
|
||||
hidden: true, // Backwards compatibility alias for /agents
|
||||
desc: "Resume a previous conversation",
|
||||
args: "[conversation_id]",
|
||||
order: 19,
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to show agent selector
|
||||
return "Opening agent selector...";
|
||||
// Handled specially in App.tsx to show conversation selector or switch directly
|
||||
return "Opening conversation selector...";
|
||||
},
|
||||
},
|
||||
"/pinned": {
|
||||
|
||||
438
src/cli/components/ConversationSelector.tsx
Normal file
438
src/cli/components/ConversationSelector.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
import type { Letta } from "@letta-ai/letta-client";
|
||||
import type { Message } from "@letta-ai/letta-client/resources/agents/messages";
|
||||
import type { Conversation } from "@letta-ai/letta-client/resources/conversations/conversations";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getClient } from "../../agent/client";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
|
||||
interface ConversationSelectorProps {
|
||||
agentId: string;
|
||||
currentConversationId: string;
|
||||
onSelect: (conversationId: string) => void;
|
||||
onNewConversation: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Enriched conversation with message data
|
||||
interface EnrichedConversation {
|
||||
conversation: Conversation;
|
||||
lastUserMessage: string | null;
|
||||
lastActiveAt: string | null;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
const DISPLAY_PAGE_SIZE = 5;
|
||||
const FETCH_PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Format a relative time string from a date
|
||||
*/
|
||||
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);
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
|
||||
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 `${diffWeeks} week${diffWeeks === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract preview text from a user message
|
||||
* Content can be a string or an array of content parts like [{ type: "text", text: "..." }]
|
||||
*/
|
||||
function extractUserMessagePreview(message: Message): string | null {
|
||||
// User messages have a 'content' field
|
||||
const content = (
|
||||
message as Message & {
|
||||
content?: string | Array<{ type?: string; text?: string }>;
|
||||
}
|
||||
).content;
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
let textToShow: string | null = null;
|
||||
|
||||
if (typeof content === "string") {
|
||||
textToShow = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
// Find the last text part that isn't a system-reminder
|
||||
// (system-reminders are auto-injected context, not user text)
|
||||
for (let i = content.length - 1; i >= 0; i--) {
|
||||
const part = content[i];
|
||||
if (part?.type === "text" && part.text) {
|
||||
// Skip system-reminder blocks
|
||||
if (part.text.startsWith("<system-reminder>")) continue;
|
||||
textToShow = part.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!textToShow) return null;
|
||||
|
||||
// Truncate to a reasonable preview length
|
||||
const maxLen = 60;
|
||||
if (textToShow.length > maxLen) {
|
||||
return `${textToShow.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
return textToShow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last user message and last activity time from messages
|
||||
*/
|
||||
function getMessageStats(messages: Message[]): {
|
||||
lastUserMessage: string | null;
|
||||
lastActiveAt: string | null;
|
||||
messageCount: number;
|
||||
} {
|
||||
if (messages.length === 0) {
|
||||
return { lastUserMessage: null, lastActiveAt: null, messageCount: 0 };
|
||||
}
|
||||
|
||||
// Find last user message with actual content (searching from end)
|
||||
let lastUserMessage: string | null = null;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (!msg) continue;
|
||||
|
||||
// Check for user_message type
|
||||
if (msg.message_type === "user_message") {
|
||||
lastUserMessage = extractUserMessagePreview(msg);
|
||||
if (lastUserMessage) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Last activity is the timestamp of the last message
|
||||
// Most message types have a 'date' field for the timestamp
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const lastActiveAt =
|
||||
(lastMessage as Message & { date?: string }).date ?? null;
|
||||
|
||||
return { lastUserMessage, lastActiveAt, messageCount: messages.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate ID with middle ellipsis if it exceeds available width
|
||||
*/
|
||||
function truncateId(id: string, availableWidth: number): string {
|
||||
if (id.length <= availableWidth) return id;
|
||||
if (availableWidth < 15) return id.slice(0, availableWidth);
|
||||
const prefixLen = Math.floor((availableWidth - 3) / 2);
|
||||
const suffixLen = availableWidth - 3 - prefixLen;
|
||||
return `${id.slice(0, prefixLen)}...${id.slice(-suffixLen)}`;
|
||||
}
|
||||
|
||||
export function ConversationSelector({
|
||||
agentId,
|
||||
currentConversationId,
|
||||
onSelect,
|
||||
onNewConversation,
|
||||
onCancel,
|
||||
}: ConversationSelectorProps) {
|
||||
const terminalWidth = useTerminalWidth();
|
||||
const clientRef = useRef<Letta | null>(null);
|
||||
|
||||
// Conversation list state (enriched with message data)
|
||||
const [conversations, setConversations] = useState<EnrichedConversation[]>(
|
||||
[],
|
||||
);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Selection state
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
// Load conversations and enrich with message data
|
||||
const loadConversations = useCallback(
|
||||
async (afterCursor?: string | null) => {
|
||||
const isLoadingMore = !!afterCursor;
|
||||
if (isLoadingMore) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const client = clientRef.current || (await getClient());
|
||||
clientRef.current = client;
|
||||
|
||||
const result = await client.conversations.list({
|
||||
agent_id: agentId,
|
||||
limit: FETCH_PAGE_SIZE,
|
||||
...(afterCursor && { after: afterCursor }),
|
||||
});
|
||||
|
||||
// Enrich conversations with message data in parallel
|
||||
const enrichedConversations = await Promise.all(
|
||||
result.map(async (conv) => {
|
||||
try {
|
||||
// Fetch messages to get stats
|
||||
const messages = await client.conversations.messages.list(
|
||||
conv.id,
|
||||
);
|
||||
const stats = getMessageStats(messages);
|
||||
return {
|
||||
conversation: conv,
|
||||
lastUserMessage: stats.lastUserMessage,
|
||||
lastActiveAt: stats.lastActiveAt,
|
||||
messageCount: stats.messageCount,
|
||||
};
|
||||
} catch {
|
||||
// If we fail to fetch messages, show conversation anyway with -1 to indicate error
|
||||
return {
|
||||
conversation: conv,
|
||||
lastUserMessage: null,
|
||||
lastActiveAt: null,
|
||||
messageCount: -1, // Unknown, don't filter out
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Filter out empty conversations (messageCount === 0)
|
||||
// Keep conversations with messageCount > 0 or -1 (error/unknown)
|
||||
const nonEmptyConversations = enrichedConversations.filter(
|
||||
(c) => c.messageCount !== 0,
|
||||
);
|
||||
|
||||
const newCursor =
|
||||
result.length === FETCH_PAGE_SIZE
|
||||
? (result[result.length - 1]?.id ?? null)
|
||||
: null;
|
||||
|
||||
if (isLoadingMore) {
|
||||
setConversations((prev) => [...prev, ...nonEmptyConversations]);
|
||||
} else {
|
||||
setConversations(nonEmptyConversations);
|
||||
setPage(0);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
setCursor(newCursor);
|
||||
setHasMore(newCursor !== null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (isLoadingMore) {
|
||||
setLoadingMore(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[agentId],
|
||||
);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
}, [loadConversations]);
|
||||
|
||||
// Pagination calculations
|
||||
const totalPages = Math.ceil(conversations.length / DISPLAY_PAGE_SIZE);
|
||||
const startIndex = page * DISPLAY_PAGE_SIZE;
|
||||
const pageConversations = conversations.slice(
|
||||
startIndex,
|
||||
startIndex + DISPLAY_PAGE_SIZE,
|
||||
);
|
||||
const canGoNext = page < totalPages - 1 || hasMore;
|
||||
|
||||
// Fetch more when needed
|
||||
const fetchMore = useCallback(async () => {
|
||||
if (loadingMore || !hasMore || !cursor) return;
|
||||
await loadConversations(cursor);
|
||||
}, [loadingMore, hasMore, cursor, loadConversations]);
|
||||
|
||||
useInput((input, key) => {
|
||||
// CTRL-C: immediately cancel
|
||||
if (key.ctrl && input === "c") {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (loading) return;
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) =>
|
||||
Math.min(pageConversations.length - 1, prev + 1),
|
||||
);
|
||||
} else if (key.return) {
|
||||
const selected = pageConversations[selectedIndex];
|
||||
if (selected?.conversation.id) {
|
||||
onSelect(selected.conversation.id);
|
||||
}
|
||||
} else if (key.escape) {
|
||||
onCancel();
|
||||
} else if (input === "n" || input === "N") {
|
||||
// New conversation
|
||||
onNewConversation();
|
||||
} else if (input === "j" || input === "J") {
|
||||
// Previous page
|
||||
if (page > 0) {
|
||||
setPage((prev) => prev - 1);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} else if (input === "k" || input === "K") {
|
||||
// Next page
|
||||
if (canGoNext) {
|
||||
const nextPageIndex = page + 1;
|
||||
const nextStartIndex = nextPageIndex * DISPLAY_PAGE_SIZE;
|
||||
|
||||
if (nextStartIndex >= conversations.length && hasMore) {
|
||||
fetchMore();
|
||||
}
|
||||
|
||||
if (nextStartIndex < conversations.length) {
|
||||
setPage(nextPageIndex);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Render conversation item
|
||||
const renderConversationItem = (
|
||||
enrichedConv: EnrichedConversation,
|
||||
_index: number,
|
||||
isSelected: boolean,
|
||||
) => {
|
||||
const {
|
||||
conversation: conv,
|
||||
lastUserMessage,
|
||||
lastActiveAt,
|
||||
messageCount,
|
||||
} = enrichedConv;
|
||||
const isCurrent = conv.id === currentConversationId;
|
||||
const displayId = truncateId(conv.id, Math.min(40, terminalWidth - 30));
|
||||
|
||||
// Format timestamps
|
||||
const activeTime = formatRelativeTime(lastActiveAt);
|
||||
const createdTime = formatRelativeTime(conv.created_at);
|
||||
|
||||
// Preview text: prefer last user message, fall back to summary or message count
|
||||
let previewText: string;
|
||||
if (lastUserMessage) {
|
||||
previewText = lastUserMessage;
|
||||
} else if (conv.summary) {
|
||||
previewText = conv.summary;
|
||||
} else if (messageCount > 0) {
|
||||
previewText = `${messageCount} message${messageCount === 1 ? "" : "s"}`;
|
||||
} else {
|
||||
previewText = "No preview";
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={conv.id} flexDirection="column" marginBottom={1}>
|
||||
<Box flexDirection="row">
|
||||
<Text
|
||||
color={isSelected ? colors.selector.itemHighlighted : undefined}
|
||||
>
|
||||
{isSelected ? ">" : " "}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={isSelected ? colors.selector.itemHighlighted : undefined}
|
||||
>
|
||||
{displayId}
|
||||
</Text>
|
||||
{isCurrent && (
|
||||
<Text color={colors.selector.itemCurrent}> (current)</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="row" marginLeft={2}>
|
||||
<Text dimColor italic>
|
||||
{previewText}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row" marginLeft={2}>
|
||||
<Text dimColor>
|
||||
Active {activeTime} · Created {createdTime}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Header */}
|
||||
<Box flexDirection="column" gap={1} marginBottom={1}>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Resume Conversation
|
||||
</Text>
|
||||
<Text dimColor>Select a conversation to resume or start a new one</Text>
|
||||
</Box>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<Box flexDirection="column">
|
||||
<Text color="red">Error: {error}</Text>
|
||||
<Text dimColor>Press ESC to cancel</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<Box>
|
||||
<Text dimColor>Loading conversations...</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && !error && conversations.length === 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>No conversations found</Text>
|
||||
<Text dimColor>Press N to start a new conversation</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Conversation list */}
|
||||
{!loading && !error && conversations.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
{pageConversations.map((conv, index) =>
|
||||
renderConversationItem(conv, index, index === selectedIndex),
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{!loading && !error && conversations.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Page {page + 1}
|
||||
{hasMore ? "+" : `/${totalPages || 1}`}
|
||||
{loadingMore ? " (loading...)" : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
↑↓ navigate · Enter select · J/K page · N new · ESC cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -431,12 +431,17 @@ export function ResumeSelector({
|
||||
|
||||
if (currentLoading) return;
|
||||
|
||||
// For pinned tab, use pinnedPageAgents.length to include "not found" entries
|
||||
// For other tabs, use currentAgents.length
|
||||
const maxIndex =
|
||||
activeTab === "pinned"
|
||||
? pinnedPageAgents.length - 1
|
||||
: (currentAgents as AgentState[]).length - 1;
|
||||
|
||||
if (key.upArrow) {
|
||||
setCurrentSelectedIndex((prev: number) => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow) {
|
||||
setCurrentSelectedIndex((prev: number) =>
|
||||
Math.min((currentAgents as AgentState[]).length - 1, prev + 1),
|
||||
);
|
||||
setCurrentSelectedIndex((prev: number) => Math.min(maxIndex, prev + 1));
|
||||
} else if (key.return) {
|
||||
// If typing a search query (list tabs only), submit it
|
||||
if (
|
||||
@@ -526,13 +531,14 @@ export function ResumeSelector({
|
||||
setAllSelectedIndex(0);
|
||||
}
|
||||
}
|
||||
} else if (activeTab === "pinned" && (input === "d" || input === "D")) {
|
||||
// Unpin from all (pinned tab only)
|
||||
const selected = pinnedPageAgents[pinnedSelectedIndex];
|
||||
if (selected) {
|
||||
settingsManager.unpinBoth(selected.agentId);
|
||||
loadPinnedAgents();
|
||||
}
|
||||
// NOTE: "D" for unpin all disabled - too destructive without confirmation
|
||||
// } else if (activeTab === "pinned" && (input === "d" || input === "D")) {
|
||||
// const selected = pinnedPageAgents[pinnedSelectedIndex];
|
||||
// if (selected) {
|
||||
// settingsManager.unpinBoth(selected.agentId);
|
||||
// loadPinnedAgents();
|
||||
// }
|
||||
// }
|
||||
} else if (activeTab === "pinned" && (input === "p" || input === "P")) {
|
||||
// Unpin from current scope (pinned tab only)
|
||||
const selected = pinnedPageAgents[pinnedSelectedIndex];
|
||||
@@ -773,9 +779,7 @@ export function ResumeSelector({
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Tab switch · ↑↓ navigate · Enter select · J/K page
|
||||
{activeTab === "pinned"
|
||||
? " · P unpin · D unpin all"
|
||||
: " · Type to search"}
|
||||
{activeTab === "pinned" ? " · P unpin" : " · Type to search"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -444,10 +444,23 @@ export async function handleHeadlessCommand(
|
||||
}
|
||||
}
|
||||
|
||||
// Save agent ID to both project and global settings
|
||||
// Always create a new conversation on startup for headless mode too
|
||||
// This ensures isolated message history per CLI invocation
|
||||
const conversation = await client.conversations.create({
|
||||
agent_id: agent.id,
|
||||
});
|
||||
const conversationId = conversation.id;
|
||||
|
||||
// Save session (agent + conversation) to both project and global settings
|
||||
await settingsManager.loadLocalProjectSettings();
|
||||
settingsManager.updateLocalProjectSettings({ lastAgent: agent.id });
|
||||
settingsManager.updateSettings({ lastAgent: agent.id });
|
||||
settingsManager.setLocalLastSession(
|
||||
{ agentId: agent.id, conversationId },
|
||||
process.cwd(),
|
||||
);
|
||||
settingsManager.setGlobalLastSession({
|
||||
agentId: agent.id,
|
||||
conversationId,
|
||||
});
|
||||
|
||||
// Set agent context for tools that need it (e.g., Skill tool, Task tool)
|
||||
setAgentContext(agent.id, skillsDirectory);
|
||||
@@ -508,6 +521,7 @@ export async function handleHeadlessCommand(
|
||||
if (isBidirectionalMode) {
|
||||
await runBidirectionalMode(
|
||||
agent,
|
||||
conversationId,
|
||||
client,
|
||||
outputFormat,
|
||||
includePartialMessages,
|
||||
@@ -661,7 +675,9 @@ export async function handleHeadlessCommand(
|
||||
};
|
||||
|
||||
// Send the approval to clear the pending state; drain the stream without output
|
||||
const approvalStream = await sendMessageStream(agent.id, [approvalInput]);
|
||||
const approvalStream = await sendMessageStream(conversationId, [
|
||||
approvalInput,
|
||||
]);
|
||||
if (outputFormat === "stream-json") {
|
||||
// Consume quickly but don't emit message frames to stdout
|
||||
for await (const _ of approvalStream) {
|
||||
@@ -710,7 +726,7 @@ export async function handleHeadlessCommand(
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const stream = await sendMessageStream(agent.id, currentInput);
|
||||
const stream = await sendMessageStream(conversationId, currentInput);
|
||||
|
||||
// For stream-json, output each chunk as it arrives
|
||||
let stopReason: StopReasonType;
|
||||
@@ -1508,6 +1524,7 @@ export async function handleHeadlessCommand(
|
||||
*/
|
||||
async function runBidirectionalMode(
|
||||
agent: AgentState,
|
||||
conversationId: string,
|
||||
_client: Letta,
|
||||
_outputFormat: string,
|
||||
includePartialMessages: boolean,
|
||||
@@ -1749,7 +1766,7 @@ async function runBidirectionalMode(
|
||||
}
|
||||
|
||||
// Send message to agent
|
||||
const stream = await sendMessageStream(agent.id, currentInput);
|
||||
const stream = await sendMessageStream(conversationId, currentInput);
|
||||
|
||||
// Track stop reason and approvals during this stream
|
||||
let stopReason: StopReasonType = "error";
|
||||
|
||||
193
src/index.ts
193
src/index.ts
@@ -320,16 +320,23 @@ async function main(): Promise<void> {
|
||||
});
|
||||
|
||||
// Parse command-line arguments (Bun-idiomatic approach using parseArgs)
|
||||
// Preprocess args to support --conv as alias for --conversation
|
||||
const processedArgs = process.argv.map((arg) =>
|
||||
arg === "--conv" ? "--conversation" : arg,
|
||||
);
|
||||
|
||||
let values: Record<string, unknown>;
|
||||
let positionals: string[];
|
||||
try {
|
||||
const parsed = parseArgs({
|
||||
args: process.argv,
|
||||
args: processedArgs,
|
||||
options: {
|
||||
help: { type: "boolean", short: "h" },
|
||||
version: { type: "boolean", short: "v" },
|
||||
info: { type: "boolean" },
|
||||
continue: { type: "boolean", short: "c" },
|
||||
continue: { type: "boolean" }, // Deprecated - kept for error message
|
||||
resume: { type: "boolean", short: "r" }, // Resume last session (or specific conversation with --conversation)
|
||||
conversation: { type: "string", short: "C" }, // Specific conversation ID to resume (--conv alias supported)
|
||||
new: { type: "boolean" },
|
||||
"init-blocks": { type: "string" },
|
||||
"base-tools": { type: "string" },
|
||||
@@ -406,7 +413,18 @@ async function main(): Promise<void> {
|
||||
process.exit(result.success ? 0 : 1);
|
||||
}
|
||||
|
||||
const shouldContinue = (values.continue as boolean | undefined) ?? false;
|
||||
// Check for deprecated --continue flag
|
||||
if (values.continue) {
|
||||
console.error(
|
||||
"Error: --continue is deprecated. Did you mean --resume (-r)?\n" +
|
||||
" --resume resumes your last session (agent + conversation)",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const shouldResume = (values.resume as boolean | undefined) ?? false; // Resume last session
|
||||
const specifiedConversationId =
|
||||
(values.conversation as string | undefined) ?? null; // Specific conversation to resume
|
||||
const forceNew = (values.new as boolean | undefined) ?? false;
|
||||
const initBlocksRaw = values["init-blocks"] as string | undefined;
|
||||
const baseToolsRaw = values["base-tools"] as string | undefined;
|
||||
@@ -556,8 +574,8 @@ async function main(): Promise<void> {
|
||||
console.error("Error: --from-af cannot be used with --name");
|
||||
process.exit(1);
|
||||
}
|
||||
if (shouldContinue) {
|
||||
console.error("Error: --from-af cannot be used with --continue");
|
||||
if (shouldResume) {
|
||||
console.error("Error: --from-af cannot be used with --resume");
|
||||
process.exit(1);
|
||||
}
|
||||
if (forceNew) {
|
||||
@@ -806,8 +824,11 @@ async function main(): Promise<void> {
|
||||
>("selecting");
|
||||
const [agentId, setAgentId] = useState<string | null>(null);
|
||||
const [agentState, setAgentState] = useState<AgentState | null>(null);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [resumeData, setResumeData] = useState<ResumeData | null>(null);
|
||||
const [isResumingSession, setIsResumingSession] = useState(false);
|
||||
const [resumedExistingConversation, setResumedExistingConversation] =
|
||||
useState(false);
|
||||
const [agentProvenance, setAgentProvenance] =
|
||||
useState<AgentProvenance | null>(null);
|
||||
const [selectedGlobalAgentId, setSelectedGlobalAgentId] = useState<
|
||||
@@ -895,19 +916,46 @@ async function main(): Promise<void> {
|
||||
// Load settings
|
||||
await settingsManager.loadLocalProjectSettings();
|
||||
const localSettings = settingsManager.getLocalProjectSettings();
|
||||
const globalPinned = settingsManager.getGlobalPinnedAgents();
|
||||
let globalPinned = settingsManager.getGlobalPinnedAgents();
|
||||
|
||||
// Check if user would see selector (fresh dir, no bypass flags)
|
||||
const wouldShowSelector =
|
||||
!localSettings.lastAgent &&
|
||||
!forceNew &&
|
||||
!agentIdArg &&
|
||||
!fromAfFile &&
|
||||
!continueSession;
|
||||
|
||||
// Ensure default agents (Memo/Incognito) exist for all users
|
||||
const client = await getClient();
|
||||
const { ensureDefaultAgents } = await import("./agent/defaults");
|
||||
|
||||
if (wouldShowSelector && globalPinned.length === 0) {
|
||||
// New user with no agents - block and show loading while creating defaults
|
||||
setLoadingState("assembling");
|
||||
try {
|
||||
await ensureDefaultAgents(client);
|
||||
// Refresh pinned list after defaults created
|
||||
globalPinned = settingsManager.getGlobalPinnedAgents();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Warning: Failed to create default agents: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Existing user - fire and forget, don't block startup
|
||||
ensureDefaultAgents(client).catch((err) =>
|
||||
console.warn(
|
||||
`Warning: Failed to ensure default agents: ${err instanceof Error ? err.message : String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Show selector if:
|
||||
// 1. No lastAgent in this project (fresh directory)
|
||||
// 2. No explicit flags that bypass selection (--new, --agent, --from-af, --continue)
|
||||
// 3. Has global pinned agents available
|
||||
const shouldShowSelector =
|
||||
!localSettings.lastAgent &&
|
||||
!forceNew &&
|
||||
!agentIdArg &&
|
||||
!fromAfFile &&
|
||||
!continueSession &&
|
||||
globalPinned.length > 0;
|
||||
const shouldShowSelector = wouldShowSelector && globalPinned.length > 0;
|
||||
|
||||
if (shouldShowSelector) {
|
||||
setLoadingState("selecting_global");
|
||||
@@ -939,8 +987,21 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: LRU from local settings (if not --new or user explicitly requested new from selector)
|
||||
// Priority 2: Use agent selected from global selector (user just picked one)
|
||||
// This takes precedence over stale LRU since user explicitly chose it
|
||||
const shouldCreateNew = forceNew || userRequestedNewAgent;
|
||||
if (!resumingAgentId && !shouldCreateNew && selectedGlobalAgentId) {
|
||||
try {
|
||||
await client.agents.retrieve(selectedGlobalAgentId);
|
||||
resumingAgentId = selectedGlobalAgentId;
|
||||
} catch {
|
||||
// Selected agent doesn't exist - show selector again
|
||||
setLoadingState("selecting_global");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: LRU from local settings (if not --new or user explicitly requested new from selector)
|
||||
if (!resumingAgentId && !shouldCreateNew) {
|
||||
const localProjectSettings =
|
||||
settingsManager.getLocalProjectSettings();
|
||||
@@ -956,7 +1017,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Try global settings if --continue flag
|
||||
// Priority 4: Try global settings if --continue flag
|
||||
if (!resumingAgentId && continueSession && settings.lastAgent) {
|
||||
try {
|
||||
await client.agents.retrieve(settings.lastAgent);
|
||||
@@ -967,16 +1028,6 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Use agent selected from global selector
|
||||
if (!resumingAgentId && selectedGlobalAgentId) {
|
||||
try {
|
||||
await client.agents.retrieve(selectedGlobalAgentId);
|
||||
resumingAgentId = selectedGlobalAgentId;
|
||||
} catch {
|
||||
// Agent doesn't exist, will create new
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set resuming state early so loading messages are accurate
|
||||
@@ -1171,7 +1222,7 @@ async function main(): Promise<void> {
|
||||
// Check if we're resuming an existing agent
|
||||
// We're resuming if:
|
||||
// 1. We specified an agent ID via --agent flag (agentIdArg)
|
||||
// 2. We used --continue flag (continueSession)
|
||||
// 2. We used --resume flag (continueSession)
|
||||
// 3. We're reusing a project agent (detected early as resumingAgentId)
|
||||
// 4. We retrieved an agent from LRU (detected by checking if agent already existed)
|
||||
const isResumingProject = !shouldCreateNew && !!resumingAgentId;
|
||||
@@ -1224,15 +1275,83 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Get resume data (pending approval + message history) if resuming
|
||||
if (resuming) {
|
||||
setLoadingState("checking");
|
||||
const data = await getResumeData(client, agent);
|
||||
setResumeData(data);
|
||||
// Handle conversation: either resume existing or create new
|
||||
let conversationIdToUse: string;
|
||||
|
||||
// Debug: log resume flag status
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[DEBUG] shouldResume=${shouldResume}`);
|
||||
console.log(
|
||||
`[DEBUG] specifiedConversationId=${specifiedConversationId}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (specifiedConversationId) {
|
||||
// Use the explicitly specified conversation ID
|
||||
conversationIdToUse = specifiedConversationId;
|
||||
setResumedExistingConversation(true);
|
||||
|
||||
// Load message history and pending approvals from the conversation
|
||||
setLoadingState("checking");
|
||||
const data = await getResumeData(
|
||||
client,
|
||||
agent,
|
||||
specifiedConversationId,
|
||||
);
|
||||
setResumeData(data);
|
||||
} else if (shouldResume) {
|
||||
// Try to load the last session for this agent
|
||||
const lastSession =
|
||||
settingsManager.getLocalLastSession(process.cwd()) ??
|
||||
settingsManager.getGlobalLastSession();
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[DEBUG] lastSession=${JSON.stringify(lastSession)}`);
|
||||
console.log(`[DEBUG] agent.id=${agent.id}`);
|
||||
}
|
||||
|
||||
if (lastSession && lastSession.agentId === agent.id) {
|
||||
// Resume the exact last conversation
|
||||
conversationIdToUse = lastSession.conversationId;
|
||||
setResumedExistingConversation(true);
|
||||
|
||||
// Load message history and pending approvals from the conversation
|
||||
setLoadingState("checking");
|
||||
const data = await getResumeData(
|
||||
client,
|
||||
agent,
|
||||
lastSession.conversationId,
|
||||
);
|
||||
setResumeData(data);
|
||||
} else {
|
||||
// No valid session to resume for this agent, create new
|
||||
const conversation = await client.conversations.create({
|
||||
agent_id: agent.id,
|
||||
});
|
||||
conversationIdToUse = conversation.id;
|
||||
}
|
||||
} else {
|
||||
// Default: create a new conversation on startup
|
||||
// This ensures each CLI session has isolated message history
|
||||
const conversation = await client.conversations.create({
|
||||
agent_id: agent.id,
|
||||
});
|
||||
conversationIdToUse = conversation.id;
|
||||
}
|
||||
|
||||
// Save the session (agent + conversation) to settings
|
||||
settingsManager.setLocalLastSession(
|
||||
{ agentId: agent.id, conversationId: conversationIdToUse },
|
||||
process.cwd(),
|
||||
);
|
||||
settingsManager.setGlobalLastSession({
|
||||
agentId: agent.id,
|
||||
conversationId: conversationIdToUse,
|
||||
});
|
||||
|
||||
setAgentId(agent.id);
|
||||
setAgentState(agent);
|
||||
setConversationId(conversationIdToUse);
|
||||
setLoadingState("ready");
|
||||
}
|
||||
|
||||
@@ -1256,6 +1375,7 @@ async function main(): Promise<void> {
|
||||
fromAfFile,
|
||||
loadingState,
|
||||
selectedGlobalAgentId,
|
||||
shouldResume,
|
||||
]);
|
||||
|
||||
// Wait for keybinding auto-install to complete before showing UI
|
||||
@@ -1275,9 +1395,6 @@ async function main(): Promise<void> {
|
||||
loading: false,
|
||||
freshRepoMode: true, // Hides "(global)" labels and simplifies context message
|
||||
onSelect: (agentId: string) => {
|
||||
// Auto-pin the selected global agent to this project
|
||||
settingsManager.pinLocal(agentId);
|
||||
|
||||
setSelectedGlobalAgentId(agentId);
|
||||
setLoadingState("assembling");
|
||||
},
|
||||
@@ -1291,14 +1408,16 @@ async function main(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
if (!agentId) {
|
||||
if (!agentId || !conversationId) {
|
||||
return React.createElement(App, {
|
||||
agentId: "loading",
|
||||
conversationId: "loading",
|
||||
loadingState,
|
||||
continueSession: isResumingSession,
|
||||
startupApproval: resumeData?.pendingApproval ?? null,
|
||||
startupApprovals: resumeData?.pendingApprovals ?? EMPTY_APPROVAL_ARRAY,
|
||||
messageHistory: resumeData?.messageHistory ?? EMPTY_MESSAGE_ARRAY,
|
||||
resumedExistingConversation,
|
||||
tokenStreaming: settings.tokenStreaming,
|
||||
agentProvenance,
|
||||
});
|
||||
@@ -1307,11 +1426,13 @@ async function main(): Promise<void> {
|
||||
return React.createElement(App, {
|
||||
agentId,
|
||||
agentState,
|
||||
conversationId,
|
||||
loadingState,
|
||||
continueSession: isResumingSession,
|
||||
startupApproval: resumeData?.pendingApproval ?? null,
|
||||
startupApprovals: resumeData?.pendingApprovals ?? EMPTY_APPROVAL_ARRAY,
|
||||
messageHistory: resumeData?.messageHistory ?? EMPTY_MESSAGE_ARRAY,
|
||||
resumedExistingConversation,
|
||||
tokenStreaming: settings.tokenStreaming,
|
||||
agentProvenance,
|
||||
});
|
||||
@@ -1319,7 +1440,7 @@ async function main(): Promise<void> {
|
||||
|
||||
render(
|
||||
React.createElement(LoadingApp, {
|
||||
continueSession: shouldContinue,
|
||||
continueSession: shouldResume,
|
||||
forceNew: forceNew,
|
||||
initBlocks: initBlocks,
|
||||
baseTools: baseTools,
|
||||
|
||||
@@ -199,11 +199,7 @@ class PermissionModeManager {
|
||||
}
|
||||
|
||||
// Allow if target is any .md file in the plans directory
|
||||
if (
|
||||
targetPath &&
|
||||
targetPath.startsWith(plansDir) &&
|
||||
targetPath.endsWith(".md")
|
||||
) {
|
||||
if (targetPath?.startsWith(plansDir) && targetPath.endsWith(".md")) {
|
||||
return "allow";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,18 @@ import {
|
||||
setSecureTokens,
|
||||
} from "./utils/secrets.js";
|
||||
|
||||
/**
|
||||
* Reference to a session (agent + conversation pair).
|
||||
* Always tracked together since a conversation belongs to exactly one agent.
|
||||
*/
|
||||
export interface SessionRef {
|
||||
agentId: string;
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
lastAgent: string | null;
|
||||
lastAgent: string | null; // DEPRECATED: kept for migration to lastSession
|
||||
lastSession?: SessionRef; // Current session (agent + conversation)
|
||||
tokenStreaming: boolean;
|
||||
enableSleeptime: boolean;
|
||||
sessionContextEnabled: boolean; // Send device/agent context on first message of each session
|
||||
@@ -23,6 +33,7 @@ export interface Settings {
|
||||
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
|
||||
createDefaultAgents?: boolean; // Create Memo/Incognito default agents on startup (default: true)
|
||||
permissions?: PermissionRules;
|
||||
env?: Record<string, string>;
|
||||
// Letta Cloud OAuth token management (stored separately in secrets)
|
||||
@@ -50,7 +61,8 @@ export interface ProjectSettings {
|
||||
}
|
||||
|
||||
export interface LocalProjectSettings {
|
||||
lastAgent: string | null;
|
||||
lastAgent: string | null; // DEPRECATED: kept for migration to lastSession
|
||||
lastSession?: SessionRef; // Current session (agent + conversation)
|
||||
permissions?: PermissionRules;
|
||||
profiles?: Record<string, string>; // DEPRECATED: old format, kept for migration
|
||||
pinnedAgents?: string[]; // Array of agent IDs pinned locally
|
||||
@@ -630,6 +642,118 @@ class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Session Management Helpers
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* Get the last session from global settings.
|
||||
* Migrates from lastAgent if lastSession is not set.
|
||||
* Returns null if no session is available.
|
||||
*/
|
||||
getGlobalLastSession(): SessionRef | null {
|
||||
const settings = this.getSettings();
|
||||
if (settings.lastSession) {
|
||||
return settings.lastSession;
|
||||
}
|
||||
// Migration: if lastAgent exists but lastSession doesn't, return null
|
||||
// (caller will need to create a new conversation for this agent)
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last agent ID from global settings (for migration purposes).
|
||||
* Returns the agentId from lastSession if available, otherwise falls back to lastAgent.
|
||||
*/
|
||||
getGlobalLastAgentId(): string | null {
|
||||
const settings = this.getSettings();
|
||||
if (settings.lastSession) {
|
||||
return settings.lastSession.agentId;
|
||||
}
|
||||
return settings.lastAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the last session in global settings.
|
||||
*/
|
||||
setGlobalLastSession(session: SessionRef): void {
|
||||
this.updateSettings({ lastSession: session, lastAgent: session.agentId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last session from local project settings.
|
||||
* Migrates from lastAgent if lastSession is not set.
|
||||
* Returns null if no session is available.
|
||||
*/
|
||||
getLocalLastSession(
|
||||
workingDirectory: string = process.cwd(),
|
||||
): SessionRef | null {
|
||||
const localSettings = this.getLocalProjectSettings(workingDirectory);
|
||||
if (localSettings.lastSession) {
|
||||
return localSettings.lastSession;
|
||||
}
|
||||
// Migration: if lastAgent exists but lastSession doesn't, return null
|
||||
// (caller will need to create a new conversation for this agent)
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last agent ID from local project settings (for migration purposes).
|
||||
* Returns the agentId from lastSession if available, otherwise falls back to lastAgent.
|
||||
*/
|
||||
getLocalLastAgentId(workingDirectory: string = process.cwd()): string | null {
|
||||
const localSettings = this.getLocalProjectSettings(workingDirectory);
|
||||
if (localSettings.lastSession) {
|
||||
return localSettings.lastSession.agentId;
|
||||
}
|
||||
return localSettings.lastAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the last session in local project settings.
|
||||
*/
|
||||
setLocalLastSession(
|
||||
session: SessionRef,
|
||||
workingDirectory: string = process.cwd(),
|
||||
): void {
|
||||
this.updateLocalProjectSettings(
|
||||
{ lastSession: session, lastAgent: session.agentId },
|
||||
workingDirectory,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective last session (local overrides global).
|
||||
* Returns null if no session is available anywhere.
|
||||
*/
|
||||
getEffectiveLastSession(
|
||||
workingDirectory: string = process.cwd(),
|
||||
): SessionRef | null {
|
||||
// Check local first
|
||||
const localSession = this.getLocalLastSession(workingDirectory);
|
||||
if (localSession) {
|
||||
return localSession;
|
||||
}
|
||||
// Fall back to global
|
||||
return this.getGlobalLastSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective last agent ID (local overrides global).
|
||||
* Useful for migration when we need an agent but don't have a conversation yet.
|
||||
*/
|
||||
getEffectiveLastAgentId(
|
||||
workingDirectory: string = process.cwd(),
|
||||
): string | null {
|
||||
// Check local first
|
||||
const localAgentId = this.getLocalLastAgentId(workingDirectory);
|
||||
if (localAgentId) {
|
||||
return localAgentId;
|
||||
}
|
||||
// Fall back to global
|
||||
return this.getGlobalLastAgentId();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Profile Management Helpers
|
||||
// =====================================================================
|
||||
@@ -775,6 +899,15 @@ class SettingsManager {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if default agents (Memo/Incognito) should be created on startup.
|
||||
* Defaults to true if not explicitly set to false.
|
||||
*/
|
||||
shouldCreateDefaultAgents(): boolean {
|
||||
const settings = this.getSettings();
|
||||
return settings.createDefaultAgents !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin an agent globally
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Quick sanity check: create an agent, send a message, log streamed output.
|
||||
*/
|
||||
|
||||
import { getClient } from "../agent/client";
|
||||
import { createAgent } from "../agent/create";
|
||||
import { sendMessageStream } from "../agent/message";
|
||||
|
||||
@@ -13,12 +14,20 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
|
||||
console.log("🧠 Creating test agent...");
|
||||
const { agent } = await createAgent("smoke-agent", "openai/gpt-4.1");
|
||||
console.log(`✅ Agent created: ${agent.id}`);
|
||||
|
||||
console.log("📝 Creating conversation...");
|
||||
const conversation = await client.conversations.create({
|
||||
agent_id: agent.id,
|
||||
});
|
||||
console.log(`✅ Conversation created: ${conversation.id}`);
|
||||
|
||||
console.log("💬 Sending test message...");
|
||||
const stream = await sendMessageStream(agent.id, [
|
||||
const stream = await sendMessageStream(conversation.id, [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hello from Bun smoke test! Try calling a tool.",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { getClient } from "../agent/client";
|
||||
import { createAgent } from "../agent/create";
|
||||
import { sendMessageStream } from "../agent/message";
|
||||
|
||||
@@ -10,17 +11,26 @@ async function main() {
|
||||
writeFileSync(testImagePath, Buffer.from(testImageBase64, "base64"));
|
||||
console.log("Created test image at", testImagePath);
|
||||
|
||||
const client = await getClient();
|
||||
|
||||
// Create agent
|
||||
console.log("\nCreating test agent...");
|
||||
const { agent } = await createAgent("image-test-agent");
|
||||
console.log("Agent created:", agent.id);
|
||||
|
||||
// Create conversation
|
||||
console.log("Creating conversation...");
|
||||
const conversation = await client.conversations.create({
|
||||
agent_id: agent.id,
|
||||
});
|
||||
console.log("Conversation created:", conversation.id);
|
||||
|
||||
// Read image
|
||||
const imageData = readFileSync(testImagePath).toString("base64");
|
||||
|
||||
// Send message with image
|
||||
console.log("\nSending image to agent...");
|
||||
const stream = await sendMessageStream(agent.id, [
|
||||
const stream = await sendMessageStream(conversation.id, [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
|
||||
@@ -283,8 +283,11 @@ export async function skill(args: SkillArgs): Promise<SkillResult> {
|
||||
const { content: skillContent, path: skillPath } =
|
||||
await readSkillContent(skillId, skillsDir);
|
||||
|
||||
// Replace placeholder if this is the first skill
|
||||
if (currentValue === "[CURRENTLY EMPTY]") {
|
||||
// Replace placeholder if this is the first skill (support old and new formats)
|
||||
if (
|
||||
currentValue === "No skills currently loaded." ||
|
||||
currentValue === "[CURRENTLY EMPTY]"
|
||||
) {
|
||||
currentValue = "";
|
||||
}
|
||||
|
||||
@@ -378,7 +381,7 @@ export async function skill(args: SkillArgs): Promise<SkillResult> {
|
||||
// Clean up the value
|
||||
currentValue = currentValue.trim();
|
||||
if (currentValue === "") {
|
||||
currentValue = "[CURRENTLY EMPTY]";
|
||||
currentValue = "No skills currently loaded.";
|
||||
}
|
||||
|
||||
// Update the block
|
||||
|
||||
@@ -54,6 +54,14 @@ export async function ensureCorrectMemoryTool(
|
||||
const currentTools = agentWithTools.tools || [];
|
||||
const mapByName = new Map(currentTools.map((t) => [t.name, t.id]));
|
||||
|
||||
// If agent has no memory tool at all, don't add one
|
||||
// This preserves stateless agents (like Incognito) that intentionally have no memory
|
||||
const hasAnyMemoryTool =
|
||||
mapByName.has("memory") || mapByName.has("memory_apply_patch");
|
||||
if (!hasAnyMemoryTool) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which memory tool we want
|
||||
// Only OpenAI (Codex) uses memory_apply_patch; Claude and Gemini use memory
|
||||
const desiredMemoryTool = shouldUsePatch ? "memory_apply_patch" : "memory";
|
||||
|
||||
Reference in New Issue
Block a user