feat: add set-conversation command (CLI, API, channel, portal) (#512)
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, Store>; // Agent stores for management endpoints
|
||||
agentChannels?: Map<string, string[]>; // Channel IDs per agent name
|
||||
sessionInvalidators?: Map<string, (key?: string) => 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<string, any> = {};
|
||||
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 = `<!DOCTYPE html>
|
||||
/* 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; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -728,8 +903,10 @@ const portalHtml = `<!DOCTYPE html>
|
||||
<script>
|
||||
const CHANNELS = ['telegram', 'discord', 'slack'];
|
||||
let apiKey = sessionStorage.getItem('lbkey') || '';
|
||||
let activeChannel = 'telegram';
|
||||
let activeTab = 'telegram';
|
||||
let data = {};
|
||||
let statusData = {};
|
||||
let convListData = {};
|
||||
let refreshTimer;
|
||||
|
||||
function login() {
|
||||
@@ -760,33 +937,138 @@ async function refresh() {
|
||||
data[ch] = json.requests || [];
|
||||
} catch (e) { if (e.message === 'Unauthorized') return; data[ch] = []; }
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch('/api/v1/status');
|
||||
const json = await res.json();
|
||||
statusData = json.agents || {};
|
||||
} catch (e) { if (e.message === 'Unauthorized') return; }
|
||||
renderTabs();
|
||||
renderList();
|
||||
renderContent();
|
||||
if (activeTab === '__status__') loadConversations();
|
||||
document.getElementById('status').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
const el = document.getElementById('tabs');
|
||||
el.innerHTML = CHANNELS.map(ch => {
|
||||
let html = CHANNELS.map(ch => {
|
||||
const n = (data[ch] || []).length;
|
||||
const cls = ch === activeChannel ? 'tab active' : 'tab';
|
||||
const cls = ch === activeTab ? 'tab active' : 'tab';
|
||||
const count = n > 0 ? '<span class="count">' + n + '</span>' : '';
|
||||
return '<div class="' + cls + '" onclick="switchTab(\\'' + ch + '\\')">' + ch.charAt(0).toUpperCase() + ch.slice(1) + count + '</div>';
|
||||
}).join('');
|
||||
const statusCls = activeTab === '__status__' ? 'tab active' : 'tab';
|
||||
html += '<div class="' + statusCls + '" onclick="switchTab(\\'__status__\\')">Status</div>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
if (activeTab === '__status__') { renderStatus(); return; }
|
||||
renderList();
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const el = document.getElementById('list');
|
||||
const items = data[activeChannel] || [];
|
||||
const items = data[activeTab] || [];
|
||||
if (items.length === 0) { el.innerHTML = '<div class="empty">No pending pairing requests</div>'; return; }
|
||||
el.innerHTML = items.map(r => {
|
||||
const name = r.meta?.username ? '@' + r.meta.username : r.meta?.firstName || 'User ' + r.id;
|
||||
const ago = timeAgo(r.createdAt);
|
||||
return '<div class="request"><div class="code">' + esc(r.code) + '</div><div class="meta"><div class="name">' + esc(name) + '</div><div class="time">' + ago + '</div></div><button class="approve-btn" onclick="approve(\\'' + activeChannel + '\\',\\'' + r.code + '\\', this)">Approve</button></div>';
|
||||
return '<div class="request"><div class="code">' + esc(r.code) + '</div><div class="meta"><div class="name">' + esc(name) + '</div><div class="time">' + ago + '</div></div><button class="approve-btn" onclick="approve(\\'' + activeTab + '\\',\\'' + r.code + '\\', this)">Approve</button></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function switchTab(ch) { activeChannel = ch; renderTabs(); renderList(); }
|
||||
function renderStatus() {
|
||||
const el = document.getElementById('list');
|
||||
const agents = Object.entries(statusData);
|
||||
if (agents.length === 0) { el.innerHTML = '<div class="empty">No agents configured</div>'; return; }
|
||||
let html = '';
|
||||
const agentNames = agents.map(a => a[0]);
|
||||
for (const [name, info] of agents) {
|
||||
html += '<div class="agent-card"><h3>' + esc(name) + '</h3>';
|
||||
html += '<div class="agent-field"><span class="label">Agent ID</span><span class="value">' + esc(info.agentId || '(none)') + '</span></div>';
|
||||
html += '<div class="agent-field"><span class="label">Conversation</span><span class="value">' + esc(info.conversationId || '(none)') + '</span></div>';
|
||||
const convs = Object.entries(info.conversations || {});
|
||||
if (convs.length > 0) {
|
||||
html += '<div class="agent-field"><span class="label">Per-key conversations</span><span class="value">' + convs.length + '</span></div>';
|
||||
for (const [k, v] of convs) { html += '<div class="conv-entry">' + esc(k) + ' = ' + esc(v) + '</div>'; }
|
||||
}
|
||||
if (info.baseUrl) html += '<div class="agent-field"><span class="label">Server</span><span class="value">' + esc(info.baseUrl) + '</span></div>';
|
||||
if (info.lastUsedAt) html += '<div class="agent-field"><span class="label">Last used</span><span class="value">' + esc(info.lastUsedAt) + '</span></div>';
|
||||
html += '</div>';
|
||||
|
||||
// Conversation list from Letta API
|
||||
const conversations = convListData[name] || [];
|
||||
const activeConvId = info.conversationId;
|
||||
html += '<div class="conv-list"><h3>Conversations</h3>';
|
||||
if (conversations === 'loading') {
|
||||
html += '<div class="conv-loading">Loading...</div>';
|
||||
} else if (conversations.length === 0) {
|
||||
html += '<div class="conv-loading">No conversations found</div>';
|
||||
} else {
|
||||
for (const c of conversations) {
|
||||
const isActive = c.id === activeConvId ? ' active' : '';
|
||||
const summary = c.summary ? esc(c.summary) : '';
|
||||
const msgs = c.messageCount ? c.messageCount + ' msgs' : '';
|
||||
const ago = c.updatedAt ? timeAgo(c.updatedAt) : '';
|
||||
html += '<div class="conv-row' + isActive + '" onclick="pickConversation(\\'' + esc(c.id) + '\\',\\'' + esc(name) + '\\')">';
|
||||
html += '<div class="conv-id">' + esc(c.id) + '</div>';
|
||||
html += '<div class="conv-meta">' + (summary || ago) + '</div>';
|
||||
html += '<div class="conv-msgs">' + msgs + '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
html += '<div class="set-conv-form"><h3>Set Conversation</h3>';
|
||||
html += '<div class="form-row"><label>Agent</label><select id="sc-agent">';
|
||||
for (const n of agentNames) { html += '<option value="' + esc(n) + '">' + esc(n) + '</option>'; }
|
||||
html += '</select></div>';
|
||||
html += '<div class="form-row"><label>Key (leave empty for shared)</label><input id="sc-key" placeholder="e.g. telegram:12345"></div>';
|
||||
html += '<div class="form-row"><label>Conversation ID</label><input id="sc-id" placeholder="conversation-xxx"></div>';
|
||||
html += '<button class="set-conv-btn" onclick="setConversation(this)">Set Conversation</button>';
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadConversations() {
|
||||
for (const name of Object.keys(statusData)) {
|
||||
convListData[name] = 'loading';
|
||||
try {
|
||||
const res = await apiFetch('/api/v1/conversations?agent=' + encodeURIComponent(name));
|
||||
const json = await res.json();
|
||||
convListData[name] = json.conversations || [];
|
||||
} catch (e) { convListData[name] = []; }
|
||||
}
|
||||
if (activeTab === '__status__') renderStatus();
|
||||
}
|
||||
|
||||
async function pickConversation(id, agent) {
|
||||
try {
|
||||
const res = await apiFetch('/api/v1/conversation', { method: 'POST', body: JSON.stringify({ conversationId: id, agent }) });
|
||||
const json = await res.json();
|
||||
if (json.success) { toast('Switched to ' + id.slice(0, 20) + '...'); await refresh(); }
|
||||
else { toast(json.error || 'Failed', true); }
|
||||
} catch (e) { toast('Error: ' + e.message, true); }
|
||||
}
|
||||
|
||||
function switchTab(t) { activeTab = t; renderTabs(); renderContent(); }
|
||||
|
||||
async function setConversation(btn) {
|
||||
const agent = document.getElementById('sc-agent').value;
|
||||
const key = document.getElementById('sc-key').value.trim() || undefined;
|
||||
const conversationId = document.getElementById('sc-id').value.trim();
|
||||
if (!conversationId) { toast('Conversation ID is required', true); return; }
|
||||
btn.disabled = true; btn.textContent = '...';
|
||||
try {
|
||||
const body = { conversationId, agent };
|
||||
if (key) body.key = key;
|
||||
const res = await apiFetch('/api/v1/conversation', { method: 'POST', body: JSON.stringify(body) });
|
||||
const json = await res.json();
|
||||
if (json.success) { toast('Conversation set'); await refresh(); }
|
||||
else { toast(json.error || 'Failed', true); }
|
||||
} catch (e) { toast('Error: ' + e.message, true); }
|
||||
btn.disabled = false; btn.textContent = 'Set Conversation';
|
||||
}
|
||||
|
||||
async function approve(channel, code, btn) {
|
||||
btn.disabled = true; btn.textContent = '...';
|
||||
|
||||
@@ -236,7 +236,7 @@ Ask the bot owner to approve with:
|
||||
return;
|
||||
}
|
||||
if (this.onCommand) {
|
||||
if (command === 'status' || command === 'reset' || command === 'heartbeat' || command === 'cancel' || command === 'model') {
|
||||
if (command === 'status' || command === 'reset' || command === 'heartbeat' || command === 'cancel' || command === 'model' || command === 'setconv') {
|
||||
const result = await this.onCommand(command, message.channel.id, cmdArgs);
|
||||
if (result) {
|
||||
await message.channel.send(result);
|
||||
|
||||
@@ -268,6 +268,15 @@ export class TelegramAdapter implements ChannelAdapter {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle /setconv <id>
|
||||
this.bot.command('setconv', async (ctx) => {
|
||||
if (this.onCommand) {
|
||||
const args = ctx.match?.trim() || undefined;
|
||||
const result = await this.onCommand('setconv', String(ctx.chat.id), args);
|
||||
await ctx.reply(result || 'Failed to set conversation');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle text messages
|
||||
this.bot.on('message:text', async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
|
||||
51
src/cli.ts
51
src/cli.ts
@@ -247,6 +247,7 @@ Commands:
|
||||
todo complete <id> Mark a todo complete
|
||||
todo remove <id> Remove a todo
|
||||
todo snooze <id> Snooze a todo until a date
|
||||
set-conversation <id> Set a specific conversation ID
|
||||
reset-conversation Clear conversation ID (fixes corrupted conversations)
|
||||
destroy Delete all local data and start fresh
|
||||
pairing list <ch> List pending pairing requests
|
||||
@@ -507,6 +508,54 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'set-conversation': {
|
||||
const p = await import('@clack/prompts');
|
||||
const config = getConfig();
|
||||
const newConvId = subCommand;
|
||||
|
||||
if (!newConvId) {
|
||||
console.error('Usage: lettabot set-conversation <conversation-id>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
p.intro('Set Conversation');
|
||||
|
||||
const configuredName =
|
||||
(config.agent?.name?.trim())
|
||||
|| (config.agents?.length && config.agents[0].name?.trim())
|
||||
|| 'LettaBot';
|
||||
|
||||
const configuredAgents = (config.agents?.length ? config.agents : [{ name: configuredName }])
|
||||
.map(agent => agent.name?.trim())
|
||||
.filter((name): name is string => !!name);
|
||||
|
||||
const uniqueAgents = Array.from(new Set(configuredAgents));
|
||||
|
||||
let targetAgent = uniqueAgents[0];
|
||||
if (uniqueAgents.length > 1) {
|
||||
const choice = await p.select({
|
||||
message: 'Which agent?',
|
||||
options: uniqueAgents.map(name => ({ value: name, label: name })),
|
||||
});
|
||||
if (p.isCancel(choice)) {
|
||||
p.cancel('Cancelled');
|
||||
break;
|
||||
}
|
||||
targetAgent = choice as string;
|
||||
}
|
||||
|
||||
const store = new Store('lettabot-agent.json', targetAgent);
|
||||
const oldConvId = store.conversationId;
|
||||
store.conversationId = newConvId;
|
||||
|
||||
if (oldConvId) {
|
||||
p.log.info(`Previous conversation: ${oldConvId}`);
|
||||
}
|
||||
p.log.success(`Conversation set to: ${newConvId} (agent: ${targetAgent})`);
|
||||
p.outro('Restart the server for the change to take effect.');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'reset-conversation': {
|
||||
const p = await import('@clack/prompts');
|
||||
const config = getConfig();
|
||||
@@ -631,7 +680,7 @@ async function main() {
|
||||
|
||||
case undefined:
|
||||
console.log('Usage: lettabot <command>\n');
|
||||
console.log('Commands: onboard, server, configure, connect, model, channels, skills, reset-conversation, destroy, help\n');
|
||||
console.log('Commands: onboard, server, configure, connect, model, channels, skills, set-conversation, reset-conversation, destroy, help\n');
|
||||
console.log('Run "lettabot help" for more information.');
|
||||
break;
|
||||
|
||||
|
||||
109
src/core/bot.ts
109
src/core/bot.ts
@@ -187,7 +187,7 @@ export function resolveHeartbeatConversationKey(
|
||||
}
|
||||
|
||||
export class LettaBot implements AgentSession {
|
||||
private store: Store;
|
||||
readonly store: Store;
|
||||
private config: BotConfig;
|
||||
private channels: Map<string, ChannelAdapter> = new Map();
|
||||
private messageQueue: Array<{ msg: InboundMessage; adapter: ChannelAdapter }> = [];
|
||||
@@ -243,20 +243,32 @@ export class LettaBot implements AgentSession {
|
||||
return `${this.config.displayName}: ${text}`;
|
||||
}
|
||||
|
||||
private normalizeResultRunIds(msg: StreamMsg): string[] {
|
||||
// Forward-looking compatibility:
|
||||
// - Current SDK releases often emit result.run_ids as null/undefined.
|
||||
// - When runIds are absent, caller gets [] and falls back to streamed vs
|
||||
// result text comparison (which works with today's wire payloads).
|
||||
private normalizeStreamRunIds(msg: StreamMsg): string[] {
|
||||
const ids: string[] = [];
|
||||
|
||||
const rawRunId = (msg as StreamMsg & { runId?: unknown; run_id?: unknown }).runId
|
||||
?? (msg as StreamMsg & { run_id?: unknown }).run_id;
|
||||
if (typeof rawRunId === 'string' && rawRunId.trim().length > 0) {
|
||||
ids.push(rawRunId.trim());
|
||||
}
|
||||
|
||||
const rawRunIds = (msg as StreamMsg & { runIds?: unknown; run_ids?: unknown }).runIds
|
||||
?? (msg as StreamMsg & { run_ids?: unknown }).run_ids;
|
||||
if (!Array.isArray(rawRunIds)) return [];
|
||||
if (Array.isArray(rawRunIds)) {
|
||||
for (const id of rawRunIds) {
|
||||
if (typeof id === 'string' && id.trim().length > 0) {
|
||||
ids.push(id.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const runIds = rawRunIds.filter((id): id is string =>
|
||||
typeof id === 'string' && id.trim().length > 0
|
||||
);
|
||||
if (ids.length === 0) return [];
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
private normalizeResultRunIds(msg: StreamMsg): string[] {
|
||||
const runIds = this.normalizeStreamRunIds(msg);
|
||||
if (runIds.length === 0) return [];
|
||||
|
||||
return [...new Set(runIds)].sort();
|
||||
}
|
||||
|
||||
@@ -472,6 +484,14 @@ export class LettaBot implements AgentSession {
|
||||
return this.sessionManager.warmSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the live session for a conversation key.
|
||||
* The next message will create a fresh session using the current store state.
|
||||
*/
|
||||
invalidateSession(key?: string): void {
|
||||
this.sessionManager.invalidateSession(key);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Channel management
|
||||
// =========================================================================
|
||||
@@ -655,6 +675,26 @@ export class LettaBot implements AgentSession {
|
||||
lines.push('', 'Use `/model <handle>` to switch.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
case 'setconv': {
|
||||
if (!args?.trim()) {
|
||||
return 'Usage: /setconv <conversation-id>';
|
||||
}
|
||||
const newConvId = args.trim();
|
||||
const convKey = channelId ? this.resolveConversationKey(channelId, chatId) : 'shared';
|
||||
|
||||
if (convKey === 'default') {
|
||||
return 'Conversations are disabled -- cannot set conversation ID.';
|
||||
}
|
||||
|
||||
if (convKey === 'shared') {
|
||||
this.store.conversationId = newConvId;
|
||||
} else {
|
||||
this.store.setConversationId(convKey, newConvId);
|
||||
}
|
||||
this.sessionManager.invalidateSession(convKey);
|
||||
log.info(`/setconv - conversation set to ${newConvId} for key="${convKey}"`);
|
||||
return `Conversation set to: ${newConvId}`;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -980,6 +1020,10 @@ export class LettaBot implements AgentSession {
|
||||
let lastErrorDetail: { message: string; stopReason: string; apiError?: Record<string, unknown>; isApprovalError?: boolean } | null = null;
|
||||
let retryInfo: { attempt: number; maxAttempts: number; reason: string } | null = null;
|
||||
let reasoningBuffer = '';
|
||||
let expectedForegroundRunId: string | null = null;
|
||||
let expectedForegroundRunSource: 'assistant' | 'result' | null = null;
|
||||
let foregroundRunSwitchCount = 0;
|
||||
let filteredRunEventCount = 0;
|
||||
const msgTypeCounts: Record<string, number> = {};
|
||||
|
||||
const parseAndHandleDirectives = async () => {
|
||||
@@ -1056,6 +1100,46 @@ export class LettaBot implements AgentSession {
|
||||
break;
|
||||
}
|
||||
if (!firstChunkLogged) { lap('first stream chunk'); firstChunkLogged = true; }
|
||||
|
||||
const eventRunIds = this.normalizeStreamRunIds(streamMsg);
|
||||
if (expectedForegroundRunId === null && eventRunIds.length > 0) {
|
||||
if (streamMsg.type === 'assistant' || streamMsg.type === 'result') {
|
||||
expectedForegroundRunId = eventRunIds[0];
|
||||
expectedForegroundRunSource = streamMsg.type === 'assistant' ? 'assistant' : 'result';
|
||||
log.info(`Selected foreground run for stream delivery (seq=${seq}, key=${convKey}, runId=${expectedForegroundRunId}, source=${streamMsg.type})`);
|
||||
} else {
|
||||
// Do not lock to a run based on pre-assistant non-terminal events;
|
||||
// these can belong to a concurrent background run.
|
||||
filteredRunEventCount++;
|
||||
log.info(`Deferring run-scoped pre-foreground event (seq=${seq}, key=${convKey}, type=${streamMsg.type}, runIds=${eventRunIds.join(',')})`);
|
||||
continue;
|
||||
}
|
||||
} else if (expectedForegroundRunId && eventRunIds.length > 0 && !eventRunIds.includes(expectedForegroundRunId)) {
|
||||
const canSafelySwitchForeground = !sentAnyMessage || messageId !== null;
|
||||
if (streamMsg.type === 'result'
|
||||
&& foregroundRunSwitchCount === 0
|
||||
&& canSafelySwitchForeground) {
|
||||
const previousRunId = expectedForegroundRunId;
|
||||
const previousRunSource = expectedForegroundRunSource;
|
||||
expectedForegroundRunId = eventRunIds[0];
|
||||
expectedForegroundRunSource = 'result';
|
||||
foregroundRunSwitchCount += 1;
|
||||
// Drop any state collected from the previous run so it cannot
|
||||
// flush to user-facing delivery after the switch.
|
||||
response = '';
|
||||
reasoningBuffer = '';
|
||||
streamedAssistantText = '';
|
||||
lastMsgType = null;
|
||||
lastAssistantUuid = null;
|
||||
sawNonAssistantSinceLastUuid = false;
|
||||
log.warn(`Switching foreground run at result boundary (seq=${seq}, key=${convKey}, from=${previousRunId}, to=${expectedForegroundRunId}, prevSource=${previousRunSource || 'unknown'})`);
|
||||
} else {
|
||||
filteredRunEventCount++;
|
||||
log.info(`Skipping non-foreground stream event (seq=${seq}, key=${convKey}, type=${streamMsg.type}, runIds=${eventRunIds.join(',')}, expected=${expectedForegroundRunId})`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
receivedAnyData = true;
|
||||
msgTypeCounts[streamMsg.type] = (msgTypeCounts[streamMsg.type] || 0) + 1;
|
||||
|
||||
@@ -1272,6 +1356,9 @@ export class LettaBot implements AgentSession {
|
||||
log.debug(`Stream result preview: seq=${seq} responsePreview=${response.trim().slice(0, 60)}`);
|
||||
}
|
||||
log.info(`Stream message counts:`, msgTypeCounts);
|
||||
if (filteredRunEventCount > 0) {
|
||||
log.info(`Filtered ${filteredRunEventCount} non-foreground event(s) from stream (seq=${seq}, key=${convKey}, expectedRunId=${expectedForegroundRunId || 'unknown'})`);
|
||||
}
|
||||
if (streamMsg.error) {
|
||||
const detail = resultText.trim();
|
||||
const parts = [`error=${streamMsg.error}`];
|
||||
|
||||
@@ -94,10 +94,11 @@ describe('COMMANDS', () => {
|
||||
expect(COMMANDS).toContain('help');
|
||||
expect(COMMANDS).toContain('start');
|
||||
expect(COMMANDS).toContain('model');
|
||||
expect(COMMANDS).toContain('setconv');
|
||||
});
|
||||
|
||||
it('has exactly 6 commands', () => {
|
||||
expect(COMMANDS).toHaveLength(7);
|
||||
it('has exactly 8 commands', () => {
|
||||
expect(COMMANDS).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Shared command parsing and help text for all channels.
|
||||
*/
|
||||
|
||||
export const COMMANDS = ['status', 'heartbeat', 'reset', 'cancel', 'help', 'start', 'model'] as const;
|
||||
export const COMMANDS = ['status', 'heartbeat', 'reset', 'cancel', 'help', 'start', 'model', 'setconv'] as const;
|
||||
export type Command = typeof COMMANDS[number];
|
||||
|
||||
export interface ParsedCommand {
|
||||
@@ -21,6 +21,7 @@ Commands:
|
||||
/cancel - Abort the current agent run
|
||||
/model - Show current model and list available models
|
||||
/model <handle> - Switch to a different model
|
||||
/setconv <id> - Set conversation ID for this chat
|
||||
/help - Show this message
|
||||
|
||||
Just send a message to get started!`;
|
||||
|
||||
10
src/main.ts
10
src/main.ts
@@ -167,6 +167,7 @@ await refreshTokensIfNeeded();
|
||||
import { normalizeAgents } from './config/types.js';
|
||||
import { LettaGateway } from './core/gateway.js';
|
||||
import { LettaBot } from './core/bot.js';
|
||||
import type { Store } from './core/store.js';
|
||||
import { TelegramAdapter } from './channels/telegram.js';
|
||||
import { TelegramMTProtoAdapter } from './channels/telegram-mtproto.js';
|
||||
import { SlackAdapter } from './channels/slack.js';
|
||||
@@ -569,6 +570,9 @@ async function main() {
|
||||
}
|
||||
|
||||
const gateway = new LettaGateway();
|
||||
const agentStores = new Map<string, Store>();
|
||||
const sessionInvalidators = new Map<string, (key?: string) => void>();
|
||||
const agentChannelMap = new Map<string, string[]>();
|
||||
const voiceMemoEnabled = isVoiceMemoConfigured();
|
||||
const services: {
|
||||
cronServices: CronService[],
|
||||
@@ -775,6 +779,9 @@ async function main() {
|
||||
}
|
||||
|
||||
gateway.addAgent(agentConfig.name, bot);
|
||||
agentStores.set(agentConfig.name, bot.store);
|
||||
sessionInvalidators.set(agentConfig.name, (key) => bot.invalidateSession(key));
|
||||
agentChannelMap.set(agentConfig.name, adapters.map(a => a.id));
|
||||
}
|
||||
|
||||
// Start all agents
|
||||
@@ -793,6 +800,9 @@ async function main() {
|
||||
apiKey: apiKey,
|
||||
host: apiHost,
|
||||
corsOrigin: apiCorsOrigin,
|
||||
stores: agentStores,
|
||||
agentChannels: agentChannelMap,
|
||||
sessionInvalidators,
|
||||
});
|
||||
|
||||
// Startup banner
|
||||
|
||||
Reference in New Issue
Block a user