Initial release of Letta Code SDK
Programmatic control of Letta Code CLI with persistent agent memory. Features: - createSession() / resumeSession() / prompt() API - resumeConversation() for multi-threaded conversations - Multi-turn conversations with memory - Tool execution (Bash, Read, Edit, etc.) - System prompt and memory configuration - Permission callbacks (canUseTool) - Message streaming with typed events 👾 Generated with [Letta Code](https://letta.com)
This commit is contained in:
159
src/index.ts
Normal file
159
src/index.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Letta Code SDK
|
||||
*
|
||||
* Programmatic control of Letta Code CLI with persistent agent memory.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createSession, prompt } from '@letta-ai/letta-code-sdk';
|
||||
*
|
||||
* // One-shot
|
||||
* const result = await prompt('What is 2+2?', { model: 'claude-sonnet-4-20250514' });
|
||||
*
|
||||
* // Multi-turn session
|
||||
* await using session = createSession({ model: 'claude-sonnet-4-20250514' });
|
||||
* await session.send('Hello!');
|
||||
* for await (const msg of session.stream()) {
|
||||
* if (msg.type === 'assistant') console.log(msg.content);
|
||||
* }
|
||||
*
|
||||
* // Resume with persistent memory
|
||||
* await using resumed = resumeSession(agentId, { model: 'claude-sonnet-4-20250514' });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Session } from "./session.js";
|
||||
import type { SessionOptions, SDKMessage, SDKResultMessage } from "./types.js";
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
SessionOptions,
|
||||
SDKMessage,
|
||||
SDKInitMessage,
|
||||
SDKAssistantMessage,
|
||||
SDKToolCallMessage,
|
||||
SDKToolResultMessage,
|
||||
SDKReasoningMessage,
|
||||
SDKResultMessage,
|
||||
SDKStreamEventMessage,
|
||||
PermissionMode,
|
||||
PermissionResult,
|
||||
CanUseToolCallback,
|
||||
} from "./types.js";
|
||||
|
||||
export { Session } from "./session.js";
|
||||
|
||||
/**
|
||||
* Create a new session with a fresh Letta agent.
|
||||
*
|
||||
* The agent will have persistent memory that survives across sessions.
|
||||
* Use `resumeSession` to continue a conversation with an existing agent.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await using session = createSession({ model: 'claude-sonnet-4-20250514' });
|
||||
* await session.send('My name is Alice');
|
||||
* for await (const msg of session.stream()) {
|
||||
* console.log(msg);
|
||||
* }
|
||||
* console.log(`Agent ID: ${session.agentId}`); // Save this to resume later
|
||||
* ```
|
||||
*/
|
||||
export function createSession(options: SessionOptions = {}): Session {
|
||||
return new Session(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an existing session with a Letta agent.
|
||||
*
|
||||
* Unlike Claude Agent SDK (ephemeral sessions), Letta agents have persistent
|
||||
* memory. You can resume a conversation days later and the agent will remember.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Days later...
|
||||
* await using session = resumeSession(agentId, { model: 'claude-sonnet-4-20250514' });
|
||||
* await session.send('What is my name?');
|
||||
* for await (const msg of session.stream()) {
|
||||
* // Agent remembers: "Your name is Alice"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function resumeSession(
|
||||
agentId: string,
|
||||
options: SessionOptions = {}
|
||||
): Session {
|
||||
return new Session({ ...options, agentId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an existing conversation.
|
||||
*
|
||||
* Conversations are threads within an agent. The agent is derived automatically
|
||||
* from the conversation ID. Use this to continue a specific conversation thread.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Resume a specific conversation
|
||||
* await using session = resumeConversation(conversationId);
|
||||
* await session.send('Continue our discussion...');
|
||||
* for await (const msg of session.stream()) {
|
||||
* console.log(msg);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function resumeConversation(
|
||||
conversationId: string,
|
||||
options: SessionOptions = {}
|
||||
): Session {
|
||||
return new Session({ ...options, conversationId });
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot prompt convenience function.
|
||||
*
|
||||
* Creates a session, sends the prompt, collects the response, and closes.
|
||||
* Returns the final result message.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await prompt('What is the capital of France?', {
|
||||
* model: 'claude-sonnet-4-20250514'
|
||||
* });
|
||||
* if (result.success) {
|
||||
* console.log(result.result);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function prompt(
|
||||
message: string,
|
||||
options: SessionOptions = {}
|
||||
): Promise<SDKResultMessage> {
|
||||
const session = createSession(options);
|
||||
|
||||
try {
|
||||
await session.send(message);
|
||||
|
||||
let result: SDKResultMessage | null = null;
|
||||
for await (const msg of session.stream()) {
|
||||
if (msg.type === "result") {
|
||||
result = msg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
type: "result",
|
||||
success: false,
|
||||
error: "No result received",
|
||||
durationMs: 0,
|
||||
conversationId: session.conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
358
src/session.ts
Normal file
358
src/session.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Session
|
||||
*
|
||||
* Represents a conversation session with a Letta agent.
|
||||
* Implements the V2 API pattern: send() / receive()
|
||||
*/
|
||||
|
||||
import { SubprocessTransport } from "./transport.js";
|
||||
import type {
|
||||
SessionOptions,
|
||||
SDKMessage,
|
||||
SDKInitMessage,
|
||||
SDKAssistantMessage,
|
||||
SDKResultMessage,
|
||||
WireMessage,
|
||||
ControlRequest,
|
||||
CanUseToolControlRequest,
|
||||
CanUseToolResponse,
|
||||
CanUseToolResponseAllow,
|
||||
CanUseToolResponseDeny,
|
||||
} from "./types.js";
|
||||
import { validateSessionOptions } from "./validation.js";
|
||||
|
||||
export class Session implements AsyncDisposable {
|
||||
private transport: SubprocessTransport;
|
||||
private _agentId: string | null = null;
|
||||
private _sessionId: string | null = null;
|
||||
private _conversationId: string | null = null;
|
||||
private initialized = false;
|
||||
|
||||
constructor(
|
||||
private options: SessionOptions & { agentId?: string } = {}
|
||||
) {
|
||||
// Validate options before creating transport
|
||||
validateSessionOptions(options);
|
||||
this.transport = new SubprocessTransport(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the session (called automatically on first send)
|
||||
*/
|
||||
async initialize(): Promise<SDKInitMessage> {
|
||||
if (this.initialized) {
|
||||
throw new Error("Session already initialized");
|
||||
}
|
||||
|
||||
await this.transport.connect();
|
||||
|
||||
// Send initialize control request
|
||||
await this.transport.write({
|
||||
type: "control_request",
|
||||
request_id: "init_1",
|
||||
request: { subtype: "initialize" },
|
||||
});
|
||||
|
||||
// Wait for init message
|
||||
for await (const msg of this.transport.messages()) {
|
||||
if (msg.type === "system" && "subtype" in msg && msg.subtype === "init") {
|
||||
const initMsg = msg as WireMessage & {
|
||||
agent_id: string;
|
||||
session_id: string;
|
||||
conversation_id: string;
|
||||
model: string;
|
||||
tools: string[];
|
||||
};
|
||||
this._agentId = initMsg.agent_id;
|
||||
this._sessionId = initMsg.session_id;
|
||||
this._conversationId = initMsg.conversation_id;
|
||||
this.initialized = true;
|
||||
|
||||
return {
|
||||
type: "init",
|
||||
agentId: initMsg.agent_id,
|
||||
sessionId: initMsg.session_id,
|
||||
conversationId: initMsg.conversation_id,
|
||||
model: initMsg.model,
|
||||
tools: initMsg.tools,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to initialize session - no init message received");
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the agent
|
||||
*/
|
||||
async send(message: string): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
await this.transport.write({
|
||||
type: "user",
|
||||
message: { role: "user", content: message },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream messages from the agent
|
||||
*/
|
||||
async *stream(): AsyncGenerator<SDKMessage> {
|
||||
for await (const wireMsg of this.transport.messages()) {
|
||||
// Handle CLI → SDK control requests (e.g., can_use_tool)
|
||||
if (wireMsg.type === "control_request") {
|
||||
const controlReq = wireMsg as ControlRequest;
|
||||
if (controlReq.request.subtype === "can_use_tool") {
|
||||
await this.handleCanUseTool(
|
||||
controlReq.request_id,
|
||||
controlReq.request as CanUseToolControlRequest
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const sdkMsg = this.transformMessage(wireMsg);
|
||||
if (sdkMsg) {
|
||||
yield sdkMsg;
|
||||
|
||||
// Stop on result message
|
||||
if (sdkMsg.type === "result") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle can_use_tool control request from CLI (Claude SDK compatible format)
|
||||
*/
|
||||
private async handleCanUseTool(
|
||||
requestId: string,
|
||||
req: CanUseToolControlRequest
|
||||
): Promise<void> {
|
||||
let response: CanUseToolResponse;
|
||||
|
||||
if (this.options.canUseTool) {
|
||||
try {
|
||||
const result = await this.options.canUseTool(req.tool_name, req.input);
|
||||
if (result.allow) {
|
||||
response = {
|
||||
behavior: "allow",
|
||||
updatedInput: null, // TODO: not supported
|
||||
updatedPermissions: [], // TODO: not implemented
|
||||
} satisfies CanUseToolResponseAllow;
|
||||
} else {
|
||||
response = {
|
||||
behavior: "deny",
|
||||
message: result.reason ?? "Denied by canUseTool callback",
|
||||
interrupt: false, // TODO: not wired up yet
|
||||
} satisfies CanUseToolResponseDeny;
|
||||
}
|
||||
} catch (err) {
|
||||
response = {
|
||||
behavior: "deny",
|
||||
message: err instanceof Error ? err.message : "Callback error",
|
||||
interrupt: false,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// No callback registered - deny by default
|
||||
response = {
|
||||
behavior: "deny",
|
||||
message: "No canUseTool callback registered",
|
||||
interrupt: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Send control_response (Claude SDK compatible format)
|
||||
await this.transport.write({
|
||||
type: "control_response",
|
||||
response: {
|
||||
subtype: "success",
|
||||
request_id: requestId,
|
||||
response,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the current operation (interrupt without closing the session)
|
||||
*/
|
||||
async abort(): Promise<void> {
|
||||
await this.transport.write({
|
||||
type: "control_request",
|
||||
request_id: `interrupt-${Date.now()}`,
|
||||
request: { subtype: "interrupt" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the session
|
||||
*/
|
||||
close(): void {
|
||||
this.transport.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the agent ID (available after initialization)
|
||||
*/
|
||||
get agentId(): string | null {
|
||||
return this._agentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session ID (available after initialization)
|
||||
*/
|
||||
get sessionId(): string | null {
|
||||
return this._sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the conversation ID (available after initialization)
|
||||
*/
|
||||
get conversationId(): string | null {
|
||||
return this._conversationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* AsyncDisposable implementation for `await using`
|
||||
*/
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
this.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform wire message to SDK message
|
||||
*/
|
||||
private transformMessage(wireMsg: WireMessage): SDKMessage | null {
|
||||
// Init message
|
||||
if (wireMsg.type === "system" && "subtype" in wireMsg && wireMsg.subtype === "init") {
|
||||
const msg = wireMsg as WireMessage & {
|
||||
agent_id: string;
|
||||
session_id: string;
|
||||
conversation_id: string;
|
||||
model: string;
|
||||
tools: string[];
|
||||
};
|
||||
return {
|
||||
type: "init",
|
||||
agentId: msg.agent_id,
|
||||
sessionId: msg.session_id,
|
||||
conversationId: msg.conversation_id,
|
||||
model: msg.model,
|
||||
tools: msg.tools,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle message types (all have type: "message" with message_type field)
|
||||
if (wireMsg.type === "message" && "message_type" in wireMsg) {
|
||||
const msg = wireMsg as WireMessage & {
|
||||
message_type: string;
|
||||
uuid: string;
|
||||
// assistant_message fields
|
||||
content?: string;
|
||||
// tool_call_message fields
|
||||
tool_call?: { name: string; arguments: string; tool_call_id: string };
|
||||
tool_calls?: Array<{ name: string; arguments: string; tool_call_id: string }>;
|
||||
// tool_return_message fields
|
||||
tool_call_id?: string;
|
||||
tool_return?: string;
|
||||
status?: "success" | "error";
|
||||
// reasoning_message fields
|
||||
reasoning?: string;
|
||||
};
|
||||
|
||||
// Assistant message
|
||||
if (msg.message_type === "assistant_message" && msg.content) {
|
||||
return {
|
||||
type: "assistant",
|
||||
content: msg.content,
|
||||
uuid: msg.uuid,
|
||||
};
|
||||
}
|
||||
|
||||
// Tool call message
|
||||
if (msg.message_type === "tool_call_message") {
|
||||
const toolCall = msg.tool_calls?.[0] || msg.tool_call;
|
||||
if (toolCall) {
|
||||
let toolInput: Record<string, unknown> = {};
|
||||
try {
|
||||
toolInput = JSON.parse(toolCall.arguments);
|
||||
} catch {
|
||||
toolInput = { raw: toolCall.arguments };
|
||||
}
|
||||
return {
|
||||
type: "tool_call",
|
||||
toolCallId: toolCall.tool_call_id,
|
||||
toolName: toolCall.name,
|
||||
toolInput,
|
||||
uuid: msg.uuid,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Tool return message
|
||||
if (msg.message_type === "tool_return_message" && msg.tool_call_id) {
|
||||
return {
|
||||
type: "tool_result",
|
||||
toolCallId: msg.tool_call_id,
|
||||
content: msg.tool_return || "",
|
||||
isError: msg.status === "error",
|
||||
uuid: msg.uuid,
|
||||
};
|
||||
}
|
||||
|
||||
// Reasoning message
|
||||
if (msg.message_type === "reasoning_message" && msg.reasoning) {
|
||||
return {
|
||||
type: "reasoning",
|
||||
content: msg.reasoning,
|
||||
uuid: msg.uuid,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Stream event (partial message updates)
|
||||
if (wireMsg.type === "stream_event") {
|
||||
const msg = wireMsg as WireMessage & {
|
||||
event: {
|
||||
type: string;
|
||||
index?: number;
|
||||
delta?: { type?: string; text?: string; reasoning?: string };
|
||||
content_block?: { type?: string; text?: string };
|
||||
};
|
||||
uuid: string;
|
||||
};
|
||||
return {
|
||||
type: "stream_event",
|
||||
event: msg.event,
|
||||
uuid: msg.uuid,
|
||||
};
|
||||
}
|
||||
|
||||
// Result message
|
||||
if (wireMsg.type === "result") {
|
||||
const msg = wireMsg as WireMessage & {
|
||||
subtype: string;
|
||||
result?: string;
|
||||
duration_ms: number;
|
||||
total_cost_usd?: number;
|
||||
conversation_id: string;
|
||||
};
|
||||
return {
|
||||
type: "result",
|
||||
success: msg.subtype === "success",
|
||||
result: msg.result,
|
||||
error: msg.subtype !== "success" ? msg.subtype : undefined,
|
||||
durationMs: msg.duration_ms,
|
||||
totalCostUsd: msg.total_cost_usd,
|
||||
conversationId: msg.conversation_id,
|
||||
};
|
||||
}
|
||||
|
||||
// Skip other message types (system_message, user_message, etc.)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
342
src/transport.ts
Normal file
342
src/transport.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* SubprocessTransport
|
||||
*
|
||||
* Spawns the Letta Code CLI and communicates via stdin/stdout JSON streams.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createInterface, type Interface } from "node:readline";
|
||||
import type { SessionOptions, WireMessage } from "./types.js";
|
||||
|
||||
export class SubprocessTransport {
|
||||
private process: ChildProcess | null = null;
|
||||
private stdout: Interface | null = null;
|
||||
private messageQueue: WireMessage[] = [];
|
||||
private messageResolvers: Array<(msg: WireMessage) => void> = [];
|
||||
private closed = false;
|
||||
private agentId?: string;
|
||||
|
||||
constructor(
|
||||
private options: SessionOptions & { agentId?: string } = {}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start the CLI subprocess
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
const args = this.buildArgs();
|
||||
|
||||
// Find the CLI - use the installed letta-code package
|
||||
const cliPath = await this.findCli();
|
||||
|
||||
this.process = spawn("node", [cliPath, ...args], {
|
||||
cwd: this.options.cwd || process.cwd(),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
if (!this.process.stdout || !this.process.stdin) {
|
||||
throw new Error("Failed to create subprocess pipes");
|
||||
}
|
||||
|
||||
// Set up stdout reading
|
||||
this.stdout = createInterface({
|
||||
input: this.process.stdout,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
this.stdout.on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
try {
|
||||
const msg = JSON.parse(line) as WireMessage;
|
||||
this.handleMessage(msg);
|
||||
} catch {
|
||||
// Ignore non-JSON lines (stderr leakage, etc.)
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
this.process.on("close", () => {
|
||||
this.closed = true;
|
||||
});
|
||||
|
||||
this.process.on("error", (err) => {
|
||||
console.error("CLI process error:", err);
|
||||
this.closed = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the CLI via stdin
|
||||
*/
|
||||
async write(data: object): Promise<void> {
|
||||
if (!this.process?.stdin || this.closed) {
|
||||
throw new Error("Transport not connected");
|
||||
}
|
||||
this.process.stdin.write(JSON.stringify(data) + "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the next message from the CLI
|
||||
*/
|
||||
async read(): Promise<WireMessage | null> {
|
||||
// Return queued message if available
|
||||
if (this.messageQueue.length > 0) {
|
||||
return this.messageQueue.shift()!;
|
||||
}
|
||||
|
||||
// If closed, no more messages
|
||||
if (this.closed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wait for next message
|
||||
return new Promise((resolve) => {
|
||||
this.messageResolvers.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Async iterator for messages
|
||||
*/
|
||||
async *messages(): AsyncGenerator<WireMessage> {
|
||||
while (true) {
|
||||
const msg = await this.read();
|
||||
if (msg === null) break;
|
||||
yield msg;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the transport
|
||||
*/
|
||||
close(): void {
|
||||
if (this.process) {
|
||||
this.process.stdin?.end();
|
||||
this.process.kill();
|
||||
this.process = null;
|
||||
}
|
||||
this.closed = true;
|
||||
|
||||
// Resolve any pending readers with null
|
||||
for (const resolve of this.messageResolvers) {
|
||||
resolve(null as unknown as WireMessage);
|
||||
}
|
||||
this.messageResolvers = [];
|
||||
}
|
||||
|
||||
get isClosed(): boolean {
|
||||
return this.closed;
|
||||
}
|
||||
|
||||
private handleMessage(msg: WireMessage): void {
|
||||
// Track agent_id from init message
|
||||
if (msg.type === "system" && "subtype" in msg && msg.subtype === "init") {
|
||||
this.agentId = (msg as unknown as { agent_id: string }).agent_id;
|
||||
}
|
||||
|
||||
// If someone is waiting for a message, give it to them
|
||||
if (this.messageResolvers.length > 0) {
|
||||
const resolve = this.messageResolvers.shift()!;
|
||||
resolve(msg);
|
||||
} else {
|
||||
// Otherwise queue it
|
||||
this.messageQueue.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
private buildArgs(): string[] {
|
||||
const args: string[] = [
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--input-format",
|
||||
"stream-json",
|
||||
];
|
||||
|
||||
// Validate conversation + agent combinations
|
||||
// (These require agentId context, so can't be in validateSessionOptions)
|
||||
|
||||
// conversationId (non-default) cannot be used with agentId
|
||||
if (this.options.conversationId &&
|
||||
this.options.conversationId !== "default" &&
|
||||
this.options.agentId) {
|
||||
throw new Error(
|
||||
"Cannot use both 'conversationId' and 'agentId'. " +
|
||||
"When resuming a conversation, the agent is derived automatically."
|
||||
);
|
||||
}
|
||||
|
||||
// conversationId: "default" requires agentId
|
||||
if (this.options.conversationId === "default" && !this.options.agentId) {
|
||||
throw new Error(
|
||||
"conversationId 'default' requires agentId. " +
|
||||
"Use resumeSession(agentId, { defaultConversation: true }) instead."
|
||||
);
|
||||
}
|
||||
|
||||
// defaultConversation requires agentId
|
||||
if (this.options.defaultConversation && !this.options.agentId) {
|
||||
throw new Error(
|
||||
"'defaultConversation' requires agentId. " +
|
||||
"Use resumeSession(agentId, { defaultConversation: true })."
|
||||
);
|
||||
}
|
||||
|
||||
// newConversation requires agentId
|
||||
if (this.options.newConversation && !this.options.agentId) {
|
||||
throw new Error(
|
||||
"'newConversation' requires agentId. " +
|
||||
"Use resumeSession(agentId, { newConversation: true })."
|
||||
);
|
||||
}
|
||||
|
||||
// Conversation and agent handling
|
||||
if (this.options.continue) {
|
||||
// Resume last session (agent + conversation)
|
||||
args.push("--continue");
|
||||
} else if (this.options.conversationId) {
|
||||
// Resume specific conversation (derives agent automatically)
|
||||
args.push("--conversation", this.options.conversationId);
|
||||
} else if (this.options.agentId) {
|
||||
// Resume existing agent
|
||||
args.push("--agent", this.options.agentId);
|
||||
if (this.options.newConversation) {
|
||||
// Create new conversation on this agent
|
||||
args.push("--new");
|
||||
} else if (this.options.defaultConversation) {
|
||||
// Use agent's default conversation explicitly
|
||||
args.push("--default");
|
||||
}
|
||||
} else {
|
||||
// Create new agent
|
||||
args.push("--new-agent");
|
||||
}
|
||||
|
||||
// Model
|
||||
if (this.options.model) {
|
||||
args.push("-m", this.options.model);
|
||||
}
|
||||
|
||||
// System prompt configuration
|
||||
if (this.options.systemPrompt !== undefined) {
|
||||
if (typeof this.options.systemPrompt === "string") {
|
||||
// Raw string → --system-custom
|
||||
args.push("--system-custom", this.options.systemPrompt);
|
||||
} else {
|
||||
// Preset object → --system (+ optional --system-append)
|
||||
args.push("--system", this.options.systemPrompt.preset);
|
||||
if (this.options.systemPrompt.append) {
|
||||
args.push("--system-append", this.options.systemPrompt.append);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Memory blocks (only for new agents)
|
||||
if (this.options.memory !== undefined && !this.options.agentId) {
|
||||
if (this.options.memory.length === 0) {
|
||||
// Empty array → no memory blocks (just core)
|
||||
args.push("--init-blocks", "");
|
||||
} else {
|
||||
// Separate preset names from custom/reference blocks
|
||||
const presetNames: string[] = [];
|
||||
const memoryBlocksJson: Array<
|
||||
| { label: string; value: string }
|
||||
| { blockId: string }
|
||||
> = [];
|
||||
|
||||
for (const item of this.options.memory) {
|
||||
if (typeof item === "string") {
|
||||
// Preset name
|
||||
presetNames.push(item);
|
||||
} else if ("blockId" in item) {
|
||||
// Block reference - pass to --memory-blocks
|
||||
memoryBlocksJson.push(item as { blockId: string });
|
||||
} else {
|
||||
// CreateBlock
|
||||
memoryBlocksJson.push(item as { label: string; value: string });
|
||||
}
|
||||
}
|
||||
|
||||
// Add preset names via --init-blocks
|
||||
if (presetNames.length > 0) {
|
||||
args.push("--init-blocks", presetNames.join(","));
|
||||
}
|
||||
|
||||
// Add custom blocks and block references via --memory-blocks
|
||||
if (memoryBlocksJson.length > 0) {
|
||||
args.push("--memory-blocks", JSON.stringify(memoryBlocksJson));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience props for block values (only for new agents)
|
||||
if (!this.options.agentId) {
|
||||
if (this.options.persona !== undefined) {
|
||||
args.push("--block-value", `persona=${this.options.persona}`);
|
||||
}
|
||||
if (this.options.human !== undefined) {
|
||||
args.push("--block-value", `human=${this.options.human}`);
|
||||
}
|
||||
if (this.options.project !== undefined) {
|
||||
args.push("--block-value", `project=${this.options.project}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Permission mode
|
||||
if (this.options.permissionMode === "bypassPermissions") {
|
||||
args.push("--yolo");
|
||||
} else if (this.options.permissionMode === "acceptEdits") {
|
||||
args.push("--accept-edits");
|
||||
}
|
||||
|
||||
// Allowed tools
|
||||
if (this.options.allowedTools) {
|
||||
args.push("--allowedTools", this.options.allowedTools.join(","));
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private async findCli(): Promise<string> {
|
||||
// Try multiple resolution strategies
|
||||
const { existsSync } = await import("node:fs");
|
||||
const { dirname, join } = await import("node:path");
|
||||
const { fileURLToPath } = await import("node:url");
|
||||
|
||||
// Strategy 1: Check LETTA_CLI_PATH env var
|
||||
if (process.env.LETTA_CLI_PATH && existsSync(process.env.LETTA_CLI_PATH)) {
|
||||
return process.env.LETTA_CLI_PATH;
|
||||
}
|
||||
|
||||
// Strategy 2: Try to resolve from node_modules
|
||||
try {
|
||||
const { createRequire } = await import("node:module");
|
||||
const require = createRequire(import.meta.url);
|
||||
const resolved = require.resolve("@letta-ai/letta-code/letta.js");
|
||||
if (existsSync(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
} catch {
|
||||
// Continue to next strategy
|
||||
}
|
||||
|
||||
// Strategy 3: Check relative to this file (for local file: deps)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const localPaths = [
|
||||
join(__dirname, "../../@letta-ai/letta-code/letta.js"),
|
||||
join(__dirname, "../../../letta-code-prod/letta.js"),
|
||||
join(__dirname, "../../../letta-code/letta.js"),
|
||||
];
|
||||
|
||||
for (const p of localPaths) {
|
||||
if (existsSync(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Letta Code CLI not found. Set LETTA_CLI_PATH or install @letta-ai/letta-code."
|
||||
);
|
||||
}
|
||||
}
|
||||
252
src/types.ts
Normal file
252
src/types.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* SDK Types
|
||||
*
|
||||
* These are the public-facing types for SDK consumers.
|
||||
* Protocol types are imported from @letta-ai/letta-code/protocol.
|
||||
*/
|
||||
|
||||
// Re-export protocol types for internal use
|
||||
export type {
|
||||
WireMessage,
|
||||
SystemInitMessage,
|
||||
MessageWire,
|
||||
ResultMessage,
|
||||
ErrorMessage,
|
||||
StreamEvent,
|
||||
ControlRequest,
|
||||
ControlResponse,
|
||||
CanUseToolControlRequest,
|
||||
CanUseToolResponse,
|
||||
CanUseToolResponseAllow,
|
||||
CanUseToolResponseDeny,
|
||||
// Configuration types
|
||||
SystemPromptPresetConfig,
|
||||
CreateBlock,
|
||||
} from "@letta-ai/letta-code/protocol";
|
||||
|
||||
// Import types for use in SessionOptions
|
||||
import type { CreateBlock } from "@letta-ai/letta-code/protocol";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SYSTEM PROMPT TYPES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Available system prompt presets.
|
||||
*/
|
||||
export type SystemPromptPreset =
|
||||
| "default" // Alias for letta-claude
|
||||
| "letta-claude" // Full Letta Code prompt (Claude-optimized)
|
||||
| "letta-codex" // Full Letta Code prompt (Codex-optimized)
|
||||
| "letta-gemini" // Full Letta Code prompt (Gemini-optimized)
|
||||
| "claude" // Basic Claude (no skills/memory instructions)
|
||||
| "codex" // Basic Codex
|
||||
| "gemini"; // Basic Gemini
|
||||
|
||||
/**
|
||||
* System prompt preset configuration.
|
||||
*/
|
||||
export interface SystemPromptPresetConfigSDK {
|
||||
type: "preset";
|
||||
preset: SystemPromptPreset;
|
||||
append?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* System prompt configuration - either a raw string or preset config.
|
||||
*/
|
||||
export type SystemPromptConfig = string | SystemPromptPresetConfigSDK;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MEMORY TYPES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Reference to an existing shared block by ID.
|
||||
*/
|
||||
export interface BlockReference {
|
||||
blockId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory item - can be a preset name, custom block, or block reference.
|
||||
*/
|
||||
export type MemoryItem =
|
||||
| string // Preset name: "project", "persona", "human"
|
||||
| CreateBlock // Custom block: { label, value, description? }
|
||||
| BlockReference; // Shared block reference: { blockId }
|
||||
|
||||
/**
|
||||
* Default memory block preset names.
|
||||
*/
|
||||
export type MemoryPreset = "persona" | "human" | "project";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SESSION OPTIONS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Result of a canUseTool callback
|
||||
*/
|
||||
export interface PermissionResult {
|
||||
allow: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for custom permission handling
|
||||
*/
|
||||
export type CanUseToolCallback = (
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
) => Promise<PermissionResult> | PermissionResult;
|
||||
|
||||
/**
|
||||
* Options for creating a session
|
||||
*/
|
||||
export interface SessionOptions {
|
||||
/** Model to use (e.g., "claude-sonnet-4-20250514") */
|
||||
model?: string;
|
||||
|
||||
/** Resume a specific conversation by ID (derives agent automatically) */
|
||||
conversationId?: string;
|
||||
|
||||
/** Create a new conversation for concurrent sessions (requires agentId) */
|
||||
newConversation?: boolean;
|
||||
|
||||
/** Resume the last session (agent + conversation from previous run) */
|
||||
continue?: boolean;
|
||||
|
||||
/** Use agent's default conversation (requires agentId) */
|
||||
defaultConversation?: boolean;
|
||||
|
||||
/**
|
||||
* System prompt configuration.
|
||||
* - string: Use as the complete system prompt
|
||||
* - { type: 'preset', preset, append? }: Use a preset with optional appended text
|
||||
*
|
||||
* Available presets: 'default', 'letta-claude', 'letta-codex', 'letta-gemini',
|
||||
* 'claude', 'codex', 'gemini'
|
||||
*/
|
||||
systemPrompt?: SystemPromptConfig;
|
||||
|
||||
/**
|
||||
* Memory block configuration. Each item can be:
|
||||
* - string: Preset block name ("project", "persona", "human")
|
||||
* - CreateBlock: Custom block definition
|
||||
* - { blockId: string }: Reference to existing shared block
|
||||
*
|
||||
* If not specified, defaults to ["persona", "human", "project"].
|
||||
* Core blocks (skills, loaded_skills) are always included automatically.
|
||||
*/
|
||||
memory?: MemoryItem[];
|
||||
|
||||
/**
|
||||
* Convenience: Set persona block value directly.
|
||||
* Uses default block description/limit, just overrides the value.
|
||||
* Error if persona not included in memory config.
|
||||
*/
|
||||
persona?: string;
|
||||
|
||||
/**
|
||||
* Convenience: Set human block value directly.
|
||||
*/
|
||||
human?: string;
|
||||
|
||||
/**
|
||||
* Convenience: Set project block value directly.
|
||||
*/
|
||||
project?: string;
|
||||
|
||||
/** List of allowed tool names */
|
||||
allowedTools?: string[];
|
||||
|
||||
/** Permission mode */
|
||||
permissionMode?: PermissionMode;
|
||||
|
||||
/** Working directory */
|
||||
cwd?: string;
|
||||
|
||||
/** Maximum conversation turns */
|
||||
maxTurns?: number;
|
||||
|
||||
/** Custom permission callback - called when tool needs approval */
|
||||
canUseTool?: CanUseToolCallback;
|
||||
}
|
||||
|
||||
export type PermissionMode = "default" | "acceptEdits" | "bypassPermissions";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SDK MESSAGE TYPES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* SDK message types - clean wrappers around wire types
|
||||
*/
|
||||
export interface SDKInitMessage {
|
||||
type: "init";
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
conversationId: string;
|
||||
model: string;
|
||||
tools: string[];
|
||||
}
|
||||
|
||||
export interface SDKAssistantMessage {
|
||||
type: "assistant";
|
||||
content: string;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export interface SDKToolCallMessage {
|
||||
type: "tool_call";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export interface SDKToolResultMessage {
|
||||
type: "tool_result";
|
||||
toolCallId: string;
|
||||
content: string;
|
||||
isError: boolean;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export interface SDKReasoningMessage {
|
||||
type: "reasoning";
|
||||
content: string;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export interface SDKResultMessage {
|
||||
type: "result";
|
||||
success: boolean;
|
||||
result?: string;
|
||||
error?: string;
|
||||
durationMs: number;
|
||||
totalCostUsd?: number;
|
||||
conversationId: string | null;
|
||||
}
|
||||
|
||||
export interface SDKStreamEventMessage {
|
||||
type: "stream_event";
|
||||
event: {
|
||||
type: string; // "content_block_start" | "content_block_delta" | "content_block_stop"
|
||||
index?: number;
|
||||
delta?: { type?: string; text?: string; reasoning?: string };
|
||||
content_block?: { type?: string; text?: string };
|
||||
};
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
/** Union of all SDK message types */
|
||||
export type SDKMessage =
|
||||
| SDKInitMessage
|
||||
| SDKAssistantMessage
|
||||
| SDKToolCallMessage
|
||||
| SDKToolResultMessage
|
||||
| SDKReasoningMessage
|
||||
| SDKResultMessage
|
||||
| SDKStreamEventMessage;
|
||||
112
src/validation.ts
Normal file
112
src/validation.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* SDK Validation
|
||||
*
|
||||
* Validates SessionOptions before spawning the CLI.
|
||||
*/
|
||||
|
||||
import type { SessionOptions, MemoryItem, CreateBlock } from "./types.js";
|
||||
|
||||
/**
|
||||
* Extract block labels from memory items.
|
||||
*/
|
||||
function getBlockLabels(memory: MemoryItem[]): string[] {
|
||||
return memory
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item; // preset name
|
||||
if ("label" in item) return (item as CreateBlock).label; // CreateBlock
|
||||
return null; // blockId - no label to check
|
||||
})
|
||||
.filter((label): label is string => label !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SessionOptions before spawning CLI.
|
||||
* Throws an error if validation fails.
|
||||
*/
|
||||
export function validateSessionOptions(options: SessionOptions): void {
|
||||
// If memory is specified, validate that convenience props match included blocks
|
||||
if (options.memory !== undefined) {
|
||||
const blockLabels = getBlockLabels(options.memory);
|
||||
|
||||
if (options.persona !== undefined && !blockLabels.includes("persona")) {
|
||||
throw new Error(
|
||||
"Cannot set 'persona' value - block not included in 'memory'. " +
|
||||
"Either add 'persona' to memory array or remove the persona option."
|
||||
);
|
||||
}
|
||||
|
||||
if (options.human !== undefined && !blockLabels.includes("human")) {
|
||||
throw new Error(
|
||||
"Cannot set 'human' value - block not included in 'memory'. " +
|
||||
"Either add 'human' to memory array or remove the human option."
|
||||
);
|
||||
}
|
||||
|
||||
if (options.project !== undefined && !blockLabels.includes("project")) {
|
||||
throw new Error(
|
||||
"Cannot set 'project' value - block not included in 'memory'. " +
|
||||
"Either add 'project' to memory array or remove the project option."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate systemPrompt preset if provided
|
||||
if (
|
||||
options.systemPrompt !== undefined &&
|
||||
typeof options.systemPrompt === "object"
|
||||
) {
|
||||
const validPresets = [
|
||||
"default",
|
||||
"letta-claude",
|
||||
"letta-codex",
|
||||
"letta-gemini",
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
];
|
||||
if (!validPresets.includes(options.systemPrompt.preset)) {
|
||||
throw new Error(
|
||||
`Invalid system prompt preset '${options.systemPrompt.preset}'. ` +
|
||||
`Valid presets: ${validPresets.join(", ")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate conversation options
|
||||
if (options.conversationId && options.newConversation) {
|
||||
throw new Error(
|
||||
"Cannot use both 'conversationId' and 'newConversation'. " +
|
||||
"Use conversationId to resume a specific conversation, or newConversation to create a new one."
|
||||
);
|
||||
}
|
||||
|
||||
if (options.continue && options.conversationId) {
|
||||
throw new Error(
|
||||
"Cannot use both 'continue' and 'conversationId'. " +
|
||||
"Use continue to resume the last session, or conversationId to resume a specific conversation."
|
||||
);
|
||||
}
|
||||
|
||||
if (options.continue && options.newConversation) {
|
||||
throw new Error(
|
||||
"Cannot use both 'continue' and 'newConversation'. " +
|
||||
"Use continue to resume the last session, or newConversation to create a new one."
|
||||
);
|
||||
}
|
||||
|
||||
if (options.defaultConversation && options.conversationId) {
|
||||
throw new Error(
|
||||
"Cannot use both 'defaultConversation' and 'conversationId'. " +
|
||||
"Use defaultConversation with agentId, or conversationId alone."
|
||||
);
|
||||
}
|
||||
|
||||
if (options.defaultConversation && options.newConversation) {
|
||||
throw new Error(
|
||||
"Cannot use both 'defaultConversation' and 'newConversation'."
|
||||
);
|
||||
}
|
||||
|
||||
// Note: Validations that require agentId context happen in transport.ts buildArgs()
|
||||
// because agentId is passed separately to resumeSession(), not in SessionOptions
|
||||
}
|
||||
Reference in New Issue
Block a user