ai-sdk-letta 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +9 -0
  4. package/README.md +35 -0
  5. package/dist/agent.d.ts +108 -0
  6. package/dist/agent.d.ts.map +1 -0
  7. package/dist/agent.js +371 -0
  8. package/dist/agent.js.map +1 -0
  9. package/dist/definition.d.ts +81 -0
  10. package/dist/definition.d.ts.map +1 -0
  11. package/dist/definition.js +87 -0
  12. package/dist/definition.js.map +1 -0
  13. package/dist/history.d.ts +38 -0
  14. package/dist/history.d.ts.map +1 -0
  15. package/dist/history.js +226 -0
  16. package/dist/history.js.map +1 -0
  17. package/dist/identity.d.ts +43 -0
  18. package/dist/identity.d.ts.map +1 -0
  19. package/dist/identity.js +158 -0
  20. package/dist/identity.js.map +1 -0
  21. package/dist/images.d.ts +83 -0
  22. package/dist/images.d.ts.map +1 -0
  23. package/dist/images.js +181 -0
  24. package/dist/images.js.map +1 -0
  25. package/dist/index.d.ts +18 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +18 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/interactions.d.ts +61 -0
  30. package/dist/interactions.d.ts.map +1 -0
  31. package/dist/interactions.js +110 -0
  32. package/dist/interactions.js.map +1 -0
  33. package/dist/memory.d.ts +11 -0
  34. package/dist/memory.d.ts.map +1 -0
  35. package/dist/memory.js +55 -0
  36. package/dist/memory.js.map +1 -0
  37. package/dist/navigation.d.ts +50 -0
  38. package/dist/navigation.d.ts.map +1 -0
  39. package/dist/navigation.js +87 -0
  40. package/dist/navigation.js.map +1 -0
  41. package/dist/runtime.d.ts +83 -0
  42. package/dist/runtime.d.ts.map +1 -0
  43. package/dist/runtime.js +246 -0
  44. package/dist/runtime.js.map +1 -0
  45. package/dist/state.d.ts +24 -0
  46. package/dist/state.d.ts.map +1 -0
  47. package/dist/state.js +39 -0
  48. package/dist/state.js.map +1 -0
  49. package/dist/tools.d.ts +83 -0
  50. package/dist/tools.d.ts.map +1 -0
  51. package/dist/tools.js +162 -0
  52. package/dist/tools.js.map +1 -0
  53. package/package.json +67 -0
  54. package/src/agent.ts +334 -0
  55. package/src/definition.ts +136 -0
  56. package/src/history.ts +198 -0
  57. package/src/identity.ts +125 -0
  58. package/src/images.ts +184 -0
  59. package/src/index.ts +23 -0
  60. package/src/interactions.ts +121 -0
  61. package/src/memory.ts +42 -0
  62. package/src/navigation.ts +84 -0
  63. package/src/runtime.ts +243 -0
  64. package/src/state.ts +39 -0
  65. package/src/tools.ts +166 -0
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * ai-sdk-letta: a persistent, Letta-backed Vercel AI SDK `Agent` with
3
+ * application-owned tools and human-in-the-loop interactions.
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+ export { LettaAgent, historyKey, userTurnContent, MAX_INPUT_CHARACTERS, type LettaAgentOptions, type AgentPresentation, type DeliveryHooks, type TurnSession } from './agent.js';
8
+ export {
9
+ IMAGE_LIMITS, IMAGE_MEDIA_TYPES, IMAGE_REFERENCE_PROVIDER, ImageInputError, validateImages, decodeImagePart, assertImageBudget, isImagePart, sniffImageType, toLettaImage, imagePartDigest, compactImagePart,
10
+ type ImageInputErrorCode, type ImageMediaType, type ImageLimits, type DecodedImage,
11
+ } from './images.js';
12
+ export {
13
+ defineAgent, creationOptions, dreamingCommand, memoryPolicyInstructions, DEFAULT_DREAMING, INTERNAL_MEMORY_TOOLS,
14
+ type AgentDefinition, type AgentDefinitionInput, type DreamingSettings, type DreamingTrigger, type ToolPermission,
15
+ } from './definition.js';
16
+ export { openLettaAgent, createLettaAgent, assertIdle, sessionOptions, foregroundToolsCommand, localBackendDirectory, type OpenAgentOptions, type LettaRuntime, type ConversationChoice } from './runtime.js';
17
+ export { acquireIdentity, validConversationId, type Identity, type IdentityBackend, type IdentityLease } from './identity.js';
18
+ export { HISTORY_LIMIT, HISTORY_IMAGE_BUDGET, IMAGE_PLACEHOLDER, sanitizeText, historyPage, loadHistory, projectHistory, assertHistorySettled, listConversations } from './history.js';
19
+ export { listNavigationEntries, searchConversations, snippet, SEARCH_CONVERSATIONS, SEARCH_RECORDS, SEARCH_TOTAL, SEARCH_MATCHES, SEARCH_MILLISECONDS, type ConversationEntry, type NavigationSource, type SearchMatch } from './navigation.js';
20
+ export { ToolInteractions, validateQuestion, validateResponse, type Question, type InteractionRequest, type InteractionResponse, type InteractionHandler } from './interactions.js';
21
+ export { createToolBridge, askUserTool, fileTraceWriter, ASK_USER_TOOL, type AskUserResult, type ToolActivity, type ToolBridge, type ToolBridgeOptions } from './tools.js';
22
+ export { allowMemoryTool, memoryCommitCommand } from './memory.js';
23
+ export { resolveStateDirectory, statePaths, STATE_DIR_ENV } from './state.js';
@@ -0,0 +1,121 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ /** Arguments of the built-in `ask_user` tool. */
4
+ export type Question = { question: string; options?: { id: string; label: string }[]; allowFreeText?: boolean; multiSelect?: boolean };
5
+
6
+ /** A prompt the application must show to a human: approve a tool call, or answer a question. */
7
+ export type InteractionRequest = {
8
+ /** Unique per prompt; a response must echo it. */
9
+ id: string;
10
+ toolCallId: string;
11
+ tool: string;
12
+ kind: 'approval' | 'question';
13
+ title: string;
14
+ /** For approvals: the exact JSON arguments that will run if approved. */
15
+ details?: string;
16
+ options?: Question['options'];
17
+ allowFreeText?: boolean;
18
+ multiSelect?: boolean;
19
+ };
20
+
21
+ /** A human's answer. Approvals use `approved`; questions use `selected` and/or `text`. */
22
+ export type InteractionResponse = { id: string; approved?: boolean; cancelled?: boolean; selected?: string[]; text?: string };
23
+
24
+ /** Renders one request and resolves with the human's response. `signal` aborts when the prompt is withdrawn. */
25
+ export type InteractionHandler = (request: InteractionRequest, signal: AbortSignal) => Promise<unknown>;
26
+
27
+ type Pending = { request: InteractionRequest; signal: AbortSignal; resolve(value: InteractionResponse): void; reject(error: Error): void; cleanup(): void; control: AbortController };
28
+
29
+ /**
30
+ * Interaction broker: serializes human prompts (FIFO, one at a time) between
31
+ * tool calls and a single connected renderer (TUI, GUI, or your own).
32
+ *
33
+ * Responses are validated against the exact request and are never interpreted
34
+ * as chat turns. Without a connected renderer, requests fail closed.
35
+ */
36
+ export class ToolInteractions {
37
+ private handler?: InteractionHandler;
38
+ private queue: Pending[] = [];
39
+ private active?: Pending;
40
+ private closed = false;
41
+
42
+ /** Attach the renderer. Returns a disconnect function that cancels all pending prompts. */
43
+ readonly connect = (handler: InteractionHandler): (() => void) => {
44
+ if (this.closed || this.handler) throw new Error('interaction_unavailable');
45
+ this.handler = handler;
46
+ this.pump();
47
+ return () => { this.handler = undefined; this.cancelAll(); };
48
+ };
49
+
50
+ /** Permanently close the broker and cancel every pending prompt. */
51
+ close(): void { this.closed = true; this.handler = undefined; this.cancelAll(); }
52
+
53
+ private cancelAll() {
54
+ for (const pending of [...this.queue]) pending.control.abort();
55
+ }
56
+
57
+ /** Queue a prompt. Rejects with `interaction_unavailable` if no renderer is connected. */
58
+ request(request: Omit<InteractionRequest, 'id'>, signal: AbortSignal): Promise<InteractionResponse> {
59
+ if (this.closed || !this.handler) return Promise.reject(new Error('interaction_unavailable'));
60
+ if (signal.aborted) return Promise.reject(new Error('tool_cancelled'));
61
+ return new Promise((resolve, reject) => {
62
+ const control = new AbortController();
63
+ const combined = AbortSignal.any([signal, control.signal]);
64
+ const pending: Pending = { request: structuredClone({ ...request, id: randomUUID() }), signal: combined, resolve, reject, control, cleanup: () => combined.removeEventListener('abort', abort) };
65
+ const abort = () => { this.finish(pending, undefined, new Error('tool_cancelled')); };
66
+ combined.addEventListener('abort', abort, { once: true });
67
+ this.queue.push(pending);
68
+ this.pump();
69
+ });
70
+ }
71
+
72
+ private pump() {
73
+ if (this.active || !this.handler || !this.queue.length) return;
74
+ const pending = this.active = this.queue[0]!;
75
+ const handler = this.handler;
76
+ void Promise.resolve().then(() => {
77
+ pending.signal.throwIfAborted();
78
+ return handler(structuredClone(pending.request), pending.signal);
79
+ }).then(value => {
80
+ if (this.active !== pending) return;
81
+ try { this.finish(pending, validateResponse(pending.request, value)); }
82
+ catch { this.finish(pending, undefined, new Error('invalid_interaction_response')); }
83
+ }, () => this.finish(pending, undefined, new Error('interaction_cancelled')));
84
+ }
85
+
86
+ private finish(pending: Pending, value?: InteractionResponse, error?: Error) {
87
+ const index = this.queue.indexOf(pending);
88
+ if (index < 0) return;
89
+ this.queue.splice(index, 1);
90
+ pending.cleanup();
91
+ if (this.active === pending) this.active = undefined;
92
+ // Close the renderer before showing another prompt, including abort races.
93
+ pending.control.abort();
94
+ if (error) pending.reject(error); else pending.resolve(value!);
95
+ queueMicrotask(() => this.pump());
96
+ }
97
+ }
98
+
99
+ /** @throws `invalid_arguments` when a question offers no way to answer or has duplicate option IDs. */
100
+ export function validateQuestion(question: Question): void {
101
+ const options = question.options ?? [];
102
+ if ((!options.length && !question.allowFreeText) || new Set(options.map(o => o.id)).size !== options.length) throw new Error('invalid_arguments');
103
+ }
104
+
105
+ /** Validate a human response against the exact request it answers. */
106
+ export function validateResponse(request: InteractionRequest, value: unknown): InteractionResponse {
107
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid_response');
108
+ const response = value as InteractionResponse;
109
+ if (response.id !== request.id) throw new Error('stale_response');
110
+ if (response.cancelled === true) return { id: request.id, cancelled: true };
111
+ if (request.kind === 'approval') {
112
+ if (typeof response.approved !== 'boolean') throw new Error('invalid_response');
113
+ return { id: request.id, approved: response.approved };
114
+ }
115
+ const selected = response.selected ?? [];
116
+ if (!Array.isArray(selected) || new Set(selected).size !== selected.length || selected.some(id => typeof id !== 'string' || !request.options?.some(o => o.id === id)) || (!request.multiSelect && selected.length > 1)) throw new Error('invalid_response');
117
+ if (response.text !== undefined && (typeof response.text !== 'string' || !request.allowFreeText || response.text.length > 2000)) throw new Error('invalid_response');
118
+ const text = response.text?.trim();
119
+ if (!selected.length && !text) throw new Error('empty_response');
120
+ return { id: request.id, selected, ...(text ? { text } : {}) };
121
+ }
package/src/memory.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { lstatSync, realpathSync } from 'node:fs';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+ import { INTERNAL_MEMORY_TOOLS } from './definition.js';
4
+
5
+ const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
6
+
7
+ /**
8
+ * The only shell command the agent may run: commit Markdown changes in its own
9
+ * MemFS repository, with hooks disabled and a fixed author.
10
+ */
11
+ export function memoryCommitCommand(root: string, author = 'ai-sdk-letta agent'): string {
12
+ const git = `git -c core.hooksPath=/dev/null -C ${quote(root)}`;
13
+ return `${git} add -- '*.md' && ${git} -c user.name=${quote(author.replace(/[\r\n]/g, ' '))} -c user.email='agent@localhost' commit -m 'Update agent memory'`;
14
+ }
15
+
16
+ /**
17
+ * Strict per-call permission for harness memory tools: own Markdown files only;
18
+ * no general shell, traversal, dot-files (including `.git`), symlinks or hard links.
19
+ */
20
+ export function allowMemoryTool(name: string, input: Record<string, unknown>, root?: string, author?: string): boolean {
21
+ if (!root || !(INTERNAL_MEMORY_TOOLS as readonly string[]).includes(name)) return false;
22
+ if (name === 'Bash') return input.command === memoryCommitCommand(root, author) && !input.run_in_background;
23
+ if (typeof input.file_path !== 'string' || !isAbsolute(input.file_path) || !input.file_path.endsWith('.md')) return false;
24
+ try {
25
+ const canonicalRoot = realpathSync(root);
26
+ const path = resolve(input.file_path);
27
+ // Accept the backend-provided spelling (e.g. macOS /var -> /private/var)
28
+ // or its canonical spelling; inspect every component below the real root.
29
+ const rawSuffix = relative(resolve(root), path);
30
+ const suffix = !rawSuffix.startsWith(`..${sep}`) && rawSuffix !== '..' && !isAbsolute(rawSuffix) ? rawSuffix : relative(canonicalRoot, path);
31
+ if (!suffix || suffix.startsWith(`..${sep}`) || suffix === '..' || isAbsolute(suffix)) return false;
32
+ const parts = suffix.split(sep);
33
+ if (parts.some(part => part.startsWith('.'))) return false;
34
+ let current = canonicalRoot;
35
+ for (const part of parts) {
36
+ current = resolve(current, part);
37
+ try { const stat = lstatSync(current); if (stat.isSymbolicLink() || (stat.isFile() && stat.nlink > 1)) return false; }
38
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return false; }
39
+ }
40
+ return true;
41
+ } catch { return false; }
42
+ }
@@ -0,0 +1,84 @@
1
+ import type { ListMessagesOptions, ListMessagesResult, LettaConversation } from '@letta-ai/letta-agent-sdk';
2
+ import { loadHistory, projectHistory, sanitizeText } from './history.js';
3
+
4
+ /** One row of a conversation picker. */
5
+ export type ConversationEntry = { id: string; title: string; date: string };
6
+ /** Read-only access to the current agent's conversations, used by pickers and search. */
7
+ export type NavigationSource = {
8
+ agentId: string;
9
+ currentId: string;
10
+ list: (signal?: AbortSignal) => Promise<{ entries: ConversationEntry[]; limited: boolean }>;
11
+ page: (id: string, options: ListMessagesOptions) => Promise<ListMessagesResult>;
12
+ validate: (id: string, signal?: AbortSignal) => Promise<void>;
13
+ };
14
+ /** Newest conversations of one agent (up to 200 plus `default`), excluding archived ones. */
15
+ export async function listNavigationEntries(list: (query: { agentId: string; limit: number; order: 'desc'; orderBy: 'createdAt'; after?: string }) => Promise<LettaConversation[]>, agentId: string, signal?: AbortSignal) {
16
+ const entries: ConversationEntry[] = [{ id: 'default', title: 'Default conversation', date: 'activity unavailable' }];
17
+ const seen = new Set<string>(['default']);
18
+ let after: string | undefined;
19
+ let limited = false;
20
+ for (let index = 0; index < 3; index++) {
21
+ signal?.throwIfAborted();
22
+ const page = await list({ agentId, limit: 100, order: 'desc', orderBy: 'createdAt', ...(after ? { after } : {}) });
23
+ signal?.throwIfAborted();
24
+ if (page.length > 100) throw new Error('Oversized conversation page');
25
+ if (!page.length) break;
26
+ for (const row of page) {
27
+ if (row.agent_id !== agentId || seen.has(row.id)) throw new Error('Conversation listing escaped current agent or repeated cursor');
28
+ seen.add(row.id);
29
+ if (index === 2) { limited = true; continue; }
30
+ if (!row.archived) entries.push({ id: row.id, title: sanitizeText(row.summary || 'Untitled conversation'), date: row.last_message_at || row.updated_at || row.created_at || 'activity unavailable' });
31
+ }
32
+ if (limited) break;
33
+ after = page.at(-1)!.id;
34
+ }
35
+ return { entries, limited };
36
+ }
37
+
38
+ export const SEARCH_CONVERSATIONS = 50;
39
+ export const SEARCH_RECORDS = 500;
40
+ export const SEARCH_TOTAL = 5000;
41
+ export const SEARCH_MATCHES = 100;
42
+ export const SEARCH_MILLISECONDS = 30_000;
43
+ const plain = (text: string) => sanitizeText(text).replace(/\s+/g, ' ').trim();
44
+ /** A short excerpt of `text` around the first match of `query`. */
45
+ export function snippet(text: string, query: string) {
46
+ const clean = plain(text);
47
+ const at = clean.toLowerCase().indexOf(query.toLowerCase());
48
+ const start = Math.max(0, at - 65);
49
+ return `${start ? '…' : ''}${clean.slice(start, start + 220)}${clean.length > start + 220 ? '…' : ''}`;
50
+ }
51
+ export type SearchMatch = { conversation: ConversationEntry; role: string; snippet: string };
52
+ /** Literal, case-insensitive text search. Only authoritative human/assistant text
53
+ * enters the projection: never tools, reasoning, system or memory records. */
54
+ export async function searchConversations(source: NavigationSource, entries: ConversationEntry[], query: string, signal: AbortSignal, progress: (text: string) => void = () => {}) {
55
+ if (!query.trim() || query.length > 200) throw new Error('Search requires 1–200 characters');
56
+ const matches: SearchMatch[] = [];
57
+ let records = 0;
58
+ let scanned = 0;
59
+ let limited = entries.length > SEARCH_CONVERSATIONS;
60
+ const deadline = Date.now() + SEARCH_MILLISECONDS;
61
+ const check = () => { signal.throwIfAborted(); if (Date.now() >= deadline) throw new Error('Search deadline reached'); };
62
+ for (const conversation of entries.slice(0, SEARCH_CONVERSATIONS)) {
63
+ check();
64
+ if (records >= SEARCH_TOTAL || matches.length >= SEARCH_MATCHES) { limited = true; break; }
65
+ progress(`Searching ${scanned + 1}/${Math.min(entries.length, SEARCH_CONVERSATIONS)} · ${records} records · Esc cancels after current read`);
66
+ const history = await loadHistory(async options => {
67
+ check();
68
+ const page = await source.page(conversation.id, options);
69
+ check();
70
+ records += page.messages.length;
71
+ return page;
72
+ }, Math.min(SEARCH_RECORDS, SEARCH_TOTAL - records));
73
+ limited ||= history.truncated;
74
+ scanned++;
75
+ for (const message of projectHistory(history.messages, [], 0)) {
76
+ const text = message.parts.filter(p => p.type === 'text').map(p => p.text).join('\n');
77
+ if (plain(text).toLowerCase().includes(query.toLowerCase())) {
78
+ if (matches.length >= SEARCH_MATCHES) { limited = true; break; }
79
+ matches.push({ conversation, role: message.role, snippet: snippet(text, query) });
80
+ }
81
+ }
82
+ }
83
+ return { matches, records, scanned, limited };
84
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,243 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+ import { LettaAgentClient, type LettaCodeClientSessionOptions, type LettaCodeSession, type LettaConversation, type SessionDeviceStatus } from '@letta-ai/letta-agent-sdk';
4
+ import type { ToolSet } from 'ai';
5
+ import { LettaAgent } from './agent.js';
6
+ import { creationOptions, dreamingCommand, INTERNAL_MEMORY_TOOLS, type AgentDefinition } from './definition.js';
7
+ import { acquireIdentity, validConversationId, type Identity } from './identity.js';
8
+ import { assertHistorySettled, historyPage, listConversations, loadHistory, projectHistory, sanitizeText } from './history.js';
9
+ import { allowMemoryTool, memoryCommitCommand } from './memory.js';
10
+ import { listNavigationEntries, type NavigationSource } from './navigation.js';
11
+ import { ToolInteractions } from './interactions.js';
12
+ import { createToolBridge, fileTraceWriter, type ToolActivity, type ToolBridge } from './tools.js';
13
+ import { resolveStateDirectory, statePaths } from './state.js';
14
+
15
+ /** Which conversation to open. `null` means "open nothing" (for example, the user quit a picker). */
16
+ export type ConversationChoice = { conversationId: string } | { newTitle: string } | null;
17
+
18
+ /** Options for {@link openLettaAgent}. */
19
+ export interface OpenAgentOptions {
20
+ /** State root; see {@link resolveStateDirectory}. */
21
+ stateDirectory?: string;
22
+ /** Open this conversation (`'default'` or a Letta conversation ID). */
23
+ conversationId?: string;
24
+ /** Create and open a new conversation with this title. */
25
+ newTitle?: string;
26
+ /** Interactive picker; takes precedence over `conversationId`/`newTitle`. */
27
+ choose?: (identity: Identity, conversations: LettaConversation[]) => Promise<ConversationChoice>;
28
+ /**
29
+ * Keep application tools in the foreground for up to five minutes. Without
30
+ * this, the Letta harness backgrounds a client tool after about 10 seconds,
31
+ * which breaks human approvals and questions. @default true
32
+ */
33
+ foregroundExternalTools?: boolean;
34
+ /** Tool audit trail: `true` writes private NDJSON under the state directory, or pass a sink. @default true */
35
+ traces?: boolean | ((event: ToolActivity) => void);
36
+ }
37
+
38
+ /** An opened agent plus the resources that belong to it. */
39
+ export interface LettaRuntime<TOOLS extends ToolSet = ToolSet> {
40
+ agent: LettaAgent<TOOLS>;
41
+ identity: Identity;
42
+ /** Read-only conversation listing and search for the same agent. */
43
+ navigation: NavigationSource;
44
+ /** Close the session and SDK client, then release the identity lock. Idempotent. */
45
+ close(): Promise<void>;
46
+ }
47
+
48
+ const managementClient = (requestTimeoutMs?: number) => new LettaAgentClient({ backend: 'local', appServer: { harnessBackend: 'local', ...(requestTimeoutMs ? { requestTimeoutMs } : {}) } });
49
+
50
+ /** Letta local backend directory used to key identity mappings. */
51
+ export function localBackendDirectory(env: NodeJS.ProcessEnv = process.env): string {
52
+ return resolve(env.LETTA_LOCAL_BACKEND_DIR?.trim() || join(homedir(), '.letta', 'lc-local-backend'));
53
+ }
54
+
55
+ /** @throws unless the session is online, idle and has no queued or pending work. */
56
+ export function assertIdle(status: SessionDeviceStatus): void {
57
+ if (status.isOnline !== true || status.isProcessing !== false || !Array.isArray(status.pendingControlRequests) || !status.raw || status.pendingControlRequests.length || (Array.isArray(status.raw.queue) && status.raw.queue.length) || (Array.isArray(status.raw.active_run_ids) && status.raw.active_run_ids.length)) throw new Error('Conversation has unfinished work or is offline; inspect backend before continuing (no retry/repair).');
58
+ }
59
+
60
+ /** Letta session options: application tools plus MemFS tools confined to the agent's memory. */
61
+ export function sessionOptions(bridge: ToolBridge, getMemoryRoot: () => string | undefined, cwd: string, memoryAuthor?: string): LettaCodeClientSessionOptions {
62
+ return {
63
+ stateless: false, cwd,
64
+ toolset: { base: 'none', include: [...INTERNAL_MEMORY_TOOLS] },
65
+ allowedTools: [...bridge.allowedTools, ...INTERNAL_MEMORY_TOOLS], tools: bridge.tools,
66
+ permissionMode: 'strict', skillSources: [],
67
+ // Avoid the SDK convenience `dreaming` option; app-scoped settings are applied after ready().
68
+ canUseTool: async (name, input, context) => {
69
+ if ((INTERNAL_MEMORY_TOOLS as readonly string[]).includes(name)) {
70
+ const root = getMemoryRoot();
71
+ if (allowMemoryTool(name, input, root, memoryAuthor)) return { behavior: 'allow' };
72
+ return { behavior: 'deny', message: `Only own-memory Markdown operations are permitted.${root ? ` The only permitted Bash command is: ${memoryCommitCommand(root, memoryAuthor)}` : ''}` };
73
+ }
74
+ return bridge.canUseTool(name, input, context);
75
+ },
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Runtime-scoped protocol command that keeps application tools in the
81
+ * foreground (no auto-backgrounding) with a five-minute timeout.
82
+ */
83
+ export function foregroundToolsCommand(bridge: Pick<ToolBridge, 'tools'>, agentId: string, conversationId: string) {
84
+ return { type: 'runtime_external_tools_update', updates: [{
85
+ runtimes: [{ agent_id: agentId, conversation_id: conversationId }],
86
+ external_tools: [{ tools: bridge.tools.map(tool => ({ name: tool.name, label: tool.label, description: tool.description, parameters: tool.parameters, auto_background: false, timeout_ms: 300_000 })) }],
87
+ }] };
88
+ }
89
+
90
+ /** Open an agent, or throw if no conversation was selected. */
91
+ export async function createLettaAgent<TOOLS extends ToolSet>(definition: AgentDefinition<TOOLS>, options: Omit<OpenAgentOptions, 'choose'> = {}): Promise<LettaRuntime<TOOLS>> {
92
+ const runtime = await openLettaAgent(definition, options);
93
+ if (!runtime) throw new Error('No conversation selected');
94
+ return runtime;
95
+ }
96
+
97
+ /**
98
+ * Open (creating on first use) the persistent Letta agent for a definition on
99
+ * the local Letta backend, and return a ready {@link LettaAgent}.
100
+ *
101
+ * Steps, each failing closed: acquire the identity lock and mapping; select or
102
+ * create a conversation; resume the session; verify it is idle and its history
103
+ * settled; restore display history; apply project-scoped dreaming and verify
104
+ * it; confirm MemFS. Returns `undefined` if `choose` returned `null`.
105
+ */
106
+ export async function openLettaAgent<TOOLS extends ToolSet>(definition: AgentDefinition<TOOLS>, options: OpenAgentOptions = {}): Promise<LettaRuntime<TOOLS> | undefined> {
107
+ const paths = statePaths(resolveStateDirectory(options.stateDirectory));
108
+ const cwd = paths.agents;
109
+ const client = new LettaAgentClient({ backend: 'local', appServer: { harnessBackend: 'local', requestTimeoutMs: 180_000, startupTimeoutMs: 60_000 } });
110
+ let session: LettaCodeSession | undefined;
111
+ let release: (() => void) | undefined;
112
+ let interactions: ToolInteractions | undefined;
113
+ let closing: Promise<void> | undefined;
114
+ const close = () => closing ??= (async () => {
115
+ try { interactions?.close(); session?.close(); await client.close(); }
116
+ finally { release?.(); }
117
+ })();
118
+ try {
119
+ const backend = localBackendDirectory();
120
+ const lease = await acquireIdentity(cwd, definition, backend, {
121
+ create: async () => {
122
+ const models = await client.models.list();
123
+ if (!models.entries.some(model => model.handle === definition.model)) throw new Error(`Model "${definition.model}" is not available on the local Letta backend; connect its provider first`);
124
+ return client.createAgent(creationOptions(definition, cwd));
125
+ },
126
+ validate: async id => {
127
+ // Local management snapshots can predate createAgent's separate process.
128
+ const inspector = managementClient();
129
+ try {
130
+ const agent = await inspector.agents.retrieve(id);
131
+ if (agent.id !== id || agent.name !== definition.name) throw new Error('Mapped agent identity mismatch; refusing to recreate');
132
+ if (!agent.tags?.includes('git-memory-enabled')) throw new Error('Mapped agent has MemFS disabled; refusing to continue');
133
+ } finally { await inspector.close(); }
134
+ },
135
+ });
136
+ const { identity, selectConversation, createConversation, assertNoPendingTurn, beginTurn, completeTurn } = lease;
137
+ release = lease.release;
138
+ // A fresh management client sees agents created by the SDK's separate process.
139
+ const manager = managementClient();
140
+ let conversationId = identity.conversationId;
141
+ let conversationTitle = 'Default conversation';
142
+ try {
143
+ const choice: ConversationChoice = options.choose
144
+ ? await options.choose(identity, await listConversations(query => manager.conversations.list(query), identity.agentId))
145
+ : options.newTitle !== undefined ? { newTitle: options.newTitle } : { conversationId: options.conversationId ?? identity.conversationId };
146
+ if (choice === null) { await close(); return undefined; }
147
+ if ('newTitle' in choice) {
148
+ if (!choice.newTitle.trim() || choice.newTitle.length > 120) throw new Error('Conversation title must contain 1–120 characters');
149
+ conversationId = await createConversation(async agentId => {
150
+ const created = await manager.conversations.create({ agentId, summary: sanitizeText(choice.newTitle.trim()) });
151
+ if (created.agent_id !== agentId) throw new Error('Created conversation belongs to a different agent');
152
+ return created.id;
153
+ });
154
+ } else conversationId = choice.conversationId;
155
+ if (!validConversationId(conversationId)) throw new Error('Invalid conversation ID');
156
+ if (conversationId !== 'default') {
157
+ const conversation = await manager.conversations.retrieve(conversationId);
158
+ if (conversation.agent_id !== identity.agentId || conversation.archived) throw new Error('Conversation is archived or belongs to another agent');
159
+ conversationTitle = sanitizeText(conversation.summary ?? 'Untitled conversation');
160
+ }
161
+ } finally { await manager.close(); }
162
+ assertNoPendingTurn(conversationId);
163
+ let turnSignal: AbortSignal | undefined;
164
+ const broker = interactions = new ToolInteractions();
165
+ const persist = options.traces === false ? undefined : typeof options.traces === 'function' ? options.traces : fileTraceWriter(paths.traces);
166
+ // Background harness work must never open a prompt over an idle chat input.
167
+ const bridge = createToolBridge({
168
+ tools: definition.tools, permissions: definition.permissions, timeoutMs: definition.toolTimeoutMs, persist,
169
+ get interactions() { return turnSignal ? broker : undefined; }, get signal() { return turnSignal; },
170
+ });
171
+ let memoryRoot: string | undefined;
172
+ session = client.resumeSession(conversationId === 'default' ? identity.agentId : conversationId, sessionOptions(bridge, () => memoryRoot, cwd, definition.name));
173
+ const ready = await session.ready();
174
+ if (ready.agentId !== identity.agentId || ready.conversationId !== conversationId) throw new Error('Backend resumed a different identity/conversation');
175
+ assertIdle(await session.getDeviceStatus());
176
+ if (options.foregroundExternalTools !== false && bridge.tools.length) {
177
+ // The SDK's tools serializer drops auto_background/timeout_ms. Use the
178
+ // supported runtime-scoped protocol rather than global defaults.
179
+ const configured = await session.sendCommand(foregroundToolsCommand(bridge, ready.agentId, ready.conversationId), { responseType: 'runtime_external_tools_update_response' });
180
+ if (configured.success !== true) throw new Error('Unable to configure foreground interaction tools');
181
+ }
182
+ const live = session;
183
+ const history = await loadHistory(query => historyPage(live, identity.agentId, conversationId, query));
184
+ assertHistorySettled(history.messages);
185
+ const initialMessages = projectHistory(history.messages, Object.keys(definition.tools));
186
+ // The SDK's dreaming option writes global defaults. Use the protocol
187
+ // command with project scope (the private state cwd) instead.
188
+ const configured = await session.sendCommand(dreamingCommand(definition, ready.agentId, ready.conversationId), { responseType: 'set_reflection_settings_response' });
189
+ if (configured.success !== true) throw new Error('Unable to configure project-scoped dreaming');
190
+ const status = await session.getDeviceStatus();
191
+ if (!status.memoryDirectory) throw new Error('MemFS unavailable; refusing to run without memory');
192
+ const reflection = status.raw.reflection_settings as { trigger?: string; step_count?: number } | undefined;
193
+ if (reflection?.trigger !== definition.dreaming.trigger || reflection.step_count !== definition.dreaming.stepCount) throw new Error('Persistent dreaming configuration differs from the definition; inspect agent settings before continuing');
194
+ assertIdle(status);
195
+ memoryRoot = status.memoryDirectory;
196
+ selectConversation(conversationId);
197
+ // Navigation reads through this same mapped runtime; it never enumerates other agents.
198
+ let listedIds = new Set<string>(['default']);
199
+ const navigation: NavigationSource = {
200
+ agentId: identity.agentId, currentId: conversationId,
201
+ list: async signal => {
202
+ assertIdle(await live.getDeviceStatus());
203
+ const reader = managementClient(15_000);
204
+ try {
205
+ const result = await listNavigationEntries(query => reader.conversations.list(query), identity.agentId, signal);
206
+ listedIds = new Set(result.entries.map(entry => entry.id));
207
+ return result;
208
+ } finally { await reader.close(); }
209
+ },
210
+ page: async (id, pageOptions) => {
211
+ if (!listedIds.has(id)) throw new Error('Conversation outside current-agent listing');
212
+ return historyPage(live, identity.agentId, id, pageOptions, 10_000);
213
+ },
214
+ validate: async (id, signal) => {
215
+ if (!listedIds.has(id) || !validConversationId(id)) throw new Error('Conversation outside current-agent listing');
216
+ assertIdle(await live.getDeviceStatus());
217
+ assertNoPendingTurn(id);
218
+ const deadline = Date.now() + 30_000;
219
+ const loaded = await loadHistory(pageOptions => {
220
+ signal?.throwIfAborted();
221
+ if (Date.now() >= deadline) throw new Error('Selection validation deadline reached');
222
+ return historyPage(live, identity.agentId, id, pageOptions, 10_000);
223
+ });
224
+ signal?.throwIfAborted();
225
+ assertHistorySettled(loaded.messages);
226
+ },
227
+ };
228
+ const dreamingLine = definition.dreaming.trigger === 'off' ? 'Dreaming: off' : `Dreaming: ${reflection.trigger}${reflection.trigger === 'step-count' ? ` ${reflection.step_count}` : ''} configured (not evidence a dream ran)`;
229
+ const startupStatus = `${definition.name}\nLogical ID: ${definition.id}\nLetta ID: ${identity.agentId}\nConversation: ${conversationTitle} (${conversationId})\nStartup status: idle / ready · MemFS confirmed enabled\n${dreamingLine}\nHistory: ${initialMessages.length} visible records restored${history.truncated ? ' · LIMITED to newest 10,000 backend records; older history omitted' : ' · complete backend pagination'}`;
230
+ // Keep one session alive between turns so background dreaming can progress.
231
+ // LettaAgent closes only its per-turn wrapper, not this shared session.
232
+ const agent = new LettaAgent<TOOLS>({
233
+ id: definition.id, tools: definition.tools, memoryTools: INTERNAL_MEMORY_TOOLS, lettaAgentId: identity.agentId, modelId: definition.model, interactions: broker,
234
+ open: signal => {
235
+ turnSignal = signal;
236
+ return { send: message => live.send(message), stream: () => live.stream(), abort: () => live.abort(), close: () => { if (turnSignal === signal) turnSignal = undefined; } };
237
+ },
238
+ presentation: { conversationId, title: conversationTitle, initialMessages, status: startupStatus, memoryDirectory: memoryRoot, historyTruncated: history.truncated },
239
+ delivery: { begin: () => beginTurn(conversationId), complete: () => completeTurn(conversationId) },
240
+ });
241
+ return { agent, identity, navigation, close: async () => { agent.close(); await close(); } };
242
+ } catch (error) { await close(); throw error; }
243
+ }
package/src/state.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { homedir } from 'node:os';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
+
4
+ /** Environment variable that overrides the state directory. */
5
+ export const STATE_DIR_ENV = 'AI_SDK_LETTA_STATE_DIR';
6
+
7
+ /**
8
+ * Resolve the root state directory.
9
+ *
10
+ * Order: explicit argument, `AI_SDK_LETTA_STATE_DIR`, then the platform state
11
+ * location: `$XDG_STATE_HOME/ai-sdk-letta` or `~/.local/state/ai-sdk-letta` on
12
+ * Linux and macOS, `%LOCALAPPDATA%\ai-sdk-letta\state` on Windows.
13
+ *
14
+ * The identity mapping lives in `<stateDir>/agents/`; point this at an
15
+ * existing directory to keep using an agent created earlier.
16
+ */
17
+ export function resolveStateDirectory(explicit?: string, env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, home = homedir()): string {
18
+ const chosen = explicit ?? env[STATE_DIR_ENV];
19
+ if (chosen !== undefined) {
20
+ if (!chosen.trim()) throw new Error('State directory must not be empty');
21
+ return resolve(chosen);
22
+ }
23
+ if (platform === 'win32') return join(env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'), 'ai-sdk-letta', 'state');
24
+ const xdg = env.XDG_STATE_HOME;
25
+ return join(xdg && isAbsolute(xdg) ? xdg : join(home, '.local', 'state'), 'ai-sdk-letta');
26
+ }
27
+
28
+ /** Well-known subdirectories of a state root. */
29
+ export function statePaths(root: string) {
30
+ return {
31
+ root,
32
+ /** Identity mappings, locks and pending-intent files; also the Letta session cwd. */
33
+ agents: join(root, 'agents'),
34
+ /** Metadata-only tool audit trail. */
35
+ traces: join(root, 'tool-traces'),
36
+ /** Per-definition HTTP runtime state (threads, runs). */
37
+ server: (definitionId: string) => join(root, 'server', definitionId),
38
+ };
39
+ }