feat: add project-level agent persistence with auto-resume (#17)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,10 +1,14 @@
|
||||
# Letta Code local settings
|
||||
.letta/settings.local.json
|
||||
.letta
|
||||
|
||||
.idea
|
||||
node_modules
|
||||
bun.lockb
|
||||
bin/
|
||||
letta.js
|
||||
letta.js.map
|
||||
.DS_Store
|
||||
.letta
|
||||
|
||||
# Logs
|
||||
logs
|
||||
@@ -145,7 +149,3 @@ dist
|
||||
# Vite logs files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Letta Code local settings
|
||||
.letta/settings.local.json
|
||||
.idea
|
||||
|
||||
@@ -18,6 +18,7 @@ export async function handleHeadlessCommand(argv: string[]) {
|
||||
args: argv,
|
||||
options: {
|
||||
continue: { type: "boolean", short: "c" },
|
||||
new: { type: "boolean" },
|
||||
agent: { type: "string", short: "a" },
|
||||
"output-format": { type: "string" },
|
||||
},
|
||||
@@ -51,7 +52,9 @@ export async function handleHeadlessCommand(argv: string[]) {
|
||||
let agent: Letta.AgentState | null = null;
|
||||
const specifiedAgentId = values.agent as string | undefined;
|
||||
const shouldContinue = values.continue as boolean | undefined;
|
||||
const forceNew = values.new as boolean | undefined;
|
||||
|
||||
// Priority 1: Try to use --agent specified ID
|
||||
if (specifiedAgentId) {
|
||||
try {
|
||||
agent = await client.agents.retrieve(specifiedAgentId);
|
||||
@@ -60,6 +63,27 @@ export async function handleHeadlessCommand(argv: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Check if --new flag was passed (skip all resume logic)
|
||||
if (!agent && forceNew) {
|
||||
agent = await createAgent();
|
||||
}
|
||||
|
||||
// Priority 3: Try to resume from project settings (.letta/settings.local.json)
|
||||
if (!agent) {
|
||||
const { loadProjectSettings } = await import("./settings");
|
||||
const projectSettings = await loadProjectSettings();
|
||||
if (projectSettings?.lastAgent) {
|
||||
try {
|
||||
agent = await client.agents.retrieve(projectSettings.lastAgent);
|
||||
} catch (_error) {
|
||||
console.error(
|
||||
`Project agent ${projectSettings.lastAgent} not found, creating new one...`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Try to reuse global lastAgent if --continue flag is passed
|
||||
if (!agent && shouldContinue && settings.lastAgent) {
|
||||
try {
|
||||
agent = await client.agents.retrieve(settings.lastAgent);
|
||||
@@ -70,11 +94,16 @@ export async function handleHeadlessCommand(argv: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 5: Create a new agent
|
||||
if (!agent) {
|
||||
agent = await createAgent();
|
||||
await updateSettings({ lastAgent: agent.id });
|
||||
}
|
||||
|
||||
// Save agent ID to both project and global settings
|
||||
const { updateProjectSettings } = await import("./settings");
|
||||
await updateProjectSettings({ lastAgent: agent.id });
|
||||
await updateSettings({ lastAgent: agent.id });
|
||||
|
||||
// Validate output format
|
||||
const outputFormat =
|
||||
(values["output-format"] as string | undefined) || "text";
|
||||
|
||||
75
src/index.ts
75
src/index.ts
@@ -14,26 +14,32 @@ Letta Code is a general purpose CLI for interacting with Letta agents
|
||||
|
||||
USAGE
|
||||
# interactive TUI
|
||||
letta Start a new agent session
|
||||
letta --continue Resume the last agent session
|
||||
letta Auto-resume project agent (from .letta/settings.local.json)
|
||||
letta --new Force create a new agent
|
||||
letta --continue Resume global last agent (deprecated, use project-based)
|
||||
letta --agent <id> Open a specific agent by ID
|
||||
|
||||
# headless
|
||||
letta --prompt One-off prompt in headless mode (no TTY UI)
|
||||
letta -p "..." One-off prompt in headless mode (no TTY UI)
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help and exit
|
||||
-v, --version Print version and exit
|
||||
-c, --continue Resume previous session (uses settings.lastAgent)
|
||||
--new Force create new agent (skip auto-resume)
|
||||
-c, --continue Resume previous session (uses global lastAgent, deprecated)
|
||||
-a, --agent <id> Use a specific agent ID
|
||||
-p, --prompt Headless prompt mode
|
||||
--output-format <fmt> Output format for headless mode (text, json, stream-json)
|
||||
Default: text
|
||||
|
||||
BEHAVIOR
|
||||
By default, letta auto-resumes the last agent used in the current directory
|
||||
(stored in .letta/settings.local.json). Use --new to force a new agent.
|
||||
|
||||
EXAMPLES
|
||||
# when installed as an executable
|
||||
letta --help
|
||||
letta --continue
|
||||
letta # Auto-resume project agent or create new
|
||||
letta --new # Force new agent
|
||||
letta --agent agent_123
|
||||
|
||||
# headless with JSON output (includes stats)
|
||||
@@ -57,6 +63,7 @@ async function main() {
|
||||
help: { type: "boolean", short: "h" },
|
||||
version: { type: "boolean", short: "v" },
|
||||
continue: { type: "boolean", short: "c" },
|
||||
new: { type: "boolean" },
|
||||
agent: { type: "string", short: "a" },
|
||||
prompt: { type: "boolean", short: "p" },
|
||||
run: { type: "boolean" },
|
||||
@@ -100,6 +107,7 @@ async function main() {
|
||||
}
|
||||
|
||||
const shouldContinue = (values.continue as boolean | undefined) ?? false;
|
||||
const forceNew = (values.new as boolean | undefined) ?? false;
|
||||
const specifiedAgentId = (values.agent as string | undefined) ?? null;
|
||||
const isHeadless = values.prompt || values.run || !process.stdin.isTTY;
|
||||
|
||||
@@ -179,9 +187,11 @@ async function main() {
|
||||
|
||||
function LoadingApp({
|
||||
continueSession,
|
||||
forceNew,
|
||||
agentIdArg,
|
||||
}: {
|
||||
continueSession: boolean;
|
||||
forceNew: boolean;
|
||||
agentIdArg: string | null;
|
||||
}) {
|
||||
const [loadingState, setLoadingState] = useState<
|
||||
@@ -190,6 +200,7 @@ async function main() {
|
||||
const [agentId, setAgentId] = useState<string | null>(null);
|
||||
const [agentState, setAgentState] = useState<Letta.AgentState | null>(null);
|
||||
const [resumeData, setResumeData] = useState<ResumeData | null>(null);
|
||||
const [isResumingSession, setIsResumingSession] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
@@ -202,7 +213,8 @@ async function main() {
|
||||
|
||||
setLoadingState("initializing");
|
||||
const { createAgent } = await import("./agent/create");
|
||||
const { updateSettings } = await import("./settings");
|
||||
const { updateSettings, loadProjectSettings, updateProjectSettings } =
|
||||
await import("./settings");
|
||||
|
||||
let agent: Letta.AgentState | null = null;
|
||||
|
||||
@@ -218,7 +230,28 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Try to reuse lastAgent if --continue flag is passed
|
||||
// Priority 2: Check if --new flag was passed (skip all resume logic)
|
||||
if (!agent && forceNew) {
|
||||
// Create new agent, don't check any lastAgent fields
|
||||
agent = await createAgent();
|
||||
}
|
||||
|
||||
// Priority 3: Try to resume from project settings (.letta/settings.local.json)
|
||||
if (!agent) {
|
||||
const projectSettings = await loadProjectSettings();
|
||||
if (projectSettings?.lastAgent) {
|
||||
try {
|
||||
agent = await client.agents.retrieve(projectSettings.lastAgent);
|
||||
// console.log(`Resuming project agent ${projectSettings.lastAgent}...`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Project agent ${projectSettings.lastAgent} not found (error: ${JSON.stringify(error)}), creating new one...`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Try to reuse global lastAgent if --continue flag is passed
|
||||
if (!agent && continueSession && settings.lastAgent) {
|
||||
try {
|
||||
agent = await client.agents.retrieve(settings.lastAgent);
|
||||
@@ -230,15 +263,26 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Create a new agent
|
||||
// Priority 5: Create a new agent
|
||||
if (!agent) {
|
||||
agent = await createAgent();
|
||||
// Save the new agent ID to settings
|
||||
await updateSettings({ lastAgent: agent.id });
|
||||
}
|
||||
|
||||
// Get resume data (pending approval + message history) if continuing session or using specific agent
|
||||
if (continueSession || agentIdArg) {
|
||||
// Save agent ID to both project and global settings
|
||||
await updateProjectSettings({ lastAgent: agent.id });
|
||||
await updateSettings({ lastAgent: agent.id });
|
||||
|
||||
// Check if we're resuming an existing agent
|
||||
const projectSettings = await loadProjectSettings();
|
||||
const isResumingProject =
|
||||
!forceNew &&
|
||||
projectSettings?.lastAgent &&
|
||||
agent.id === projectSettings.lastAgent;
|
||||
const resuming = continueSession || !!agentIdArg || isResumingProject;
|
||||
setIsResumingSession(resuming);
|
||||
|
||||
// Get resume data (pending approval + message history) if resuming
|
||||
if (resuming) {
|
||||
setLoadingState("checking");
|
||||
const data = await getResumeData(client, agent.id);
|
||||
setResumeData(data);
|
||||
@@ -250,9 +294,7 @@ async function main() {
|
||||
}
|
||||
|
||||
init();
|
||||
}, [continueSession, agentIdArg]);
|
||||
|
||||
const isResumingSession = continueSession || !!agentIdArg;
|
||||
}, [continueSession, forceNew, agentIdArg]);
|
||||
|
||||
if (!agentId) {
|
||||
return React.createElement(App, {
|
||||
@@ -279,6 +321,7 @@ async function main() {
|
||||
render(
|
||||
React.createElement(LoadingApp, {
|
||||
continueSession: shouldContinue,
|
||||
forceNew: forceNew,
|
||||
agentIdArg: specifiedAgentId,
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// src/settings.ts
|
||||
// Manages user settings stored in ~/.letta/settings.json
|
||||
// Manages user settings stored in ~/.letta/settings.json and project settings in ./.letta/settings.local.json
|
||||
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { PermissionRules } from "./permissions/types";
|
||||
import { exists, readFile, writeFile } from "./utils/fs.js";
|
||||
import { exists, mkdir, readFile, writeFile } from "./utils/fs.js";
|
||||
|
||||
export type UIMode = "simple" | "rich";
|
||||
|
||||
@@ -17,6 +17,11 @@ export interface Settings {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ProjectSettings {
|
||||
lastAgent: string | null;
|
||||
permissions?: PermissionRules;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
uiMode: "simple",
|
||||
lastAgent: null,
|
||||
@@ -90,3 +95,66 @@ export async function getSetting<K extends keyof Settings>(
|
||||
const settings = await loadSettings();
|
||||
return settings[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project settings path (./.letta/settings.local.json)
|
||||
*/
|
||||
function getProjectSettingsPath(): string {
|
||||
return join(process.cwd(), ".letta", "settings.local.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load project settings from ./.letta/settings.local.json
|
||||
* Returns null if file doesn't exist
|
||||
*/
|
||||
export async function loadProjectSettings(): Promise<ProjectSettings | null> {
|
||||
const settingsPath = getProjectSettingsPath();
|
||||
|
||||
try {
|
||||
if (!exists(settingsPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await readFile(settingsPath);
|
||||
const settings = JSON.parse(content) as ProjectSettings;
|
||||
return settings;
|
||||
} catch (error) {
|
||||
console.error("Error loading project settings:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save project settings to ./.letta/settings.local.json
|
||||
* Creates .letta directory if it doesn't exist
|
||||
*/
|
||||
export async function saveProjectSettings(
|
||||
settings: ProjectSettings,
|
||||
): Promise<void> {
|
||||
const settingsPath = getProjectSettingsPath();
|
||||
const dirPath = join(process.cwd(), ".letta");
|
||||
|
||||
try {
|
||||
// Create .letta directory if it doesn't exist
|
||||
if (!exists(dirPath)) {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
} catch (error) {
|
||||
console.error("Error saving project settings:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update project settings fields
|
||||
*/
|
||||
export async function updateProjectSettings(
|
||||
updates: Partial<ProjectSettings>,
|
||||
): Promise<ProjectSettings> {
|
||||
const currentSettings = (await loadProjectSettings()) || { lastAgent: null };
|
||||
const newSettings = { ...currentSettings, ...updates };
|
||||
await saveProjectSettings(newSettings);
|
||||
return newSettings;
|
||||
}
|
||||
|
||||
@@ -35,3 +35,13 @@ export async function writeFile(path: string, content: string): Promise<void> {
|
||||
export function exists(path: string): boolean {
|
||||
return existsSync(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a directory, including parent directories
|
||||
*/
|
||||
export async function mkdir(
|
||||
path: string,
|
||||
options?: { recursive?: boolean },
|
||||
): Promise<void> {
|
||||
mkdirSync(path, options);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user