refactor: use conversations (#475)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-13 16:40:59 -08:00
committed by GitHub
parent 3615247d14
commit ef7d8c98df
26 changed files with 1572 additions and 168 deletions

View File

@@ -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=="],

View File

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

View File

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

View File

@@ -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
View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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.

View File

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

View File

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

View File

@@ -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} />

View File

@@ -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": {

View 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>
);
}

View File

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

View File

@@ -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";

View File

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

View File

@@ -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";
}
}

View File

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

View File

@@ -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.",

View File

@@ -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: [

View File

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

View File

@@ -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";