diff --git a/package-lock.json b/package-lock.json index 43c8c1c..54aa0ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@clack/prompts": "^0.11.0", "@hapi/boom": "^10.0.1", "@letta-ai/letta-client": "^1.7.11", - "@letta-ai/letta-code-sdk": "^0.1.9", + "@letta-ai/letta-code-sdk": "^0.1.10", "@types/express": "^5.0.6", "@types/node": "^25.0.10", "@types/node-schedule": "^2.1.8", @@ -1290,9 +1290,9 @@ } }, "node_modules/@letta-ai/letta-code-sdk": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.1.9.tgz", - "integrity": "sha512-bk/Q9g9ob9RqQDge4aObPbWbmufaz71XhhApgORwkNh+OaMgbhHLJ5mye+ocHEGG4b/a6odRvWqNzIEX94aX+A==", + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.1.10.tgz", + "integrity": "sha512-idNRvPI6RbBho0jzm46NbMM4xjRPXLTvOniKbvimnlHDRkx6acsZy1exeu56Xmkpx83orvdcjqsuccBqnZFxNA==", "license": "Apache-2.0", "dependencies": { "@letta-ai/letta-code": "0.17.1" diff --git a/package.json b/package.json index 47d8ec1..87b6a2f 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "@clack/prompts": "^0.11.0", "@hapi/boom": "^10.0.1", "@letta-ai/letta-client": "^1.7.11", - "@letta-ai/letta-code-sdk": "^0.1.9", + "@letta-ai/letta-code-sdk": "^0.1.10", "@types/express": "^5.0.6", "@types/node": "^25.0.10", "@types/node-schedule": "^2.1.8", diff --git a/src/api/server.ts b/src/api/server.ts index de79ce0..643de91 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -11,6 +11,7 @@ import { listPairingRequests, approvePairingCode } from '../pairing/store.js'; import { parseMultipart } from './multipart.js'; import type { AgentRouter } from '../core/interfaces.js'; import type { ChannelId } from '../core/types.js'; +import type { Store } from '../core/store.js'; import { generateCompletionId, extractLastUserMessage, buildCompletion, buildChunk, buildToolCallChunk, formatSSE, SSE_DONE, @@ -31,6 +32,9 @@ interface ServerOptions { apiKey: string; host?: string; // Bind address (default: 127.0.0.1 for security) corsOrigin?: string; // CORS origin (default: same-origin only) + stores?: Map; // Agent stores for management endpoints + agentChannels?: Map; // Channel IDs per agent name + sessionInvalidators?: Map void>; // Invalidate live sessions after store writes } /** @@ -555,6 +559,150 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions): return; } + // Route: GET /api/v1/status - Agent status (conversation IDs, channels) + if (req.url === '/api/v1/status' && req.method === 'GET') { + try { + if (!validateApiKey(req.headers, options.apiKey)) { + sendError(res, 401, 'Unauthorized'); + return; + } + const agents: Record = {}; + if (options.stores) { + for (const [name, store] of options.stores) { + const info = store.getInfo(); + agents[name] = { + agentId: info.agentId, + conversationId: info.conversationId || null, + conversations: info.conversations || {}, + channels: options.agentChannels?.get(name) || [], + baseUrl: info.baseUrl, + createdAt: info.createdAt, + lastUsedAt: info.lastUsedAt, + }; + } + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ agents })); + } catch (error: any) { + log.error('Status error:', error); + sendError(res, 500, error.message || 'Internal server error'); + } + return; + } + + // Route: POST /api/v1/conversation - Set conversation ID + if (req.url === '/api/v1/conversation' && req.method === 'POST') { + try { + if (!validateApiKey(req.headers, options.apiKey)) { + sendError(res, 401, 'Unauthorized'); + return; + } + if (!options.stores || options.stores.size === 0) { + sendError(res, 500, 'No stores configured'); + return; + } + + const body = await readBody(req, MAX_BODY_SIZE); + let request: { conversationId?: string; agent?: string; key?: string }; + try { + request = JSON.parse(body); + } catch { + sendError(res, 400, 'Invalid JSON body'); + return; + } + + if (!request.conversationId || typeof request.conversationId !== 'string') { + sendError(res, 400, 'Missing required field: conversationId'); + return; + } + + // Resolve agent name (default to first store) + const agentName = request.agent || options.stores.keys().next().value!; + const store = options.stores.get(agentName); + if (!store) { + sendError(res, 404, `Agent not found: ${agentName}`); + return; + } + + const key = request.key || 'shared'; + if (key === 'shared') { + store.conversationId = request.conversationId; + } else { + store.setConversationId(key, request.conversationId); + } + + // Invalidate the live session so the next message uses the new conversation + const invalidate = options.sessionInvalidators?.get(agentName); + if (invalidate) { + invalidate(key === 'shared' ? undefined : key); + } + + log.info(`API set conversation: agent=${agentName} key=${key} conv=${request.conversationId}`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, agent: agentName, key, conversationId: request.conversationId })); + } catch (error: any) { + log.error('Set conversation error:', error); + sendError(res, 500, error.message || 'Internal server error'); + } + return; + } + + // Route: GET /api/v1/conversations - List conversations from Letta API + if (req.url?.startsWith('/api/v1/conversations') && req.method === 'GET') { + try { + if (!validateApiKey(req.headers, options.apiKey)) { + sendError(res, 401, 'Unauthorized'); + return; + } + if (!options.stores || options.stores.size === 0) { + sendError(res, 500, 'No stores configured'); + return; + } + + const url = new URL(req.url, `http://${req.headers.host}`); + const agentName = url.searchParams.get('agent') || options.stores.keys().next().value!; + const store = options.stores.get(agentName); + if (!store) { + sendError(res, 404, `Agent not found: ${agentName}`); + return; + } + + const agentId = store.getInfo().agentId; + if (!agentId) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ conversations: [] })); + return; + } + + const { Letta } = await import('@letta-ai/letta-client'); + const client = new Letta({ + apiKey: process.env.LETTA_API_KEY || '', + baseURL: process.env.LETTA_BASE_URL || 'https://api.letta.com', + }); + const convos = await client.conversations.list({ + agent_id: agentId, + limit: 50, + order: 'desc', + order_by: 'last_run_completion', + }); + + const conversations = convos.map(c => ({ + id: c.id, + createdAt: c.created_at, + updatedAt: c.updated_at, + summary: c.summary || null, + messageCount: c.in_context_message_ids?.length || 0, + })); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ conversations })); + } catch (error: any) { + log.error('List conversations error:', error); + sendError(res, 500, error.message || 'Internal server error'); + } + return; + } + // Route: GET /portal - Admin portal for pairing approvals if ((req.url === '/portal' || req.url === '/portal/') && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); @@ -704,6 +852,33 @@ const portalHtml = ` /* Status bar */ .status { font-size: 12px; color: #444; text-align: center; margin-top: 16px; } + /* Management tab */ + .agent-card { background: #141414; border: 1px solid #222; border-radius: 8px; padding: 16px; margin-bottom: 12px; } + .agent-card h3 { font-size: 14px; color: #fff; margin-bottom: 10px; } + .agent-field { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #1a1a1a; font-size: 13px; } + .agent-field:last-child { border-bottom: none; } + .agent-field .label { color: #888; } + .agent-field .value { color: #ccc; font-family: monospace; font-size: 12px; max-width: 300px; overflow: hidden; text-overflow: ellipsis; } + .conv-entry { padding: 4px 0 4px 12px; font-size: 12px; color: #888; font-family: monospace; } + .set-conv-form { background: #141414; border: 1px solid #222; border-radius: 8px; padding: 16px; margin-top: 12px; } + .set-conv-form h3 { font-size: 14px; color: #fff; margin-bottom: 12px; } + .form-row { margin-bottom: 10px; } + .form-row label { display: block; font-size: 12px; color: #888; margin-bottom: 4px; } + .form-row input, .form-row select { width: 100%; padding: 8px 10px; background: #0a0a0a; border: 1px solid #333; border-radius: 6px; color: #fff; font-size: 13px; font-family: monospace; } + .form-row input:focus, .form-row select:focus { outline: none; border-color: #555; } + .set-conv-btn { padding: 8px 20px; background: #1a7f37; color: #fff; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; } + .set-conv-btn:hover { background: #238636; } + .set-conv-btn:disabled { background: #333; color: #666; cursor: default; } + .conv-list { margin-top: 12px; } + .conv-list h3 { font-size: 14px; color: #fff; margin-bottom: 8px; } + .conv-row { display: flex; align-items: center; padding: 10px 12px; background: #141414; border: 1px solid #222; border-radius: 6px; margin-bottom: 4px; cursor: pointer; gap: 12px; } + .conv-row:hover { border-color: #444; } + .conv-row.active { border-color: #1a7f37; background: #0d1117; } + .conv-row .conv-id { font-family: monospace; font-size: 12px; color: #ccc; min-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .conv-row .conv-meta { flex: 1; font-size: 12px; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .conv-row .conv-msgs { font-size: 11px; color: #555; white-space: nowrap; } + .conv-loading { padding: 16px; text-align: center; color: #555; font-size: 13px; } + .hidden { display: none; } @@ -728,8 +903,10 @@ const portalHtml = `