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
@@ -0,0 +1,136 @@
1
+ import type { ToolSet } from 'ai';
2
+ import type { CreateAgentOptions } from '@letta-ai/letta-agent-sdk';
3
+ import { ASK_USER_TOOL } from './tools.js';
4
+
5
+ /** How a single application tool call is authorized. */
6
+ export type ToolPermission = 'allow' | 'ask' | 'deny';
7
+
8
+ /** When Letta runs background memory consolidation ("dreaming"). */
9
+ export type DreamingTrigger = 'off' | 'step-count' | 'compaction-event';
10
+
11
+ export interface DreamingSettings {
12
+ /** @default 'step-count' */
13
+ trigger: DreamingTrigger;
14
+ /** Steps between dreams when `trigger` is `'step-count'`. @default 25 */
15
+ stepCount: number;
16
+ }
17
+
18
+ /** Input accepted by {@link defineAgent}. */
19
+ export interface AgentDefinitionInput<TOOLS extends ToolSet = ToolSet> {
20
+ /**
21
+ * Stable, application-owned logical ID (lowercase letters, digits and `-`).
22
+ * It is mapped once to the Letta-generated agent ID and never re-created.
23
+ */
24
+ id: string;
25
+ /** Display name. Also stored on the Letta agent and checked on every start. */
26
+ name: string;
27
+ /**
28
+ * Letta model handle, e.g. `openai-codex/gpt-5.5` or `anthropic/claude-sonnet-4-5`.
29
+ * Only used when the agent is first created; must be connected on the local backend.
30
+ */
31
+ model: string;
32
+ /** System instructions, applied when the agent is first created. */
33
+ instructions: string;
34
+ /** AI SDK tools executed in this process when the agent calls them. */
35
+ tools: TOOLS;
36
+ /**
37
+ * Permission per tool. Fail-closed: every tool must be listed.
38
+ * `ask_user` defaults to `'allow'` (it is itself a human interaction).
39
+ */
40
+ permissions?: Partial<Record<Extract<keyof TOOLS, string>, ToolPermission>>;
41
+ /** Background memory consolidation. @default { trigger: 'step-count', stepCount: 25 } */
42
+ dreaming?: Partial<DreamingSettings>;
43
+ /** Per-call tool execution deadline in milliseconds (human waits excluded). @default 5000 */
44
+ toolTimeoutMs?: number;
45
+ }
46
+
47
+ /** A validated, immutable agent definition. */
48
+ export interface AgentDefinition<TOOLS extends ToolSet = ToolSet> {
49
+ readonly id: string;
50
+ readonly name: string;
51
+ readonly model: string;
52
+ readonly instructions: string;
53
+ readonly tools: TOOLS;
54
+ readonly permissions: Readonly<Record<string, ToolPermission>>;
55
+ readonly dreaming: Readonly<DreamingSettings>;
56
+ readonly toolTimeoutMs: number;
57
+ }
58
+
59
+ /** Tools the harness uses for MemFS. They are confined to the agent's own memory directory. */
60
+ export const INTERNAL_MEMORY_TOOLS = ['Read', 'Write', 'Edit', 'Bash'] as const;
61
+
62
+ export const DEFAULT_DREAMING: Readonly<DreamingSettings> = Object.freeze({ trigger: 'step-count', stepCount: 25 });
63
+
64
+ const permissionValues: readonly ToolPermission[] = ['allow', 'ask', 'deny'];
65
+
66
+ /**
67
+ * Validate and freeze an agent definition.
68
+ *
69
+ * @throws if the ID is invalid, a tool lacks a permission, or a permission names an unknown tool.
70
+ */
71
+ export function defineAgent<TOOLS extends ToolSet>(input: AgentDefinitionInput<TOOLS>): AgentDefinition<TOOLS> {
72
+ if (typeof input.id !== 'string' || !/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(input.id)) {
73
+ throw new Error('Agent id must be 1–64 lowercase letters, digits or "-", starting and ending with a letter or digit');
74
+ }
75
+ if (typeof input.name !== 'string' || !input.name.trim() || input.name.length > 120) throw new Error('Agent name must contain 1–120 characters');
76
+ if (typeof input.model !== 'string' || !input.model.includes('/')) throw new Error('Agent model must be a Letta model handle such as "provider/model"');
77
+ if (typeof input.instructions !== 'string' || !input.instructions.trim()) throw new Error('Agent instructions are required');
78
+ if (input.tools === null || typeof input.tools !== 'object' || Array.isArray(input.tools)) throw new Error('Agent tools must be an object of AI SDK tools; use {} for none');
79
+ const names = Object.keys(input.tools);
80
+ for (const name of names) {
81
+ if ((INTERNAL_MEMORY_TOOLS as readonly string[]).includes(name)) throw new Error(`Tool name "${name}" is reserved for memory operations`);
82
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(name)) throw new Error(`Invalid tool name "${name}"`);
83
+ }
84
+ const permissions: Record<string, ToolPermission> = {};
85
+ for (const [name, mode] of Object.entries(input.permissions ?? {})) {
86
+ if (!names.includes(name)) throw new Error(`Permission for unknown tool "${name}"`);
87
+ if (!permissionValues.includes(mode as ToolPermission)) throw new Error(`Invalid permission for "${name}"`);
88
+ permissions[name] = mode as ToolPermission;
89
+ }
90
+ if (names.includes(ASK_USER_TOOL)) {
91
+ permissions[ASK_USER_TOOL] ??= 'allow';
92
+ if (permissions[ASK_USER_TOOL] === 'ask') throw new Error('ask_user is already interactive; use "allow" or "deny"');
93
+ }
94
+ const missing = names.filter(name => !Object.hasOwn(permissions, name));
95
+ if (missing.length) throw new Error(`Missing permission for tool(s): ${missing.join(', ')}. Every tool needs "allow", "ask" or "deny".`);
96
+ const dreaming = { ...DEFAULT_DREAMING, ...input.dreaming };
97
+ if (!['off', 'step-count', 'compaction-event'].includes(dreaming.trigger)) throw new Error('Invalid dreaming trigger');
98
+ if (!Number.isInteger(dreaming.stepCount) || dreaming.stepCount < 1 || dreaming.stepCount > 10_000) throw new Error('Dreaming stepCount must be a positive integer');
99
+ const toolTimeoutMs = input.toolTimeoutMs ?? 5000;
100
+ if (!Number.isInteger(toolTimeoutMs) || toolTimeoutMs < 1 || toolTimeoutMs > 300_000) throw new Error('toolTimeoutMs must be 1–300000');
101
+ return Object.freeze({
102
+ id: input.id, name: input.name, model: input.model, instructions: input.instructions, tools: input.tools,
103
+ permissions: Object.freeze(permissions), dreaming: Object.freeze(dreaming), toolTimeoutMs,
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Appended to the definition's instructions at creation, so the model knows
109
+ * what the (enforced) memory policy allows.
110
+ */
111
+ export function memoryPolicyInstructions(dreaming: DreamingSettings): string {
112
+ return 'Maintain useful long-term memory in your own MemFS only. Read, Write and Edit are restricted to Markdown files in your memory directory. '
113
+ + 'Bash is restricted to the exact memory commit command supplied in tool permission feedback; no general shell or filesystem actions are available. '
114
+ + (dreaming.trigger === 'off'
115
+ ? 'Background dreaming is off.'
116
+ : `Background dreaming consolidates memory${dreaming.trigger === 'step-count' ? ` after ${dreaming.stepCount} steps` : ' after compaction'}; do not claim a dream ran without evidence.`);
117
+ }
118
+
119
+ /** Letta Agent SDK creation options for a definition. */
120
+ export function creationOptions(definition: AgentDefinition, cwd: string): CreateAgentOptions {
121
+ return {
122
+ name: definition.name, model: definition.model, cwd, memfs: true,
123
+ baseTools: [], skillSources: [], systemPrompt: `${definition.instructions}\n\n${memoryPolicyInstructions(definition.dreaming)}`,
124
+ // Do not pass `dreaming` here: the SDK applies it with scope 'both', which
125
+ // mutates the user's global Letta defaults. The runtime installs the
126
+ // definition's settings with scope 'local_project' in the private state cwd.
127
+ };
128
+ }
129
+
130
+ /** Protocol command that applies dreaming settings to this project scope only. */
131
+ export function dreamingCommand(definition: AgentDefinition, agentId: string, conversationId = 'default') {
132
+ return {
133
+ type: 'set_reflection_settings', runtime: { agent_id: agentId, conversation_id: conversationId },
134
+ scope: 'local_project', settings: { trigger: definition.dreaming.trigger, step_count: definition.dreaming.stepCount },
135
+ } as const;
136
+ }
package/src/history.ts ADDED
@@ -0,0 +1,198 @@
1
+ import type { LettaCodeSession, ListMessagesOptions, ListMessagesResult, LettaConversation } from '@letta-ai/letta-agent-sdk';
2
+ import type { UIMessage } from 'ai';
3
+ import { decodeImagePart } from './images.js';
4
+
5
+ /** Maximum backend records loaded when restoring a conversation's display history. */
6
+ export const HISTORY_LIMIT = 10_000;
7
+ type Row = Record<string, unknown>;
8
+ const record = (value: unknown): Row | undefined => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Row : undefined;
9
+ /** Strip terminal escape sequences and control characters from untrusted text. */
10
+ export const sanitizeText = (text: string) => text.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
11
+
12
+ /** The installed SDK drops agent_id for default history. Its supported command
13
+ * escape hatch forwards the backend's agent-scoped query, including real cursors.
14
+ * Named conversations use the ordinary SDK API. Neither path sends input.
15
+ */
16
+ export async function historyPage(session: Pick<LettaCodeSession, 'listMessages' | 'sendCommand'>, agentId: string, conversationId: string, options: ListMessagesOptions, timeoutMs?: number): Promise<ListMessagesResult> {
17
+ if (conversationId !== 'default' && timeoutMs === undefined) return session.listMessages({ ...options, conversationId });
18
+ const response = await session.sendCommand({ type: 'conversation_messages_list', conversation_id: conversationId, query: { ...options, agent_id: agentId } }, { responseType: 'conversation_messages_list_response', ...(timeoutMs === undefined ? {} : { timeoutMs }) });
19
+ if (response.success !== true || !Array.isArray(response.messages)) throw new Error('Unable to load authoritative default conversation history');
20
+ return { messages: response.messages as ListMessagesResult['messages'],
21
+ ...(typeof response.has_more === 'boolean' ? { hasMore: response.has_more } : {}),
22
+ ...(typeof response.next_before === 'string' || response.next_before === null ? { nextBefore: response.next_before } : {}) };
23
+ }
24
+
25
+ /** Page backwards through history up to `maximum` records, validating cursors and IDs. */
26
+ export async function loadHistory(page: (options: ListMessagesOptions) => Promise<ListMessagesResult>, maximum = HISTORY_LIMIT): Promise<{ messages: ListMessagesResult['messages']; truncated: boolean }> {
27
+ if (!Number.isInteger(maximum) || maximum < 1) throw new Error('Invalid history bound');
28
+ const newest: ListMessagesResult['messages'] = [];
29
+ const ids = new Set<string>();
30
+ const cursors = new Set<string>();
31
+ let before: string | undefined;
32
+ let truncated = false;
33
+ while (true) {
34
+ const limit = Math.min(100, maximum - newest.length);
35
+ const result = await page({ order: 'desc', limit, ...(before ? { before } : {}) });
36
+ if (!Array.isArray(result.messages) || result.messages.length > limit) throw new Error('Invalid or oversized history page');
37
+ for (const message of result.messages) {
38
+ if (!message.id || ids.has(message.id)) throw new Error('History pagination returned duplicate or missing message IDs');
39
+ ids.add(message.id); newest.push(message);
40
+ }
41
+ if (result.hasMore === false) break;
42
+ if (!result.messages.length) {
43
+ if (result.hasMore === true) throw new Error('History pagination reported more data but returned no messages');
44
+ break;
45
+ }
46
+ if (newest.length >= maximum) { truncated = true; break; }
47
+ const cursor = result.nextBefore ?? result.messages.at(-1)?.id;
48
+ if (!cursor || cursors.has(cursor) || cursor === before) throw new Error('History pagination did not advance');
49
+ cursors.add(cursor); before = cursor;
50
+ }
51
+ // Preserve backend order (including equal timestamps and split tool IDs).
52
+ return { messages: newest.reverse(), truncated };
53
+ }
54
+
55
+ function textContent(content: unknown, user = false): string {
56
+ let text = typeof content === 'string' ? content : Array.isArray(content) ? content.map(part => {
57
+ const p = record(part); return p?.type === 'text' && typeof p.text === 'string' ? p.text : '';
58
+ }).filter(Boolean).join('\n') : '';
59
+ if (user) {
60
+ // Harness inserts these as ordinary user content on reconnect. Never expose
61
+ // environment, memory, or system internals as if the user had typed them.
62
+ text = text.replace(/<system-reminder\b[^>]*>[\s\S]*?<\/system-reminder>/gi, '').replace(/<system-reminder\b[\s\S]*$/gi, '');
63
+ try {
64
+ const envelope = JSON.parse(text);
65
+ if (envelope && typeof envelope === 'object' && typeof envelope.type === 'string') {
66
+ if (envelope.type === 'user_message' && typeof envelope.message === 'string') text = envelope.message;
67
+ else if (['heartbeat', 'system_message', 'system_alert', 'memory_warning'].includes(envelope.type)) return '';
68
+ }
69
+ } catch { /* ordinary human text */ }
70
+ }
71
+ return sanitizeText(text).trim();
72
+ }
73
+
74
+ /** Text shown for a user image that cannot be displayed (unsupported, invalid, or over the display budget). */
75
+ export const IMAGE_PLACEHOLDER = '[Image]';
76
+ /** Most decoded image bytes restored for display from one conversation, newest first. Older images become placeholders. */
77
+ export const HISTORY_IMAGE_BUDGET = 48 * 1024 * 1024;
78
+
79
+ /** Letta `ImageContent` items of a backend user record, in order. */
80
+ function imageItems(content: unknown): Row[] {
81
+ return Array.isArray(content) ? content.map(record).filter((p): p is Row => p?.type === 'image') : [];
82
+ }
83
+
84
+ /**
85
+ * Display part for one stored image: a `data:` URL for a well-formed PNG,
86
+ * JPEG, GIF or WebP within budget, otherwise the {@link IMAGE_PLACEHOLDER}.
87
+ * The bytes are re-checked, so a mislabelled record never becomes a data URL
88
+ * of another type.
89
+ */
90
+ function imagePart(item: Row, budget: { left: number }): UIMessage['parts'][number] {
91
+ const source = record(item.source);
92
+ if (budget.left > 0 && source?.type === 'base64' && typeof source.data === 'string' && typeof source.media_type === 'string') {
93
+ try {
94
+ const image = decodeImagePart({ type: 'image', image: source.data, mediaType: source.media_type });
95
+ if (image.bytes <= budget.left) {
96
+ budget.left -= image.bytes;
97
+ return { type: 'file', mediaType: image.mediaType, url: `data:${image.mediaType};base64,${image.base64}` };
98
+ }
99
+ } catch { /* not displayable */ }
100
+ }
101
+ return { type: 'text', text: IMAGE_PLACEHOLDER };
102
+ }
103
+
104
+ /** Display-only message time from the backend record, when it is a valid date. */
105
+ function timestamp(row: Row): { metadata?: { createdAt: string } } {
106
+ const time = typeof row.date === 'string' ? Date.parse(row.date) : NaN;
107
+ return Number.isFinite(time) ? { metadata: { createdAt: new Date(time).toISOString() } } : {};
108
+ }
109
+
110
+ /** Display projection only. Never reconstruct the model's context from this.
111
+ * Completed allowlisted app tools become inert output cards; everything else is
112
+ * omitted, including pending approvals, reasoning, system, memory and events.
113
+ * User images become `file` parts with a `data:` URL (newest first, within
114
+ * `imageBudget` bytes); the rest become an `[Image]` text placeholder.
115
+ */
116
+ export function projectHistory(messages: ListMessagesResult['messages'], appTools: readonly string[], imageBudget = HISTORY_IMAGE_BUDGET): UIMessage[] {
117
+ const returns = new Map<string, Row>();
118
+ for (const message of messages) {
119
+ const row = message as unknown as Row;
120
+ if (row.message_type === 'tool_return_message' && typeof row.tool_call_id === 'string') returns.set(row.tool_call_id, row);
121
+ }
122
+ // Spend the image budget on the newest images first.
123
+ const images = new Map<string, UIMessage['parts']>();
124
+ const budget = { left: imageBudget };
125
+ for (let index = messages.length - 1; index >= 0; index--) {
126
+ const row = messages[index] as unknown as Row;
127
+ if (row.message_type !== 'user_message') continue;
128
+ const items = imageItems(row.content);
129
+ if (items.length) images.set(messages[index]!.id, items.reverse().map(item => imagePart(item, budget)).reverse());
130
+ }
131
+ const shownCalls = new Set<string>();
132
+ const projected: UIMessage[] = [];
133
+ for (const message of messages) {
134
+ const row = message as unknown as Row;
135
+ if (row.message_type === 'user_message' || row.message_type === 'assistant_message') {
136
+ const role = row.message_type === 'user_message' ? 'user' : 'assistant';
137
+ const text = textContent(row.content, role === 'user');
138
+ const attached = role === 'user' ? images.get(message.id) ?? [] : [];
139
+ if (text || attached.length) projected.push({ id: `history-${message.id}`, role, parts: [...(text ? [{ type: 'text' as const, text }] : []), ...attached], ...timestamp(row) });
140
+ } else if (row.message_type === 'tool_call_message' || row.message_type === 'approval_request_message') {
141
+ const call = record(row.tool_call);
142
+ const id = call?.tool_call_id;
143
+ const name = call?.name;
144
+ if (!call || typeof id !== 'string' || typeof name !== 'string' || !appTools.includes(name) || shownCalls.has(id)) continue;
145
+ const output = returns.get(id);
146
+ if (!output || !['success', 'error'].includes(String(output.status))) continue;
147
+ let input: unknown;
148
+ try { input = typeof call.arguments === 'string' ? JSON.parse(sanitizeText(call.arguments)) : JSON.parse(sanitizeText(JSON.stringify(call.arguments))); }
149
+ catch { continue; }
150
+ const text = textContent(output.tool_return);
151
+ let value: unknown = text;
152
+ try { value = JSON.parse(text); } catch { /* text output */ }
153
+ shownCalls.add(id);
154
+ projected.push({ id: `history-${message.id}`, role: 'assistant', ...timestamp(row), parts: [{
155
+ type: 'dynamic-tool', toolName: name, toolCallId: id, input,
156
+ providerExecuted: true,
157
+ ...(output.status === 'error' ? { state: 'output-error', errorText: text } : { state: 'output-available', output: value }),
158
+ }] });
159
+ }
160
+ }
161
+ return projected;
162
+ }
163
+
164
+ /** @throws when the conversation ends with an unanswered user turn or an unfinished tool call. */
165
+ export function assertHistorySettled(messages: ListMessagesResult['messages']) {
166
+ // Conservative: do not resume an abandoned prompt or half-completed tool chain.
167
+ let pendingUser = false;
168
+ const calls = new Set<string>();
169
+ for (const message of messages) {
170
+ const row = message as unknown as Row;
171
+ if (row.message_type === 'user_message' && (textContent(row.content, true) || imageItems(row.content).length)) pendingUser = true;
172
+ if (row.message_type === 'assistant_message' && textContent(row.content)) pendingUser = false;
173
+ if (row.message_type === 'tool_call_message' || row.message_type === 'approval_request_message') {
174
+ const id = record(row.tool_call)?.tool_call_id;
175
+ if (typeof id === 'string') calls.add(id);
176
+ }
177
+ if (row.message_type === 'tool_return_message' && typeof row.tool_call_id === 'string') calls.delete(row.tool_call_id);
178
+ }
179
+ if (pendingUser || calls.size) throw new Error('Conversation history has an unfinished or uncertain turn. Inspect backend; no implicit retry or repair. Select another conversation to continue.');
180
+ }
181
+
182
+ /** List every conversation of one agent, failing closed on foreign or repeated rows. */
183
+ export async function listConversations(list: (options: { agentId: string; after?: string; limit: number; order: 'asc'; orderBy: 'createdAt' }) => Promise<LettaConversation[]>, agentId: string): Promise<LettaConversation[]> {
184
+ const result: LettaConversation[] = [];
185
+ const ids = new Set<string>();
186
+ let after: string | undefined;
187
+ while (true) {
188
+ const page = await list({ agentId, limit: 100, order: 'asc', orderBy: 'createdAt', ...(after ? { after } : {}) });
189
+ if (!page.length) break;
190
+ for (const conversation of page) {
191
+ if (conversation.agent_id !== agentId || ids.has(conversation.id)) throw new Error('Conversation listing returned wrong agent or non-advancing cursor');
192
+ ids.add(conversation.id); result.push(conversation);
193
+ }
194
+ after = page.at(-1)!.id;
195
+ if (result.length > 10_000) throw new Error('Conversation list exceeds 10,000; refusing to silently truncate');
196
+ }
197
+ return result;
198
+ }
@@ -0,0 +1,125 @@
1
+ import { chmodSync, closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /** Persisted mapping from a logical definition ID to the Letta-generated agent ID. */
5
+ export type Identity = { version: 2; definitionId: string; name: string; backend: string; agentId: string; conversationId: string };
6
+
7
+ /** Letta conversation IDs accepted by this library (`default` is the agent's default conversation). */
8
+ export const validConversationId = (id: string): boolean => id === 'default' || /^(?:conv-|local-conv-)[a-zA-Z0-9-]+$/.test(id);
9
+ const validLocalAgentId = (id: unknown): id is string => typeof id === 'string' && /^agent-local-[a-zA-Z0-9-]+$/.test(id);
10
+
11
+ /** Backend operations used while acquiring an identity. */
12
+ export interface IdentityBackend {
13
+ /** Create the Letta agent. Called at most once per logical ID, ever. */
14
+ create(): Promise<string>;
15
+ /** Confirm the mapped agent still exists and matches; throw otherwise. */
16
+ validate(id: string): Promise<void>;
17
+ }
18
+
19
+ /** Handle returned by {@link acquireIdentity}. Holds an exclusive lock until `release()`. */
20
+ export type IdentityLease = Awaited<ReturnType<typeof acquireIdentity>>;
21
+
22
+ /**
23
+ * Acquire the durable identity for a definition, creating the Letta agent on
24
+ * first use. Fails closed on stale locks, uncertain creations or mismatches:
25
+ * it never searches by name, recreates, or retries.
26
+ *
27
+ * Files in `directory` (all 0600, directory 0700):
28
+ * - `<id>.json` the mapping; `<id>.lock` the process lock;
29
+ * - `<id>.pending.json` / `<id>.conversation.pending.json` uncertain creations;
30
+ * - `<id>.<conversation>.turn.pending.json` a turn whose delivery is not yet confirmed.
31
+ */
32
+ export async function acquireIdentity(directory: string, definition: { id: string; name: string }, backend: string, api: IdentityBackend) {
33
+ if (!/^[a-z0-9-]+$/.test(definition.id)) throw new Error('Invalid logical agent identity');
34
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
35
+ if (lstatSync(directory).isSymbolicLink()) throw new Error('Unsafe identity directory');
36
+ chmodSync(directory, 0o700);
37
+ const base = join(directory, definition.id);
38
+ const lock = `${base}.lock`;
39
+ let fd: number;
40
+ try { fd = openSync(lock, 'wx', 0o600); }
41
+ catch { throw new Error(`Agent identity is locked: ${lock}. Another process may be running. After a crash, verify its PID is no longer running before removing the lock; never remove a pending intent blindly.`); }
42
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
43
+ fsyncSync(fd); closeSync(fd);
44
+ let released = false;
45
+ const release = () => { if (!released) { unlinkSync(lock); released = true; } };
46
+ const file = `${base}.json`;
47
+ const pending = `${base}.pending.json`;
48
+ const syncDirectory = () => { const dir = openSync(directory, 'r'); try { fsyncSync(dir); } finally { closeSync(dir); } };
49
+ const exists = (path: string) => {
50
+ try { lstatSync(path); return true; }
51
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error; }
52
+ };
53
+ const read = (path: string) => {
54
+ if (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()) throw new Error('Unsafe identity file');
55
+ return JSON.parse(readFileSync(path, 'utf8'));
56
+ };
57
+ const durableWrite = (path: string, value: unknown) => {
58
+ const handle = openSync(path, 'wx', 0o600);
59
+ try { writeFileSync(handle, JSON.stringify(value, null, 2)); fsyncSync(handle); } finally { closeSync(handle); }
60
+ syncDirectory();
61
+ };
62
+ try {
63
+ if (exists(pending)) throw new Error(`Unresolved agent creation intent: ${pending}. Inspect the local backend and reconcile manually; refusing to create a duplicate.`);
64
+ const conversationPending = `${base}.conversation.pending.json`;
65
+ if (exists(conversationPending)) throw new Error(`Unresolved conversation creation intent: ${conversationPending}. Reconcile with backend before continuing; refusing to create a duplicate.`);
66
+ let identity: Identity;
67
+ let migrate = false;
68
+ const save = () => {
69
+ if (released) throw new Error('Identity lock already released');
70
+ durableWrite(`${file}.new`, identity);
71
+ renameSync(`${file}.new`, file);
72
+ syncDirectory();
73
+ };
74
+ if (exists(file)) {
75
+ const stored = read(file);
76
+ if (![1, 2].includes(stored.version) || stored.definitionId !== definition.id || stored.name !== definition.name || stored.backend !== backend || typeof stored.conversationId !== 'string' || !validConversationId(stored.conversationId) || (stored.version === 1 && stored.conversationId !== 'default') || !validLocalAgentId(stored.agentId)) throw new Error('Invalid identity mapping or backend mismatch; refusing to recreate agent');
77
+ migrate = stored.version === 1;
78
+ identity = { ...stored, version: 2 };
79
+ } else {
80
+ durableWrite(pending, { definitionId: definition.id, name: definition.name, backend, createdAt: new Date().toISOString(), state: 'creation-uncertain' });
81
+ const agentId = await api.create();
82
+ if (!validLocalAgentId(agentId)) throw new Error('SDK returned a non-local agent ID');
83
+ identity = { version: 2, definitionId: definition.id, name: definition.name, backend, agentId, conversationId: 'default' };
84
+ // Keep the intent until an fsynced mapping is in place. Crashes fail closed.
85
+ durableWrite(`${file}.new`, identity);
86
+ renameSync(`${file}.new`, file);
87
+ syncDirectory();
88
+ unlinkSync(pending);
89
+ }
90
+ await api.validate(identity.agentId);
91
+ if (migrate) save();
92
+ const selectConversation = (id: string) => {
93
+ if (!validConversationId(id)) throw new Error('Invalid conversation ID');
94
+ identity.conversationId = id; save();
95
+ };
96
+ const createConversation = async (create: (agentId: string) => Promise<string>) => {
97
+ if (released || exists(conversationPending)) throw new Error('Unresolved conversation creation or released lock');
98
+ durableWrite(conversationPending, { agentId: identity.agentId, createdAt: new Date().toISOString(), state: 'creation-uncertain' });
99
+ const id = await create(identity.agentId);
100
+ if (id === 'default' || !validConversationId(id)) throw new Error('Invalid created conversation');
101
+ selectConversation(id);
102
+ unlinkSync(conversationPending);
103
+ syncDirectory();
104
+ return id;
105
+ };
106
+ const turnPath = (id: string) => {
107
+ if (!validConversationId(id)) throw new Error('Invalid conversation ID');
108
+ return `${base}.${id}.turn.pending.json`;
109
+ };
110
+ const assertNoPendingTurn = (id: string) => {
111
+ if (exists(turnPath(id))) throw new Error(`Uncertain prior delivery: ${turnPath(id)}. Inspect backend and reconcile manually; no automatic replay. Select another conversation to continue.`);
112
+ };
113
+ const beginTurn = (id: string) => {
114
+ if (released) throw new Error('Identity lock already released');
115
+ assertNoPendingTurn(id);
116
+ durableWrite(turnPath(id), { agentId: identity.agentId, conversationId: id, createdAt: new Date().toISOString(), state: 'delivery-uncertain' });
117
+ };
118
+ const completeTurn = (id: string) => {
119
+ if (released) throw new Error('Identity lock already released');
120
+ unlinkSync(turnPath(id));
121
+ syncDirectory();
122
+ };
123
+ return { identity, release, selectConversation, createConversation, assertNoPendingTurn, beginTurn, completeTurn };
124
+ } catch (error) { release(); throw error; }
125
+ }
package/src/images.ts ADDED
@@ -0,0 +1,184 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { ImageContent } from '@letta-ai/letta-agent-sdk';
3
+
4
+ /** Image types a user turn may carry. Letta's `ImageContent` accepts exactly these. */
5
+ export const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'] as const;
6
+ export type ImageMediaType = typeof IMAGE_MEDIA_TYPES[number];
7
+
8
+ /**
9
+ * Bounds for images in one user turn (decoded bytes). They keep requests,
10
+ * backend history and restored views bounded; clients should downscale before
11
+ * sending (the browser app does).
12
+ */
13
+ export interface ImageLimits { maxImageBytes: number; maxImages: number; maxTotalBytes: number }
14
+ export const IMAGE_LIMITS: Readonly<ImageLimits> = Object.freeze({
15
+ /** Largest single image. */
16
+ maxImageBytes: 5 * 1024 * 1024,
17
+ /** Most images in one user turn. */
18
+ maxImages: 4,
19
+ /** Largest combined size of all images in one user turn. */
20
+ maxTotalBytes: 10 * 1024 * 1024,
21
+ });
22
+
23
+ /** Machine-readable reason for {@link ImageInputError}. */
24
+ export type ImageInputErrorCode =
25
+ | 'image_unsupported_type' // not PNG, JPEG, GIF or WebP (by declared type or content)
26
+ | 'image_invalid' // not base64, empty, or bytes that do not match the declared type
27
+ | 'image_remote_url' // http(s) or other URLs: images are never fetched
28
+ | 'image_too_large' // one image over maxImageBytes
29
+ | 'images_too_many' // more than maxImages
30
+ | 'images_too_large'; // combined size over maxTotalBytes
31
+
32
+ /** A rejected image input. `code` is stable; `message` is human-readable. */
33
+ export class ImageInputError extends Error {
34
+ override readonly name = 'ImageInputError';
35
+ constructor(readonly code: ImageInputErrorCode, message: string) { super(message); }
36
+ }
37
+
38
+ const MB = (bytes: number) => `${Math.round(bytes / 1024 / 1024)} MB`;
39
+ const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
40
+ /** Reference provider key used for images in the in-memory transcript. */
41
+ export const IMAGE_REFERENCE_PROVIDER = 'ai-sdk-letta-sha256';
42
+
43
+ /** Detect PNG, JPEG, GIF or WebP from magic bytes; `undefined` otherwise. */
44
+ export function sniffImageType(bytes: Uint8Array): ImageMediaType | undefined {
45
+ const at = (offset: number, ...values: number[]) => values.every((value, index) => bytes[offset + index] === value);
46
+ if (at(0, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return 'image/png';
47
+ if (at(0, 0xff, 0xd8, 0xff)) return 'image/jpeg';
48
+ if (at(0, 0x47, 0x49, 0x46, 0x38) && (bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61) return 'image/gif';
49
+ if (at(0, 0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50)) return 'image/webp';
50
+ return undefined;
51
+ }
52
+
53
+ /** Normalize a declared media type: `image/jpg` → `image/jpeg`, parameters and case dropped; `image`/`image/*` mean "detect". */
54
+ function declaredType(mediaType: unknown): ImageMediaType | 'detect' {
55
+ if (mediaType === undefined) return 'detect';
56
+ if (typeof mediaType !== 'string') throw new ImageInputError('image_unsupported_type', 'Image media type must be a string');
57
+ const type = mediaType.split(';', 1)[0]!.trim().toLowerCase();
58
+ if (type === 'image' || type === 'image/*') return 'detect';
59
+ const normalized = type === 'image/jpg' ? 'image/jpeg' : type;
60
+ if (!(IMAGE_MEDIA_TYPES as readonly string[]).includes(normalized)) throw new ImageInputError('image_unsupported_type', `Unsupported image type ${type || 'unknown'}; use PNG, JPEG, GIF or WebP`);
61
+ return normalized as ImageMediaType;
62
+ }
63
+
64
+ /** A validated image, ready for Letta. */
65
+ export interface DecodedImage { mediaType: ImageMediaType; base64: string; bytes: number; sha256: string }
66
+
67
+ function fromBase64(base64: string, declared: ImageMediaType | 'detect', limit: number): DecodedImage {
68
+ const clean = base64.replace(/\s+/g, '');
69
+ if (!clean || clean.length % 4 === 1 || !base64Pattern.test(clean)) throw new ImageInputError('image_invalid', 'Image data is not valid base64');
70
+ // Cheap bound before decoding anything large.
71
+ if (Math.floor(clean.length * 3 / 4) - 2 > limit) throw new ImageInputError('image_too_large', `Each image can be up to ${MB(limit)}`);
72
+ const buffer = Buffer.from(clean, 'base64');
73
+ return fromBytes(buffer, declared, limit, clean);
74
+ }
75
+
76
+ function fromBytes(bytes: Uint8Array, declared: ImageMediaType | 'detect', limit: number, base64?: string): DecodedImage {
77
+ if (!bytes.byteLength) throw new ImageInputError('image_invalid', 'Image is empty');
78
+ if (bytes.byteLength > limit) throw new ImageInputError('image_too_large', `Each image can be up to ${MB(limit)}`);
79
+ const sniffed = sniffImageType(bytes);
80
+ if (!sniffed) throw new ImageInputError(declared === 'detect' ? 'image_unsupported_type' : 'image_invalid', declared === 'detect' ? 'Unsupported image; use PNG, JPEG, GIF or WebP' : `Image content is not a valid ${declared.slice(6).toUpperCase()}`);
81
+ if (declared !== 'detect' && declared !== sniffed) throw new ImageInputError('image_invalid', `Image content (${sniffed}) does not match its declared type (${declared})`);
82
+ const data = base64 ?? Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
83
+ return { mediaType: sniffed, base64: data, bytes: bytes.byteLength, sha256: createHash('sha256').update(bytes).digest('hex') };
84
+ }
85
+
86
+ /** Parse a `data:` URL (base64 only). */
87
+ function fromDataUrl(url: string, declared: ImageMediaType | 'detect', limit: number): DecodedImage {
88
+ const match = /^data:([^,;]*)((?:;[^,;]*)*),(.*)$/s.exec(url);
89
+ if (!match || !match[2]!.split(';').includes('base64')) throw new ImageInputError('image_invalid', 'Image data URLs must be base64-encoded');
90
+ const urlType = match[1] ? declaredType(match[1]) : 'detect';
91
+ if (declared !== 'detect' && urlType !== 'detect' && urlType !== declared) throw new ImageInputError('image_invalid', 'Image data URL type does not match the declared type');
92
+ return fromBase64(match[3]!, declared === 'detect' ? urlType : declared, limit);
93
+ }
94
+
95
+ /** An image-carrying user content part, as found in AI SDK model messages. */
96
+ type PartLike = { type: string; image?: unknown; data?: unknown; mediaType?: unknown };
97
+
98
+ /** The reference hash in a compact transcript part, if this part is one. */
99
+ function referenceOf(value: unknown): string | undefined {
100
+ const pick = (v: unknown) => {
101
+ const ref = v && typeof v === 'object' && !(v instanceof Uint8Array) && !(v instanceof ArrayBuffer) && !(v instanceof URL) ? (v as Record<string, unknown>)[IMAGE_REFERENCE_PROVIDER] : undefined;
102
+ return typeof ref === 'string' && /^[a-f0-9]{64}$/.test(ref) ? ref : undefined;
103
+ };
104
+ if (value && typeof value === 'object' && (value as { type?: unknown }).type === 'reference') return pick((value as { reference?: unknown }).reference);
105
+ return pick(value);
106
+ }
107
+
108
+ /** True for AI SDK `image` parts and `file` parts whose media type is an image. */
109
+ export function isImagePart(part: PartLike): boolean {
110
+ if (part.type === 'image') return true;
111
+ return part.type === 'file' && typeof part.mediaType === 'string' && /^image(?:\/|$)/i.test(part.mediaType.trim());
112
+ }
113
+
114
+ /**
115
+ * Validate one AI SDK `image` or `file` part and decode it. Accepts base64
116
+ * strings, `data:` URLs (string or `URL`), bytes, and the tagged
117
+ * `{ type: 'data' | 'url' }` file shapes. Remote URLs are rejected: images are
118
+ * never fetched.
119
+ * @throws {ImageInputError}
120
+ */
121
+ export function decodeImagePart(part: PartLike, limit: number = IMAGE_LIMITS.maxImageBytes): DecodedImage {
122
+ const declared = declaredType(part.mediaType);
123
+ let value: unknown = part.type === 'image' ? part.image : part.data;
124
+ if (value && typeof value === 'object' && !(value instanceof Uint8Array) && !(value instanceof ArrayBuffer) && !(value instanceof URL)) {
125
+ const tagged = value as { type?: unknown; data?: unknown; url?: unknown };
126
+ if (tagged.type === 'data') value = tagged.data;
127
+ else if (tagged.type === 'url') value = tagged.url;
128
+ else throw new ImageInputError('image_invalid', 'Provider file references and inline text are not supported for images');
129
+ }
130
+ if (value instanceof URL) value = value.href;
131
+ if (typeof value === 'string') {
132
+ if (/^data:/i.test(value)) return fromDataUrl(value, declared, limit);
133
+ // Base64 never contains ':'; anything with a scheme is a URL we will not fetch.
134
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value.trimStart())) throw new ImageInputError('image_remote_url', 'Image URLs are not fetched; attach the image data instead');
135
+ return fromBase64(value, declared, limit);
136
+ }
137
+ if (value instanceof ArrayBuffer) return fromBytes(new Uint8Array(value), declared, limit);
138
+ if (value instanceof Uint8Array) return fromBytes(value, declared, limit);
139
+ throw new ImageInputError('image_invalid', 'Unsupported image data');
140
+ }
141
+
142
+ /** Enforce count and combined size over already-decoded images. @throws {ImageInputError} */
143
+ export function assertImageBudget(images: readonly { bytes: number }[], limits: Readonly<ImageLimits> = IMAGE_LIMITS): void {
144
+ if (images.length > limits.maxImages) throw new ImageInputError('images_too_many', `Attach up to ${limits.maxImages} images per message`);
145
+ const total = images.reduce((sum, image) => sum + image.bytes, 0);
146
+ if (total > limits.maxTotalBytes) throw new ImageInputError('images_too_large', `Images in one message can total up to ${MB(limits.maxTotalBytes)}`);
147
+ }
148
+
149
+ /**
150
+ * Validate a list of base64 images (as received over HTTP) against every
151
+ * limit. Returns decoded images with their content hash.
152
+ * @throws {ImageInputError}
153
+ */
154
+ export function validateImages(images: readonly { mediaType?: unknown; data?: unknown }[], limits: Readonly<ImageLimits> = IMAGE_LIMITS): DecodedImage[] {
155
+ if (!Array.isArray(images)) throw new ImageInputError('image_invalid', 'Images must be a list');
156
+ if (images.length > limits.maxImages) throw new ImageInputError('images_too_many', `Attach up to ${limits.maxImages} images per message`);
157
+ const decoded = images.map(image => {
158
+ if (!image || typeof image !== 'object' || typeof image.data !== 'string') throw new ImageInputError('image_invalid', 'Each image needs base64 data');
159
+ return decodeImagePart({ type: 'image', image: image.data, mediaType: image.mediaType }, limits.maxImageBytes);
160
+ });
161
+ assertImageBudget(decoded, limits);
162
+ return decoded;
163
+ }
164
+
165
+ /** Letta `ImageContent` for a decoded image. */
166
+ export const toLettaImage = (image: DecodedImage): ImageContent => ({ type: 'image', source: { type: 'base64', media_type: image.mediaType, data: image.base64 } });
167
+
168
+ /**
169
+ * Content hash of an image part, for history comparison. Compact transcript
170
+ * references (see {@link compactImagePart}) yield their stored hash; anything
171
+ * else is decoded and hashed.
172
+ */
173
+ export function imagePartDigest(part: PartLike): string {
174
+ return referenceOf(part.type === 'image' ? part.image : part.data) ?? decodeImagePart(part, Number.MAX_SAFE_INTEGER).sha256;
175
+ }
176
+
177
+ /** Replace an image part's data with a content-hash reference, so transcripts never hold image bytes twice. */
178
+ export function compactImagePart(part: PartLike): { type: 'file'; mediaType: string; data: { type: 'reference'; reference: Record<string, string> } } {
179
+ const existing = referenceOf(part.type === 'image' ? part.image : part.data);
180
+ const mediaType = typeof part.mediaType === 'string' && part.mediaType.includes('/') ? part.mediaType : 'image';
181
+ if (existing) return { type: 'file', mediaType, data: { type: 'reference', reference: { [IMAGE_REFERENCE_PROVIDER]: existing } } };
182
+ const image = decodeImagePart(part, Number.MAX_SAFE_INTEGER);
183
+ return { type: 'file', mediaType: image.mediaType, data: { type: 'reference', reference: { [IMAGE_REFERENCE_PROVIDER]: image.sha256 } } };
184
+ }