From 9cb35228fd116150ea3e33965ac8281f29f67ede Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 6 Feb 2026 16:52:33 -0800 Subject: [PATCH 01/21] fix: deduplicate tool_call stream events by toolCallId (#199) The Letta server streams tool_call_message events token-by-token as the model generates tool arguments. A single tool call (e.g. memory_rethink with a large new_memory arg) can produce hundreds of wire events, all sharing the same toolCallId. Without dedup, the bot miscounts these as separate tool calls -- logging "Calling tool: X" hundreds of times and potentially triggering the tool-loop abort at maxToolCalls (default 100) for what is actually a single call. Track seen toolCallIds per stream and skip chunks already yielded. Written by Cameron and Letta Code "In the beginner's mind there are many possibilities, but in the expert's mind there are few." -- Shunryu Suzuki --- src/core/bot.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/core/bot.ts b/src/core/bot.ts index 92d9903..4a095f5 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -477,8 +477,18 @@ export class LettaBot { adapter.sendTypingIndicator(msg.chatId).catch(() => {}); }, 4000); + const seenToolCallIds = new Set(); try { for await (const streamMsg of session.stream()) { + // Deduplicate tool_call chunks: the server streams tool_call_message + // events token-by-token as arguments are generated, so a single tool + // call produces many wire events with the same toolCallId. + // Only count/log the first chunk per unique toolCallId. + if (streamMsg.type === 'tool_call') { + const toolCallId = (streamMsg as any).toolCallId; + if (toolCallId && seenToolCallIds.has(toolCallId)) continue; + if (toolCallId) seenToolCallIds.add(toolCallId); + } const msgUuid = (streamMsg as any).uuid; watchdog.ping(); receivedAnyData = true; From 9a1fd68b7eceb13110dc6127905903b7483251a3 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 7 Feb 2026 11:20:07 -0800 Subject: [PATCH 02/21] fix: add api.port config + Telegram message splitting + error handling hardening (#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two community-reported issues: 1. Port configuration: The API server port was only readable from the PORT env var. Add api.port/host/corsOrigin to lettabot.yaml schema so users can configure it alongside other settings. 2. Telegram sending failures: Messages exceeding Telegram's 4096 char limit would fail with no splitting. Add splitMessageText() that splits at paragraph/line boundaries (~3800 chars to leave room for MarkdownV2 escaping), with a safety net re-split if formatting expands beyond 4096. Also wrap two unguarded adapter.sendMessage() calls in bot.ts error paths that could cascade into unhandled rejections crashing the process. Written by Cameron ◯ Letta Code "When you can't make them see the light, make them feel the heat." - Ronald Reagan --- src/channels/telegram.ts | 141 ++++++++++++++++++++++++++++++++++----- src/config/io.ts | 11 +++ src/config/types.ts | 7 ++ src/core/bot.ts | 26 +++++--- 4 files changed, 159 insertions(+), 26 deletions(-) diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index a54d63e..921ad9b 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -360,24 +360,48 @@ export class TelegramAdapter implements ChannelAdapter { async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> { const { markdownToTelegramV2 } = await import('./telegram-format.js'); - // Try MarkdownV2 first - try { - const formatted = await markdownToTelegramV2(msg.text); - const result = await this.bot.api.sendMessage(msg.chatId, formatted, { - parse_mode: 'MarkdownV2', - reply_to_message_id: msg.replyToMessageId ? Number(msg.replyToMessageId) : undefined, - }); - return { messageId: String(result.message_id) }; - } catch (e) { - // If MarkdownV2 fails, send raw text with notice - console.warn('[Telegram] MarkdownV2 send failed, falling back to raw text:', e); - const errorMsg = e instanceof Error ? e.message : String(e); - const fallbackText = `${msg.text}\n\n(Telegram formatting failed: ${errorMsg.slice(0, 50)}. Report: github.com/letta-ai/lettabot/issues)`; - const result = await this.bot.api.sendMessage(msg.chatId, fallbackText, { - reply_to_message_id: msg.replyToMessageId ? Number(msg.replyToMessageId) : undefined, - }); - return { messageId: String(result.message_id) }; + // Split long messages into chunks (Telegram limit: 4096 chars) + const chunks = splitMessageText(msg.text); + let lastMessageId = ''; + + for (const chunk of chunks) { + // Only first chunk replies to the original message + const replyId = !lastMessageId && msg.replyToMessageId ? Number(msg.replyToMessageId) : undefined; + + // Try MarkdownV2 first + try { + const formatted = await markdownToTelegramV2(chunk); + // MarkdownV2 escaping can expand text beyond 4096 - re-split if needed + if (formatted.length > TELEGRAM_MAX_LENGTH) { + const subChunks = splitFormattedText(formatted); + for (const sub of subChunks) { + const result = await this.bot.api.sendMessage(msg.chatId, sub, { + parse_mode: 'MarkdownV2', + reply_to_message_id: replyId, + }); + lastMessageId = String(result.message_id); + } + } else { + const result = await this.bot.api.sendMessage(msg.chatId, formatted, { + parse_mode: 'MarkdownV2', + reply_to_message_id: replyId, + }); + lastMessageId = String(result.message_id); + } + } catch (e) { + // If MarkdownV2 fails, send raw text (also split if needed) + console.warn('[Telegram] MarkdownV2 send failed, falling back to raw text:', e); + const plainChunks = splitFormattedText(chunk); + for (const plain of plainChunks) { + const result = await this.bot.api.sendMessage(msg.chatId, plain, { + reply_to_message_id: replyId, + }); + lastMessageId = String(result.message_id); + } + } } + + return { messageId: lastMessageId }; } async sendFile(file: OutboundFile): Promise<{ messageId: string }> { @@ -620,3 +644,86 @@ const TELEGRAM_REACTION_EMOJIS = [ type TelegramReactionEmoji = typeof TELEGRAM_REACTION_EMOJIS[number]; const TELEGRAM_REACTION_SET = new Set(TELEGRAM_REACTION_EMOJIS); + +// Telegram message length limit +const TELEGRAM_MAX_LENGTH = 4096; +// Leave room for MarkdownV2 escaping overhead when splitting raw text +const TELEGRAM_SPLIT_THRESHOLD = 3800; + +/** + * Split raw markdown text into chunks that will fit within Telegram's limit + * after MarkdownV2 formatting. Splits at paragraph boundaries (double newlines), + * falling back to single newlines, then hard-splitting at the threshold. + */ +function splitMessageText(text: string): string[] { + if (text.length <= TELEGRAM_SPLIT_THRESHOLD) { + return [text]; + } + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > TELEGRAM_SPLIT_THRESHOLD) { + let splitIdx = -1; + + // Try paragraph boundary (double newline) + const searchRegion = remaining.slice(0, TELEGRAM_SPLIT_THRESHOLD); + const lastParagraph = searchRegion.lastIndexOf('\n\n'); + if (lastParagraph > TELEGRAM_SPLIT_THRESHOLD * 0.3) { + splitIdx = lastParagraph; + } + + // Fall back to single newline + if (splitIdx === -1) { + const lastNewline = searchRegion.lastIndexOf('\n'); + if (lastNewline > TELEGRAM_SPLIT_THRESHOLD * 0.3) { + splitIdx = lastNewline; + } + } + + // Hard split as last resort + if (splitIdx === -1) { + splitIdx = TELEGRAM_SPLIT_THRESHOLD; + } + + chunks.push(remaining.slice(0, splitIdx).trimEnd()); + remaining = remaining.slice(splitIdx).trimStart(); + } + + if (remaining.trim()) { + chunks.push(remaining.trim()); + } + + return chunks; +} + +/** + * Split already-formatted text (MarkdownV2 or plain) at the hard 4096 limit. + * Used as a safety net when formatting expands text beyond the limit. + * Tries to split at newlines to avoid breaking mid-word. + */ +function splitFormattedText(text: string): string[] { + if (text.length <= TELEGRAM_MAX_LENGTH) { + return [text]; + } + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > TELEGRAM_MAX_LENGTH) { + const searchRegion = remaining.slice(0, TELEGRAM_MAX_LENGTH); + let splitIdx = searchRegion.lastIndexOf('\n'); + if (splitIdx < TELEGRAM_MAX_LENGTH * 0.3) { + // No good newline found - hard split + splitIdx = TELEGRAM_MAX_LENGTH; + } + chunks.push(remaining.slice(0, splitIdx)); + remaining = remaining.slice(splitIdx).replace(/^\n/, ''); + } + + if (remaining) { + chunks.push(remaining); + } + + return chunks; +} diff --git a/src/config/io.ts b/src/config/io.ts index 0143f54..9b7d506 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -183,6 +183,17 @@ export function configToEnv(config: LettaBotConfig): Record { if (config.attachments?.maxAgeDays !== undefined) { env.ATTACHMENTS_MAX_AGE_DAYS = String(config.attachments.maxAgeDays); } + + // API server + if (config.api?.port !== undefined) { + env.PORT = String(config.api.port); + } + if (config.api?.host) { + env.API_HOST = config.api.host; + } + if (config.api?.corsOrigin) { + env.API_CORS_ORIGIN = config.api.corsOrigin; + } return env; } diff --git a/src/config/types.ts b/src/config/types.ts index 8d1ca1e..f4c1498 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -59,6 +59,13 @@ export interface LettaBotConfig { maxMB?: number; maxAgeDays?: number; }; + + // API server (health checks, CLI messaging) + api?: { + port?: number; // Default: 8080 (or PORT env var) + host?: string; // Default: 127.0.0.1 (secure). Use '0.0.0.0' for Docker/Railway + corsOrigin?: string; // CORS origin. Default: same-origin only + }; } export interface TranscriptionConfig { diff --git a/src/core/bot.ts b/src/core/bot.ts index 4a095f5..bd4b773 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -644,10 +644,14 @@ export class LettaBot { } catch (sendError) { console.error('[Bot] Error sending response:', sendError); if (!messageId) { - await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId }); - sentAnyMessage = true; - // Reset recovery counter on successful response - this.store.resetRecoveryAttempts(); + try { + await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId }); + sentAnyMessage = true; + // Reset recovery counter on successful response + this.store.resetRecoveryAttempts(); + } catch (retryError) { + console.error('[Bot] Retry send also failed:', retryError); + } } } } @@ -688,11 +692,15 @@ export class LettaBot { } catch (error) { console.error('[Bot] Error processing message:', error); - await adapter.sendMessage({ - chatId: msg.chatId, - text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, - threadId: msg.threadId, - }); + try { + await adapter.sendMessage({ + chatId: msg.chatId, + text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, + threadId: msg.threadId, + }); + } catch (sendError) { + console.error('[Bot] Failed to send error message to channel:', sendError); + } } finally { session!?.close(); } From 2fe5ebe06d6e19ac7d8d04e7c74190d77b8bdd1a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 13:09:05 -0800 Subject: [PATCH 03/21] feat: expose polling configuration in lettabot.yaml (#202) Add a top-level `polling` section to lettabot.yaml for configuring background polling (Gmail, etc.) instead of relying solely on env vars. - Add `PollingYamlConfig` type with `enabled`, `intervalMs`, and `gmail` subsection - Update `configToEnv()` to map new polling config to env vars - Update `main.ts` to read from YAML config with env var fallback - Maintain backward compat with `integrations.google` legacy path - Document polling config in docs/configuration.md Fixes #201 Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com> Co-authored-by: Cameron --- docs/configuration.md | 51 +++++++++++++++++++++++++++++++++++++++++++ src/config/io.ts | 14 +++++++++--- src/config/types.ts | 14 ++++++++++++ src/main.ts | 25 ++++++++++++++------- 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index b9ff769..ef20829 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -66,6 +66,14 @@ features: enabled: true intervalMin: 60 +# Polling (background checks for Gmail, etc.) +polling: + enabled: true + intervalMs: 60000 # Check every 60 seconds + gmail: + enabled: true + account: user@example.com + # Voice transcription transcription: provider: openai @@ -181,6 +189,47 @@ features: Enable scheduled tasks. See [Cron Setup](./cron-setup.md). +## Polling Configuration + +Background polling for integrations like Gmail. Runs independently of agent cron jobs. + +```yaml +polling: + enabled: true # Master switch (default: auto-detected from sub-configs) + intervalMs: 60000 # Check every 60 seconds (default: 60000) + gmail: + enabled: true + account: user@example.com # Gmail account to poll +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `polling.enabled` | boolean | auto | Master switch. Defaults to `true` if any sub-config is enabled | +| `polling.intervalMs` | number | `60000` | Polling interval in milliseconds | +| `polling.gmail.enabled` | boolean | auto | Enable Gmail polling. Auto-detected from `account` | +| `polling.gmail.account` | string | - | Gmail account to poll for unread messages | + +### Legacy config path + +For backward compatibility, Gmail polling can also be configured under `integrations.google`: + +```yaml +integrations: + google: + enabled: true + account: user@example.com + pollIntervalSec: 60 +``` + +The top-level `polling` section takes priority if both are present. + +### Environment variable fallback + +| Env Variable | Polling Config Equivalent | +|--------------|--------------------------| +| `GMAIL_ACCOUNT` | `polling.gmail.account` | +| `POLLING_INTERVAL_MS` | `polling.intervalMs` | + ## Transcription Configuration Voice message transcription via OpenAI Whisper: @@ -223,5 +272,7 @@ Environment variables override config file values: | `WHATSAPP_SELF_CHAT_MODE` | `channels.whatsapp.selfChat` | | `SIGNAL_PHONE_NUMBER` | `channels.signal.phone` | | `OPENAI_API_KEY` | `transcription.apiKey` | +| `GMAIL_ACCOUNT` | `polling.gmail.account` | +| `POLLING_INTERVAL_MS` | `polling.intervalMs` | See [SKILL.md](../SKILL.md) for complete environment variable reference. diff --git a/src/config/io.ts b/src/config/io.ts index 9b7d506..f030b4f 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -169,11 +169,19 @@ export function configToEnv(config: LettaBotConfig): Record { env.MAX_TOOL_CALLS = String(config.features.maxToolCalls); } - // Integrations - Google (Gmail polling) - if (config.integrations?.google?.enabled && config.integrations.google.account) { + // Polling - top-level polling config (preferred) + if (config.polling?.gmail?.enabled && config.polling.gmail.account) { + env.GMAIL_ACCOUNT = config.polling.gmail.account; + } + if (config.polling?.intervalMs) { + env.POLLING_INTERVAL_MS = String(config.polling.intervalMs); + } + + // Integrations - Google (legacy path for Gmail polling, lower priority) + if (!env.GMAIL_ACCOUNT && config.integrations?.google?.enabled && config.integrations.google.account) { env.GMAIL_ACCOUNT = config.integrations.google.account; } - if (config.integrations?.google?.pollIntervalSec) { + if (!env.POLLING_INTERVAL_MS && config.integrations?.google?.pollIntervalSec) { env.POLLING_INTERVAL_MS = String(config.integrations.google.pollIntervalSec * 1000); } diff --git a/src/config/types.ts b/src/config/types.ts index f4c1498..a5a2176 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -46,7 +46,12 @@ export interface LettaBotConfig { maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100) }; + // Polling - system-level background checks (Gmail, etc.) + polling?: PollingYamlConfig; + // Integrations (Google Workspace, etc.) + // NOTE: integrations.google is a legacy path for polling config. + // Prefer the top-level `polling` section instead. integrations?: { google?: GoogleConfig; }; @@ -74,6 +79,15 @@ export interface TranscriptionConfig { model?: string; // Defaults to 'whisper-1' } +export interface PollingYamlConfig { + enabled?: boolean; // Master switch (default: auto-detected from sub-configs) + intervalMs?: number; // Polling interval in milliseconds (default: 60000) + gmail?: { + enabled?: boolean; // Enable Gmail polling + account?: string; // Gmail account to poll (e.g., user@example.com) + }; +} + export interface ProviderConfig { id: string; // e.g., 'anthropic', 'openai' name: string; // e.g., 'lc-anthropic' diff --git a/src/main.ts b/src/main.ts index d34f9cf..33d2075 100644 --- a/src/main.ts +++ b/src/main.ts @@ -287,14 +287,23 @@ const config = { }, // Polling - system-level background checks - polling: { - enabled: !!process.env.GMAIL_ACCOUNT, // Enable if any poller is configured - intervalMs: parseInt(process.env.POLLING_INTERVAL_MS || '60000', 10), // Default 1 minute - gmail: { - enabled: !!process.env.GMAIL_ACCOUNT, - account: process.env.GMAIL_ACCOUNT || '', - }, - }, + // Priority: YAML polling section > YAML integrations.google (legacy) > env vars + polling: (() => { + const gmailAccount = yamlConfig.polling?.gmail?.account + || process.env.GMAIL_ACCOUNT || ''; + const gmailEnabled = yamlConfig.polling?.gmail?.enabled ?? !!gmailAccount; + const intervalMs = yamlConfig.polling?.intervalMs + ?? parseInt(process.env.POLLING_INTERVAL_MS || '60000', 10); + const enabled = yamlConfig.polling?.enabled ?? gmailEnabled; + return { + enabled, + intervalMs, + gmail: { + enabled: gmailEnabled, + account: gmailAccount, + }, + }; + })(), }; // Validate at least one channel is configured From c88621574aebe75976ff66ea5ae1504709dae9e2 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 7 Feb 2026 14:43:01 -0800 Subject: [PATCH 04/21] fix: remove StreamWatchdog that kills long-running agent operations (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watchdog aborted streams after idle timeouts, which breaks legitimate subagent operations. The SDK stream should throw on connection failures. Written by Cameron ◯ Letta Code "The stream will end when it's ready." - a patient engineer --- src/core/bot.ts | 66 +++------- src/core/stream-watchdog.test.ts | 204 ------------------------------- src/core/stream-watchdog.ts | 106 ---------------- 3 files changed, 14 insertions(+), 362 deletions(-) delete mode 100644 src/core/stream-watchdog.test.ts delete mode 100644 src/core/stream-watchdog.ts diff --git a/src/core/bot.ts b/src/core/bot.ts index bd4b773..01d354d 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -14,7 +14,7 @@ import { installSkillsToAgent } from '../skills/loader.js'; import { formatMessageEnvelope, type SessionContextOptions } from './formatter.js'; import { loadMemoryBlocks } from './memory.js'; import { SYSTEM_PROMPT } from './system-prompt.js'; -import { StreamWatchdog } from './stream-watchdog.js'; + /** * Detect if an error is a 409 CONFLICT from an orphaned approval. @@ -426,21 +426,6 @@ export class LettaBot { let receivedAnyData = false; // Track if we got ANY stream data const msgTypeCounts: Record = {}; - // Stream watchdog - abort if idle for too long - const watchdog = new StreamWatchdog({ - onAbort: () => { - session.abort().catch((err) => { - console.error('[Bot] Stream abort failed:', err); - }); - try { - session.close(); - } catch (err) { - console.error('[Bot] Stream close failed:', err); - } - }, - }); - watchdog.start(); - // Helper to finalize and send current accumulated response const finalizeMessage = async () => { // Check for silent marker - agent chose not to reply @@ -490,7 +475,6 @@ export class LettaBot { if (toolCallId) seenToolCallIds.add(toolCallId); } const msgUuid = (streamMsg as any).uuid; - watchdog.ping(); receivedAnyData = true; msgTypeCounts[streamMsg.type] = (msgTypeCounts[streamMsg.type] || 0) + 1; @@ -578,7 +562,6 @@ export class LettaBot { console.log('[Bot] Empty result - attempting orphaned approval recovery...'); session.close(); clearInterval(typingInterval); - watchdog.stop(); const convResult = await recoverOrphanedConversationApproval( this.store.agentId, this.store.conversationId @@ -617,7 +600,6 @@ export class LettaBot { } } finally { - watchdog.stop(); clearInterval(typingInterval); } @@ -819,40 +801,20 @@ export class LettaBot { } let response = ''; - const watchdog = new StreamWatchdog({ - onAbort: () => { - console.warn('[Bot] sendToAgent stream idle timeout, aborting session...'); - session.abort().catch((err) => { - console.error('[Bot] sendToAgent abort failed:', err); - }); - try { - session.close(); - } catch (err) { - console.error('[Bot] sendToAgent close failed:', err); - } - }, - }); - watchdog.start(); - - try { - for await (const msg of session.stream()) { - watchdog.ping(); - if (msg.type === 'assistant') { - response += msg.content; - } - - if (msg.type === 'result') { - if (session.agentId && session.agentId !== this.store.agentId) { - const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; - this.store.setAgent(session.agentId, currentBaseUrl, session.conversationId || undefined); - } else if (session.conversationId && session.conversationId !== this.store.conversationId) { - this.store.conversationId = session.conversationId; - } - break; - } + for await (const msg of session.stream()) { + if (msg.type === 'assistant') { + response += msg.content; + } + + if (msg.type === 'result') { + if (session.agentId && session.agentId !== this.store.agentId) { + const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; + this.store.setAgent(session.agentId, currentBaseUrl, session.conversationId || undefined); + } else if (session.conversationId && session.conversationId !== this.store.conversationId) { + this.store.conversationId = session.conversationId; + } + break; } - } finally { - watchdog.stop(); } return response; diff --git a/src/core/stream-watchdog.test.ts b/src/core/stream-watchdog.test.ts deleted file mode 100644 index a33c8d5..0000000 --- a/src/core/stream-watchdog.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { StreamWatchdog } from './stream-watchdog.js'; - -describe('StreamWatchdog', () => { - beforeEach(() => { - vi.useFakeTimers(); - // Clear env var before each test - delete process.env.LETTA_STREAM_IDLE_TIMEOUT_MS; - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('default behavior', () => { - it('uses 120s default idle timeout', () => { - const onAbort = vi.fn(); - const watchdog = new StreamWatchdog({ onAbort }); - watchdog.start(); - - // Should not abort before 120s - vi.advanceTimersByTime(119000); - expect(onAbort).not.toHaveBeenCalled(); - expect(watchdog.isAborted).toBe(false); - - // Should abort at 120s - vi.advanceTimersByTime(1000); - expect(onAbort).toHaveBeenCalledTimes(1); - expect(watchdog.isAborted).toBe(true); - - watchdog.stop(); - }); - - it('ping() resets the idle timer', () => { - const onAbort = vi.fn(); - const watchdog = new StreamWatchdog({ onAbort }); - watchdog.start(); - - // Advance 100s, then ping - vi.advanceTimersByTime(100000); - watchdog.ping(); - - // Advance another 100s - should not abort (only 100s since ping) - vi.advanceTimersByTime(100000); - expect(onAbort).not.toHaveBeenCalled(); - - // Advance 20 more seconds - now 120s since last ping - vi.advanceTimersByTime(20000); - expect(onAbort).toHaveBeenCalledTimes(1); - - watchdog.stop(); - }); - - it('stop() prevents abort callback', () => { - const onAbort = vi.fn(); - const watchdog = new StreamWatchdog({ onAbort }); - watchdog.start(); - - vi.advanceTimersByTime(25000); - watchdog.stop(); - - // Even after full timeout, should not call abort - vi.advanceTimersByTime(10000); - expect(onAbort).not.toHaveBeenCalled(); - }); - }); - - describe('custom options', () => { - it('respects custom idleTimeoutMs', () => { - const onAbort = vi.fn(); - const watchdog = new StreamWatchdog({ onAbort, idleTimeoutMs: 5000 }); - watchdog.start(); - - vi.advanceTimersByTime(4000); - expect(onAbort).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(1000); - expect(onAbort).toHaveBeenCalledTimes(1); - - watchdog.stop(); - }); - }); - - describe('environment variable override', () => { - it('uses LETTA_STREAM_IDLE_TIMEOUT_MS when set', () => { - process.env.LETTA_STREAM_IDLE_TIMEOUT_MS = '10000'; - - const onAbort = vi.fn(); - const watchdog = new StreamWatchdog({ onAbort }); - watchdog.start(); - - vi.advanceTimersByTime(9000); - expect(onAbort).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(1000); - expect(onAbort).toHaveBeenCalledTimes(1); - - watchdog.stop(); - }); - - it('env var takes precedence over options', () => { - process.env.LETTA_STREAM_IDLE_TIMEOUT_MS = '5000'; - - const onAbort = vi.fn(); - // Option says 60s, but env says 5s - const watchdog = new StreamWatchdog({ onAbort, idleTimeoutMs: 60000 }); - watchdog.start(); - - vi.advanceTimersByTime(5000); - expect(onAbort).toHaveBeenCalledTimes(1); - - watchdog.stop(); - }); - - it('ignores invalid env var values', () => { - process.env.LETTA_STREAM_IDLE_TIMEOUT_MS = 'invalid'; - - const onAbort = vi.fn(); - const watchdog = new StreamWatchdog({ onAbort, idleTimeoutMs: 5000 }); - watchdog.start(); - - // Should use option value (5s) since env is invalid - vi.advanceTimersByTime(5000); - expect(onAbort).toHaveBeenCalledTimes(1); - - watchdog.stop(); - }); - - it('ignores zero env var value', () => { - process.env.LETTA_STREAM_IDLE_TIMEOUT_MS = '0'; - - const onAbort = vi.fn(); - const watchdog = new StreamWatchdog({ onAbort, idleTimeoutMs: 5000 }); - watchdog.start(); - - // Should use option value (5s) since env is 0 - vi.advanceTimersByTime(5000); - expect(onAbort).toHaveBeenCalledTimes(1); - - watchdog.stop(); - }); - }); - - describe('logging', () => { - it('logs waiting message at logIntervalMs when idle', () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - const watchdog = new StreamWatchdog({ logIntervalMs: 1000 }); - watchdog.start(); - - // First interval - 1s idle - vi.advanceTimersByTime(1000); - expect(consoleSpy).toHaveBeenCalledWith( - '[Bot] Stream waiting', - expect.objectContaining({ idleMs: expect.any(Number) }) - ); - - consoleSpy.mockRestore(); - watchdog.stop(); - }); - }); - - describe('edge cases', () => { - it('can be stopped before start', () => { - const watchdog = new StreamWatchdog({}); - expect(() => watchdog.stop()).not.toThrow(); - }); - - it('multiple pings work correctly', () => { - const onAbort = vi.fn(); - const watchdog = new StreamWatchdog({ onAbort, idleTimeoutMs: 1000 }); - watchdog.start(); - - // Rapid pings should keep resetting - for (let i = 0; i < 10; i++) { - vi.advanceTimersByTime(500); - watchdog.ping(); - } - - expect(onAbort).not.toHaveBeenCalled(); - - // Now let it timeout - vi.advanceTimersByTime(1000); - expect(onAbort).toHaveBeenCalledTimes(1); - - watchdog.stop(); - }); - - it('abort callback only fires once', () => { - const onAbort = vi.fn(); - const watchdog = new StreamWatchdog({ onAbort, idleTimeoutMs: 1000 }); - watchdog.start(); - - vi.advanceTimersByTime(1000); - expect(onAbort).toHaveBeenCalledTimes(1); - - // Even if we wait more, should not fire again - vi.advanceTimersByTime(5000); - expect(onAbort).toHaveBeenCalledTimes(1); - - watchdog.stop(); - }); - }); -}); diff --git a/src/core/stream-watchdog.ts b/src/core/stream-watchdog.ts deleted file mode 100644 index 043131d..0000000 --- a/src/core/stream-watchdog.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Stream Watchdog - * - * Monitors streaming responses for idle timeouts and provides - * periodic logging when the stream is waiting. - */ - -export interface StreamWatchdogOptions { - /** Idle timeout in milliseconds. Default: 30000 (30s) */ - idleTimeoutMs?: number; - /** Log interval when idle. Default: 10000 (10s) */ - logIntervalMs?: number; - /** Called when idle timeout triggers abort */ - onAbort?: () => void; -} - -export class StreamWatchdog { - private idleTimer: NodeJS.Timeout | null = null; - private logTimer: NodeJS.Timeout | null = null; - private _aborted = false; - private startTime = 0; - private lastActivity = 0; - - private readonly idleTimeoutMs: number; - private readonly logIntervalMs: number; - private readonly onAbort?: () => void; - - constructor(options: StreamWatchdogOptions = {}) { - // Allow env override, then option, then default - const envTimeout = Number(process.env.LETTA_STREAM_IDLE_TIMEOUT_MS); - this.idleTimeoutMs = Number.isFinite(envTimeout) && envTimeout > 0 - ? envTimeout - : (options.idleTimeoutMs ?? 120000); - this.logIntervalMs = options.logIntervalMs ?? 10000; - this.onAbort = options.onAbort; - } - - /** - * Start watching the stream - */ - start(): void { - this.startTime = Date.now(); - this.lastActivity = this.startTime; - this._aborted = false; - - this.resetIdleTimer(); - - // Periodic logging when idle - this.logTimer = setInterval(() => { - const now = Date.now(); - const idleMs = now - this.lastActivity; - if (idleMs >= this.logIntervalMs) { - console.log('[Bot] Stream waiting', { - elapsedMs: now - this.startTime, - idleMs, - }); - } - }, this.logIntervalMs); - } - - /** - * Call on each stream chunk to reset the idle timer - */ - ping(): void { - this.lastActivity = Date.now(); - this.resetIdleTimer(); - } - - /** - * Stop watching and cleanup all timers - */ - stop(): void { - if (this.idleTimer) { - clearTimeout(this.idleTimer); - this.idleTimer = null; - } - if (this.logTimer) { - clearInterval(this.logTimer); - this.logTimer = null; - } - } - - /** - * Whether the watchdog triggered an abort - */ - get isAborted(): boolean { - return this._aborted; - } - - private resetIdleTimer(): void { - if (this.idleTimer) { - clearTimeout(this.idleTimer); - } - - this.idleTimer = setTimeout(() => { - if (this._aborted) return; - this._aborted = true; - - console.warn(`[Bot] Stream idle timeout after ${this.idleTimeoutMs}ms, aborting...`); - - if (this.onAbort) { - this.onAbort(); - } - }, this.idleTimeoutMs); - } -} From 66e8c462bfe8076dcb8d91c778e7b04c8d5a069e Mon Sep 17 00:00:00 2001 From: Gabriele Sarti Date: Sat, 7 Feb 2026 17:47:22 -0500 Subject: [PATCH 05/21] feat: group message batching + Telegram group gating + instantGroups (#187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add group message batching, Telegram group gating, and instantGroups Group Message Batcher: - New GroupBatcher buffers group chat messages and flushes on timer or @mention - Channel-agnostic: works with any ChannelAdapter - Configurable per-channel via groupPollIntervalMin (default: 10min, 0 = immediate) - formatGroupBatchEnvelope formats batched messages as chat logs for the agent - Single-message batches unwrapped to use DM-style formatMessageEnvelope Telegram Group Gating: - my_chat_member handler: bot leaves groups when added by unpaired users - Groups added by paired users are auto-approved via group-store - Group messages bypass DM pairing (middleware skips group/supergroup chats) - Mention detection for @bot in group messages Channel Group Support: - All adapters: getDmPolicy() interface method - Discord: serverId (guildId), wasMentioned, pairing bypass for guilds - Signal: group messages bypass pairing - Slack: wasMentioned field on messages instantGroups Config: - Per-channel instantGroups config to bypass batching for specific groups - For Discord, checked against both serverId and chatId - YAML config → env vars → parsed in main.ts → Set passed to bot Co-Authored-By: Claude Opus 4.6 * fix: preserve large numeric IDs in instantGroups YAML config Discord snowflake IDs exceed Number.MAX_SAFE_INTEGER, so YAML parses unquoted IDs as lossy JavaScript numbers. Use the document AST to extract the original string representation and avoid precision loss. Co-Authored-By: Claude Opus 4.6 * fix: Slack dmPolicy, Telegram group gating check - Add dmPolicy to SlackConfig and wire through config/env/adapter (was hardcoded to 'open', now reads from config like other adapters) - Check isGroupApproved() in Telegram middleware before processing group messages (approveGroup was called but never checked) Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/channels/discord.ts | 57 ++++++++++------- src/channels/signal.ts | 9 ++- src/channels/slack.ts | 9 ++- src/channels/telegram.ts | 96 ++++++++++++++++++++++++++-- src/channels/types.ts | 1 + src/channels/whatsapp/index.ts | 4 ++ src/config/io.ts | 86 ++++++++++++++++++++++++- src/config/types.ts | 11 ++++ src/core/bot.ts | 54 +++++++++++++++- src/core/formatter.ts | 82 ++++++++++++++++++++++++ src/core/group-batcher.ts | 113 +++++++++++++++++++++++++++++++++ src/core/types.ts | 3 + src/main.ts | 64 +++++++++++++++++++ src/pairing/group-store.ts | 68 ++++++++++++++++++++ 14 files changed, 623 insertions(+), 34 deletions(-) create mode 100644 src/core/group-batcher.ts create mode 100644 src/pairing/group-store.ts diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 1a0e6ff..e6de0ae 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -177,33 +177,36 @@ Ask the bot owner to approve with: } } - const access = await this.checkAccess(userId); - if (access === 'blocked') { - const ch = message.channel; - if (ch.isTextBased() && 'send' in ch) { - await (ch as { send: (content: string) => Promise }).send( - "Sorry, you're not authorized to use this bot." - ); - } - return; - } - - if (access === 'pairing') { - const { code, created } = await upsertPairingRequest('discord', userId, { - username: message.author.username, - }); - - if (!code) { - await message.channel.send('Too many pending pairing requests. Please try again later.'); + // Bypass pairing for guild (group) messages + if (!message.guildId) { + const access = await this.checkAccess(userId); + if (access === 'blocked') { + const ch = message.channel; + if (ch.isTextBased() && 'send' in ch) { + await (ch as { send: (content: string) => Promise }).send( + "Sorry, you're not authorized to use this bot." + ); + } return; } - if (created) { - console.log(`[Discord] New pairing request from ${userId} (${message.author.username}): ${code}`); - } + if (access === 'pairing') { + const { code, created } = await upsertPairingRequest('discord', userId, { + username: message.author.username, + }); - await this.sendPairingMessage(message, this.formatPairingMsg(code)); - return; + if (!code) { + await message.channel.send('Too many pending pairing requests. Please try again later.'); + return; + } + + if (created) { + console.log(`[Discord] New pairing request from ${userId} (${message.author.username}): ${code}`); + } + + await this.sendPairingMessage(message, this.formatPairingMsg(code)); + return; + } } const attachments = await this.collectAttachments(message.attachments, message.channel.id); @@ -237,6 +240,7 @@ Ask the bot owner to approve with: const isGroup = !!message.guildId; const groupName = isGroup && 'name' in message.channel ? message.channel.name : undefined; const displayName = message.member?.displayName || message.author.globalName || message.author.username; + const wasMentioned = isGroup && !!this.client?.user && message.mentions.has(this.client.user); await this.onMessage({ channel: 'discord', @@ -249,6 +253,8 @@ Ask the bot owner to approve with: timestamp: message.createdAt, isGroup, groupName, + serverId: message.guildId || undefined, + wasMentioned, attachments, }); } @@ -318,6 +324,10 @@ Ask the bot owner to approve with: } } + getDmPolicy(): string { + return this.config.dmPolicy || 'pairing'; + } + supportsEditing(): boolean { return true; } @@ -375,6 +385,7 @@ Ask the bot owner to approve with: timestamp: new Date(), isGroup, groupName, + serverId: message.guildId || undefined, reaction: { emoji, messageId: message.id, diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 389885b..a69fecc 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -303,6 +303,10 @@ This code expires in 1 hour.`; }; } + getDmPolicy(): string { + return this.config.dmPolicy || 'pairing'; + } + supportsEditing(): boolean { return false; } @@ -679,8 +683,11 @@ This code expires in 1 hour.`; } // selfChatMode enabled - allow the message through console.log('[Signal] Note to Self allowed (selfChatMode enabled)'); + } else if (chatId.startsWith('group:')) { + // Group messages bypass pairing - anyone in the group can interact + console.log('[Signal] Group message - bypassing access control'); } else { - // External message - check access control + // External DM - check access control console.log('[Signal] Checking access for external message'); const access = await this.checkAccess(source); console.log(`[Signal] Access result: ${access}`); diff --git a/src/channels/slack.ts b/src/channels/slack.ts index bb54840..1c4d41b 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -17,6 +17,7 @@ let App: typeof import('@slack/bolt').App; export interface SlackConfig { botToken: string; // xoxb-... appToken: string; // xapp-... (for Socket Mode) + dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[]; // Slack user IDs (e.g., U01234567) attachmentsDir?: string; attachmentsMaxBytes?: number; @@ -139,11 +140,12 @@ export class SlackAdapter implements ChannelAdapter { threadId: threadTs, isGroup, groupName: isGroup ? channelId : undefined, // Would need conversations.info for name + wasMentioned: false, // Regular messages; app_mention handles mentions attachments, }); } }); - + // Handle app mentions (@bot) this.app.event('app_mention', async ({ event }) => { const userId = event.user || ''; @@ -189,6 +191,7 @@ export class SlackAdapter implements ChannelAdapter { threadId: threadTs, isGroup, groupName: isGroup ? channelId : undefined, + wasMentioned: true, // app_mention is always a mention attachments, }); } @@ -274,6 +277,10 @@ export class SlackAdapter implements ChannelAdapter { }); } + getDmPolicy(): string { + return this.config.dmPolicy || 'pairing'; + } + async sendTypingIndicator(_chatId: string): Promise { // Slack doesn't have a typing indicator API for bots // This is a no-op diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 921ad9b..a676fac 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -14,6 +14,7 @@ import { upsertPairingRequest, formatPairingMessage, } from '../pairing/store.js'; +import { isGroupApproved, approveGroup } from '../pairing/group-store.js'; import { basename } from 'node:path'; import { buildAttachmentPath, downloadToFile } from './attachments.js'; @@ -79,17 +80,68 @@ export class TelegramAdapter implements ChannelAdapter { } private setupHandlers(): void { - // Middleware: Check access based on dmPolicy + // Detect when bot is added/removed from groups (proactive group gating) + this.bot.on('my_chat_member', async (ctx) => { + const chatMember = ctx.myChatMember; + if (!chatMember) return; + + const chatType = chatMember.chat.type; + if (chatType !== 'group' && chatType !== 'supergroup') return; + + const newStatus = chatMember.new_chat_member.status; + if (newStatus !== 'member' && newStatus !== 'administrator') return; + + const chatId = String(chatMember.chat.id); + const fromId = String(chatMember.from.id); + const dmPolicy = this.config.dmPolicy || 'pairing'; + + // No gating when policy is not pairing + if (dmPolicy !== 'pairing') { + await approveGroup('telegram', chatId); + console.log(`[Telegram] Group ${chatId} auto-approved (dmPolicy=${dmPolicy})`); + return; + } + + // Check if the user who added the bot is paired + const configAllowlist = this.config.allowedUsers?.map(String); + const allowed = await isUserAllowed('telegram', fromId, configAllowlist); + + if (allowed) { + await approveGroup('telegram', chatId); + console.log(`[Telegram] Group ${chatId} approved by paired user ${fromId}`); + } else { + console.log(`[Telegram] Unpaired user ${fromId} tried to add bot to group ${chatId}, leaving`); + try { + await ctx.api.sendMessage(chatId, 'This bot can only be added to groups by paired users.'); + await ctx.api.leaveChat(chatId); + } catch (err) { + console.error('[Telegram] Failed to leave group:', err); + } + } + }); + + // Middleware: Check access based on dmPolicy (bypass for groups) this.bot.use(async (ctx, next) => { const userId = ctx.from?.id; if (!userId) return; - + + // Group gating: check if group is approved before processing + const chatType = ctx.chat?.type; + if (chatType === 'group' || chatType === 'supergroup') { + const dmPolicy = this.config.dmPolicy || 'pairing'; + if (dmPolicy === 'open' || await isGroupApproved('telegram', String(ctx.chat!.id))) { + await next(); + } + // Silently drop messages from unapproved groups + return; + } + const access = await this.checkAccess( String(userId), ctx.from?.username, ctx.from?.first_name ); - + if (access === 'allowed') { await next(); return; @@ -158,19 +210,49 @@ export class TelegramAdapter implements ChannelAdapter { const userId = ctx.from?.id; const chatId = ctx.chat.id; const text = ctx.message.text; - + if (!userId) return; if (text.startsWith('/')) return; // Skip other commands - + + // Group detection + const chatType = ctx.chat.type; + const isGroup = chatType === 'group' || chatType === 'supergroup'; + const groupName = isGroup && 'title' in ctx.chat ? ctx.chat.title : undefined; + + // Mention detection for groups + let wasMentioned = false; + if (isGroup) { + const botUsername = this.bot.botInfo?.username; + if (botUsername) { + // Check entities for bot_command or mention matching our username + const entities = ctx.message.entities || []; + wasMentioned = entities.some((e) => { + if (e.type === 'mention') { + const mentioned = text.substring(e.offset, e.offset + e.length); + return mentioned.toLowerCase() === `@${botUsername.toLowerCase()}`; + } + return false; + }); + // Fallback: text-based check + if (!wasMentioned) { + wasMentioned = text.toLowerCase().includes(`@${botUsername.toLowerCase()}`); + } + } + } + if (this.onMessage) { await this.onMessage({ channel: 'telegram', chatId: String(chatId), userId: String(userId), userName: ctx.from.username || ctx.from.first_name, + userHandle: ctx.from.username, messageId: String(ctx.message.message_id), text, timestamp: new Date(), + isGroup, + groupName, + wasMentioned, }); } }); @@ -433,6 +515,10 @@ export class TelegramAdapter implements ChannelAdapter { ]); } + getDmPolicy(): string { + return this.config.dmPolicy || 'pairing'; + } + async sendTypingIndicator(chatId: string): Promise { await this.bot.api.sendChatAction(chatId, 'typing'); } diff --git a/src/channels/types.ts b/src/channels/types.ts index ebcc40e..899a695 100644 --- a/src/channels/types.ts +++ b/src/channels/types.ts @@ -26,6 +26,7 @@ export interface ChannelAdapter { // Capabilities (optional) supportsEditing?(): boolean; sendFile?(file: OutboundFile): Promise<{ messageId: string }>; + getDmPolicy?(): string; // Event handlers (set by bot core) onMessage?: (msg: InboundMessage) => Promise; diff --git a/src/channels/whatsapp/index.ts b/src/channels/whatsapp/index.ts index 470d624..42953fb 100644 --- a/src/channels/whatsapp/index.ts +++ b/src/channels/whatsapp/index.ts @@ -977,6 +977,10 @@ export class WhatsAppAdapter implements ChannelAdapter { ); } + getDmPolicy(): string { + return this.config.dmPolicy || 'pairing'; + } + supportsEditing(): boolean { return false; } diff --git a/src/config/io.ts b/src/config/io.ts index f030b4f..f4f2d1c 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -58,7 +58,12 @@ export function loadConfig(): LettaBotConfig { try { const content = readFileSync(configPath, 'utf-8'); const parsed = YAML.parse(content) as Partial; - + + // Fix instantGroups: YAML parses large numeric IDs (e.g. Discord snowflakes) + // as JavaScript numbers, losing precision for values > Number.MAX_SAFE_INTEGER. + // Re-extract from document AST to preserve the original string representation. + fixInstantGroupIds(content, parsed); + // Merge with defaults return { ...DEFAULT_CONFIG, @@ -133,6 +138,15 @@ export function configToEnv(config: LettaBotConfig): Record { if (config.channels.slack?.botToken) { env.SLACK_BOT_TOKEN = config.channels.slack.botToken; } + if (config.channels.slack?.dmPolicy) { + env.SLACK_DM_POLICY = config.channels.slack.dmPolicy; + } + if (config.channels.slack?.groupPollIntervalMin !== undefined) { + env.SLACK_GROUP_POLL_INTERVAL_MIN = String(config.channels.slack.groupPollIntervalMin); + } + if (config.channels.slack?.instantGroups?.length) { + env.SLACK_INSTANT_GROUPS = config.channels.slack.instantGroups.join(','); + } if (config.channels.whatsapp?.enabled) { env.WHATSAPP_ENABLED = 'true'; if (config.channels.whatsapp.selfChat) { @@ -141,6 +155,12 @@ export function configToEnv(config: LettaBotConfig): Record { env.WHATSAPP_SELF_CHAT_MODE = 'false'; } } + if (config.channels.whatsapp?.groupPollIntervalMin !== undefined) { + env.WHATSAPP_GROUP_POLL_INTERVAL_MIN = String(config.channels.whatsapp.groupPollIntervalMin); + } + if (config.channels.whatsapp?.instantGroups?.length) { + env.WHATSAPP_INSTANT_GROUPS = config.channels.whatsapp.instantGroups.join(','); + } if (config.channels.signal?.phone) { env.SIGNAL_PHONE_NUMBER = config.channels.signal.phone; // Signal selfChat defaults to true, so only set env if explicitly false @@ -148,6 +168,18 @@ export function configToEnv(config: LettaBotConfig): Record { env.SIGNAL_SELF_CHAT_MODE = 'false'; } } + if (config.channels.signal?.groupPollIntervalMin !== undefined) { + env.SIGNAL_GROUP_POLL_INTERVAL_MIN = String(config.channels.signal.groupPollIntervalMin); + } + if (config.channels.signal?.instantGroups?.length) { + env.SIGNAL_INSTANT_GROUPS = config.channels.signal.instantGroups.join(','); + } + if (config.channels.telegram?.groupPollIntervalMin !== undefined) { + env.TELEGRAM_GROUP_POLL_INTERVAL_MIN = String(config.channels.telegram.groupPollIntervalMin); + } + if (config.channels.telegram?.instantGroups?.length) { + env.TELEGRAM_INSTANT_GROUPS = config.channels.telegram.instantGroups.join(','); + } if (config.channels.discord?.token) { env.DISCORD_BOT_TOKEN = config.channels.discord.token; if (config.channels.discord.dmPolicy) { @@ -157,6 +189,12 @@ export function configToEnv(config: LettaBotConfig): Record { env.DISCORD_ALLOWED_USERS = config.channels.discord.allowedUsers.join(','); } } + if (config.channels.discord?.groupPollIntervalMin !== undefined) { + env.DISCORD_GROUP_POLL_INTERVAL_MIN = String(config.channels.discord.groupPollIntervalMin); + } + if (config.channels.discord?.instantGroups?.length) { + env.DISCORD_INSTANT_GROUPS = config.channels.discord.instantGroups.join(','); + } // Features if (config.features?.cron) { @@ -281,3 +319,49 @@ export async function syncProviders(config: LettaBotConfig): Promise { } } } + +/** + * Fix instantGroups arrays that may contain large numeric IDs parsed by YAML. + * Discord snowflake IDs exceed Number.MAX_SAFE_INTEGER, so YAML parses them + * as lossy JavaScript numbers. We re-read from the document AST to get the + * original string representation. + */ +function fixInstantGroupIds(yamlContent: string, parsed: Partial): void { + if (!parsed.channels) return; + + try { + const doc = YAML.parseDocument(yamlContent); + const channels = ['telegram', 'slack', 'whatsapp', 'signal', 'discord'] as const; + + for (const ch of channels) { + const seq = doc.getIn(['channels', ch, 'instantGroups'], true); + if (YAML.isSeq(seq)) { + const fixed = seq.items.map((item: unknown) => { + if (YAML.isScalar(item)) { + // For numbers, use the original source text to avoid precision loss + if (typeof item.value === 'number' && item.source) { + return item.source; + } + return String(item.value); + } + return String(item); + }); + const cfg = parsed.channels[ch]; + if (cfg) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (cfg as any).instantGroups = fixed; + } + } + } + } catch { + // Fallback: just ensure entries are strings (won't fix precision, but safe) + const channels = ['telegram', 'slack', 'whatsapp', 'signal', 'discord'] as const; + for (const ch of channels) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cfg = parsed.channels?.[ch] as any; + if (cfg && Array.isArray(cfg.instantGroups)) { + cfg.instantGroups = cfg.instantGroups.map((v: unknown) => String(v)); + } + } + } +} diff --git a/src/config/types.ts b/src/config/types.ts index a5a2176..0c575ad 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -100,13 +100,18 @@ export interface TelegramConfig { token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[]; + groupPollIntervalMin?: number; // Batch interval in minutes (default: 10, 0 = immediate) + instantGroups?: string[]; // Group chat IDs that bypass batching } export interface SlackConfig { enabled: boolean; appToken?: string; botToken?: string; + dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[]; + groupPollIntervalMin?: number; // Batch interval in minutes (default: 10, 0 = immediate) + instantGroups?: string[]; // Channel IDs that bypass batching } export interface WhatsAppConfig { @@ -118,6 +123,8 @@ export interface WhatsAppConfig { groupAllowFrom?: string[]; mentionPatterns?: string[]; groups?: Record; + groupPollIntervalMin?: number; // Batch interval in minutes (default: 10, 0 = immediate) + instantGroups?: string[]; // Group JIDs that bypass batching } export interface SignalConfig { @@ -129,6 +136,8 @@ export interface SignalConfig { // Group gating mentionPatterns?: string[]; // Regex patterns for mention detection (e.g., ["@bot"]) groups?: Record; // Per-group settings, "*" for defaults + groupPollIntervalMin?: number; // Batch interval in minutes (default: 10, 0 = immediate) + instantGroups?: string[]; // Group IDs that bypass batching } export interface DiscordConfig { @@ -136,6 +145,8 @@ export interface DiscordConfig { token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[]; + groupPollIntervalMin?: number; // Batch interval in minutes (default: 10, 0 = immediate) + instantGroups?: string[]; // Guild/server IDs or channel IDs that bypass batching } export interface GoogleConfig { diff --git a/src/core/bot.ts b/src/core/bot.ts index 01d354d..0a21fc4 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -11,7 +11,8 @@ import type { BotConfig, InboundMessage, TriggerContext } from './types.js'; import { Store } from './store.js'; import { updateAgentName, getPendingApprovals, rejectApproval, cancelRuns, recoverOrphanedConversationApproval } from '../tools/letta-api.js'; import { installSkillsToAgent } from '../skills/loader.js'; -import { formatMessageEnvelope, type SessionContextOptions } from './formatter.js'; +import { formatMessageEnvelope, formatGroupBatchEnvelope, type SessionContextOptions } from './formatter.js'; +import type { GroupBatcher } from './group-batcher.js'; import { loadMemoryBlocks } from './memory.js'; import { SYSTEM_PROMPT } from './system-prompt.js'; @@ -41,6 +42,9 @@ export class LettaBot { // Callback to trigger heartbeat (set by main.ts) public onTriggerHeartbeat?: () => Promise; + private groupBatcher?: GroupBatcher; + private groupIntervals: Map = new Map(); // channel -> intervalMin + private instantGroupIds: Set = new Set(); // channel:id keys for instant processing private processing = false; constructor(config: BotConfig) { @@ -65,6 +69,37 @@ export class LettaBot { console.log(`Registered channel: ${adapter.name}`); } + /** + * Set the group batcher and per-channel intervals. + */ + setGroupBatcher(batcher: GroupBatcher, intervals: Map, instantGroupIds?: Set): void { + this.groupBatcher = batcher; + this.groupIntervals = intervals; + if (instantGroupIds) { + this.instantGroupIds = instantGroupIds; + } + console.log('[Bot] Group batcher configured'); + } + + /** + * Inject a batched group message into the queue and trigger processing. + * Called by GroupBatcher's onFlush callback. + */ + processGroupBatch(msg: InboundMessage, adapter: ChannelAdapter): void { + const count = msg.batchedMessages?.length || 0; + console.log(`[Bot] Group batch: ${count} messages from ${msg.channel}:${msg.chatId}`); + + // Unwrap single-message batches so they use formatMessageEnvelope (DM-style) + // instead of the chat-log batch format + const effective = (count === 1 && msg.batchedMessages) + ? msg.batchedMessages[0] + : msg; + this.messageQueue.push({ msg: effective, adapter }); + if (!this.processing) { + this.processQueue().catch(err => console.error('[Queue] Fatal error in processQueue:', err)); + } + } + /** * Handle slash commands */ @@ -218,7 +253,18 @@ export class LettaBot { */ private async handleMessage(msg: InboundMessage, adapter: ChannelAdapter): Promise { console.log(`[${msg.channel}] Message from ${msg.userId}: ${msg.text}`); - + + // Route group messages to batcher if configured + if (msg.isGroup && this.groupBatcher) { + // Check if this group is configured for instant processing + const isInstant = this.instantGroupIds.has(`${msg.channel}:${msg.chatId}`) + || (msg.serverId && this.instantGroupIds.has(`${msg.channel}:${msg.serverId}`)); + const intervalMin = isInstant ? 0 : (this.groupIntervals.get(msg.channel) ?? 10); + console.log(`[Bot] Group message routed to batcher (interval=${intervalMin}min, mentioned=${msg.wasMentioned}, instant=${!!isInstant})`); + this.groupBatcher.enqueue(msg, adapter, intervalMin); + return; + } + // Add to queue this.messageQueue.push({ msg, adapter }); console.log(`[Queue] Added to queue, length: ${this.messageQueue.length}, processing: ${this.processing}`); @@ -394,7 +440,9 @@ export class LettaBot { } : undefined; // Send message to agent with metadata envelope - const formattedMessage = formatMessageEnvelope(msg, {}, sessionContext); + const formattedMessage = msg.isBatch && msg.batchedMessages + ? formatGroupBatchEnvelope(msg.batchedMessages) + : formatMessageEnvelope(msg); try { await withTimeout(session.send(formattedMessage), 'Session send'); } catch (sendError) { diff --git a/src/core/formatter.ts b/src/core/formatter.ts index 2cdaf45..18851e5 100644 --- a/src/core/formatter.ts +++ b/src/core/formatter.ts @@ -39,6 +39,31 @@ const DEFAULT_OPTIONS: EnvelopeOptions = { includeGroup: true, }; +/** + * Format a short time string (e.g., "4:30 PM") + */ +function formatShortTime(date: Date, options: EnvelopeOptions): string { + let timeZone: string | undefined; + if (options.timezone === 'utc') { + timeZone = 'UTC'; + } else if (options.timezone && options.timezone !== 'local') { + try { + new Intl.DateTimeFormat('en-US', { timeZone: options.timezone }); + timeZone = options.timezone; + } catch { + timeZone = undefined; + } + } + + const formatter = new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + timeZone, + }); + return formatter.format(date); +} + /** * Session context options for first-message enrichment */ @@ -337,3 +362,60 @@ export function formatMessageEnvelope( } return reminder; } + +/** + * Format a group batch of messages as a chat log for the agent. + * + * Output format: + * [GROUP CHAT - discord:123 #general - 3 messages] + * [4:30 PM] Alice: Hey everyone + * [4:32 PM] Bob: What's up? + * [4:35 PM] Alice: @LettaBot can you help? + * (Format: **bold** *italic* ...) + */ +export function formatGroupBatchEnvelope( + messages: InboundMessage[], + options: EnvelopeOptions = {} +): string { + if (messages.length === 0) return ''; + + const opts = { ...DEFAULT_OPTIONS, ...options }; + const first = messages[0]; + + // Header: [GROUP CHAT - channel:chatId #groupName - N messages] + const headerParts: string[] = ['GROUP CHAT']; + headerParts.push(`${first.channel}:${first.chatId}`); + if (first.groupName?.trim()) { + if ((first.channel === 'slack' || first.channel === 'discord') && !first.groupName.startsWith('#')) { + headerParts.push(`#${first.groupName}`); + } else { + headerParts.push(first.groupName); + } + } + headerParts.push(`${messages.length} message${messages.length === 1 ? '' : 's'}`); + const header = `[${headerParts.join(' - ')}]`; + + // Chat log lines + const lines = messages.map((msg) => { + const time = formatShortTime(msg.timestamp, opts); + const sender = formatSender(msg); + const textParts: string[] = []; + if (msg.text?.trim()) textParts.push(msg.text.trim()); + if (msg.reaction) { + const action = msg.reaction.action || 'added'; + textParts.push(`[Reaction ${action}: ${msg.reaction.emoji}]`); + } + if (msg.attachments && msg.attachments.length > 0) { + const names = msg.attachments.map((a) => a.name || 'attachment').join(', '); + textParts.push(`[Attachments: ${names}]`); + } + const body = textParts.join(' ') || '(empty)'; + return `[${time}] ${sender}: ${body}`; + }); + + // Format hint + const formatHint = CHANNEL_FORMATS[first.channel]; + const hint = formatHint ? `\n(Format: ${formatHint})` : ''; + + return `${header}\n${lines.join('\n')}${hint}`; +} diff --git a/src/core/group-batcher.ts b/src/core/group-batcher.ts new file mode 100644 index 0000000..0f9abcd --- /dev/null +++ b/src/core/group-batcher.ts @@ -0,0 +1,113 @@ +/** + * Group Message Batcher + * + * Buffers group chat messages and flushes them periodically or on @mention. + * Channel-agnostic: works with any ChannelAdapter. + */ + +import type { ChannelAdapter } from '../channels/types.js'; +import type { InboundMessage } from './types.js'; + +export interface BufferEntry { + messages: InboundMessage[]; + adapter: ChannelAdapter; + timer: ReturnType | null; +} + +export type OnFlushCallback = (msg: InboundMessage, adapter: ChannelAdapter) => void; + +export class GroupBatcher { + private buffer: Map = new Map(); + private onFlush: OnFlushCallback; + + constructor(onFlush: OnFlushCallback) { + this.onFlush = onFlush; + } + + /** + * Add a group message to the buffer. + * If wasMentioned, flush immediately. + * If intervalMin is 0, flush on every message (no batching). + * Otherwise, start a timer on the first message (does NOT reset on subsequent messages). + */ + enqueue(msg: InboundMessage, adapter: ChannelAdapter, intervalMin: number): void { + const key = `${msg.channel}:${msg.chatId}`; + + let entry = this.buffer.get(key); + if (!entry) { + entry = { messages: [], adapter, timer: null }; + this.buffer.set(key, entry); + } + + entry.messages.push(msg); + entry.adapter = adapter; // Update adapter reference + + // Immediate flush: @mention or intervalMin=0 + if (msg.wasMentioned || intervalMin === 0) { + this.flush(key); + return; + } + + // Start timer on first message only (don't reset to prevent starvation) + if (!entry.timer) { + const ms = intervalMin * 60 * 1000; + entry.timer = setTimeout(() => { + this.flush(key); + }, ms); + } + } + + /** + * Flush buffered messages for a key, building a synthetic batch InboundMessage. + */ + flush(key: string): void { + const entry = this.buffer.get(key); + if (!entry || entry.messages.length === 0) return; + + // Clear timer + if (entry.timer) { + clearTimeout(entry.timer); + entry.timer = null; + } + + const messages = entry.messages; + const adapter = entry.adapter; + + // Remove from buffer + this.buffer.delete(key); + + // Use the last message as the base for the synthetic batch message + const last = messages[messages.length - 1]; + + const batchMsg: InboundMessage = { + channel: last.channel, + chatId: last.chatId, + userId: last.userId, + userName: last.userName, + userHandle: last.userHandle, + messageId: last.messageId, + text: messages.map((m) => m.text).join('\n'), + timestamp: last.timestamp, + isGroup: true, + groupName: last.groupName, + wasMentioned: messages.some((m) => m.wasMentioned), + isBatch: true, + batchedMessages: messages, + }; + + this.onFlush(batchMsg, adapter); + } + + /** + * Clear all timers on shutdown. + */ + stop(): void { + for (const [, entry] of this.buffer) { + if (entry.timer) { + clearTimeout(entry.timer); + entry.timer = null; + } + } + this.buffer.clear(); + } +} diff --git a/src/core/types.ts b/src/core/types.ts index c0733e8..a35d381 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -76,10 +76,13 @@ export interface InboundMessage { threadId?: string; // Slack thread_ts isGroup?: boolean; // Is this from a group chat? groupName?: string; // Group/channel name if applicable + serverId?: string; // Server/guild ID (Discord only) wasMentioned?: boolean; // Was bot explicitly mentioned? (groups only) replyToUser?: string; // Phone number of who they're replying to (if reply) attachments?: InboundAttachment[]; reaction?: InboundReaction; + isBatch?: boolean; // Is this a batched group message? + batchedMessages?: InboundMessage[]; // Original individual messages (for batch formatting) } /** diff --git a/src/main.ts b/src/main.ts index 33d2075..bbd5f97 100644 --- a/src/main.ts +++ b/src/main.ts @@ -119,6 +119,7 @@ import { SlackAdapter } from './channels/slack.js'; import { WhatsAppAdapter } from './channels/whatsapp/index.js'; import { SignalAdapter } from './channels/signal.js'; import { DiscordAdapter } from './channels/discord.js'; +import { GroupBatcher } from './core/group-batcher.js'; import { CronService } from './cron/service.js'; import { HeartbeatService } from './cron/heartbeat.js'; import { PollingService } from './polling/service.js'; @@ -244,12 +245,21 @@ const config = { token: process.env.TELEGRAM_BOT_TOKEN || '', dmPolicy: (process.env.TELEGRAM_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open', allowedUsers: process.env.TELEGRAM_ALLOWED_USERS?.split(',').filter(Boolean).map(Number) || [], + groupPollIntervalMin: process.env.TELEGRAM_GROUP_POLL_INTERVAL_MIN !== undefined + ? parseInt(process.env.TELEGRAM_GROUP_POLL_INTERVAL_MIN, 10) + : 10, + instantGroups: process.env.TELEGRAM_INSTANT_GROUPS?.split(',').filter(Boolean) || [], }, slack: { enabled: !!process.env.SLACK_BOT_TOKEN && !!process.env.SLACK_APP_TOKEN, botToken: process.env.SLACK_BOT_TOKEN || '', appToken: process.env.SLACK_APP_TOKEN || '', + dmPolicy: (process.env.SLACK_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open', allowedUsers: process.env.SLACK_ALLOWED_USERS?.split(',').filter(Boolean) || [], + groupPollIntervalMin: process.env.SLACK_GROUP_POLL_INTERVAL_MIN !== undefined + ? parseInt(process.env.SLACK_GROUP_POLL_INTERVAL_MIN, 10) + : 10, + instantGroups: process.env.SLACK_INSTANT_GROUPS?.split(',').filter(Boolean) || [], }, whatsapp: { enabled: process.env.WHATSAPP_ENABLED === 'true', @@ -257,6 +267,10 @@ const config = { dmPolicy: (process.env.WHATSAPP_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open', allowedUsers: process.env.WHATSAPP_ALLOWED_USERS?.split(',').filter(Boolean) || [], selfChatMode: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false', // Default true (safe - only self-chat) + groupPollIntervalMin: process.env.WHATSAPP_GROUP_POLL_INTERVAL_MIN !== undefined + ? parseInt(process.env.WHATSAPP_GROUP_POLL_INTERVAL_MIN, 10) + : 10, + instantGroups: process.env.WHATSAPP_INSTANT_GROUPS?.split(',').filter(Boolean) || [], }, signal: { enabled: !!process.env.SIGNAL_PHONE_NUMBER, @@ -267,12 +281,20 @@ const config = { dmPolicy: (process.env.SIGNAL_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open', allowedUsers: process.env.SIGNAL_ALLOWED_USERS?.split(',').filter(Boolean) || [], selfChatMode: process.env.SIGNAL_SELF_CHAT_MODE !== 'false', // Default true + groupPollIntervalMin: process.env.SIGNAL_GROUP_POLL_INTERVAL_MIN !== undefined + ? parseInt(process.env.SIGNAL_GROUP_POLL_INTERVAL_MIN, 10) + : 10, + instantGroups: process.env.SIGNAL_INSTANT_GROUPS?.split(',').filter(Boolean) || [], }, discord: { enabled: !!process.env.DISCORD_BOT_TOKEN, token: process.env.DISCORD_BOT_TOKEN || '', dmPolicy: (process.env.DISCORD_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open', allowedUsers: process.env.DISCORD_ALLOWED_USERS?.split(',').filter(Boolean) || [], + groupPollIntervalMin: process.env.DISCORD_GROUP_POLL_INTERVAL_MIN !== undefined + ? parseInt(process.env.DISCORD_GROUP_POLL_INTERVAL_MIN, 10) + : 10, + instantGroups: process.env.DISCORD_INSTANT_GROUPS?.split(',').filter(Boolean) || [], }, // Cron @@ -414,6 +436,7 @@ async function main() { const slack = new SlackAdapter({ botToken: config.slack.botToken, appToken: config.slack.appToken, + dmPolicy: config.slack.dmPolicy, allowedUsers: config.slack.allowedUsers.length > 0 ? config.slack.allowedUsers : undefined, attachmentsDir, attachmentsMaxBytes: config.attachmentsMaxBytes, @@ -467,6 +490,46 @@ async function main() { bot.registerChannel(discord); } + // Create and wire group batcher + const groupIntervals = new Map(); + if (config.telegram.enabled) { + groupIntervals.set('telegram', config.telegram.groupPollIntervalMin ?? 10); + } + if (config.slack.enabled) { + groupIntervals.set('slack', config.slack.groupPollIntervalMin ?? 10); + } + if (config.whatsapp.enabled) { + groupIntervals.set('whatsapp', config.whatsapp.groupPollIntervalMin ?? 10); + } + if (config.signal.enabled) { + groupIntervals.set('signal', config.signal.groupPollIntervalMin ?? 10); + } + if (config.discord.enabled) { + groupIntervals.set('discord', config.discord.groupPollIntervalMin ?? 10); + } + // Build instant group IDs set (channel:id format) + const instantGroupIds = new Set(); + const channelInstantGroups: Array<[string, string[]]> = [ + ['telegram', config.telegram.instantGroups], + ['slack', config.slack.instantGroups], + ['whatsapp', config.whatsapp.instantGroups], + ['signal', config.signal.instantGroups], + ['discord', config.discord.instantGroups], + ]; + for (const [channel, ids] of channelInstantGroups) { + for (const id of ids) { + instantGroupIds.add(`${channel}:${id}`); + } + } + if (instantGroupIds.size > 0) { + console.log(`[Groups] Instant groups: ${[...instantGroupIds].join(', ')}`); + } + + const groupBatcher = new GroupBatcher((msg, adapter) => { + bot.processGroupBatch(msg, adapter); + }); + bot.setGroupBatcher(groupBatcher, groupIntervals, instantGroupIds); + // Start cron service if enabled // Note: CronService uses getDataDir() for cron-jobs.json to match the CLI let cronService: CronService | null = null; @@ -546,6 +609,7 @@ async function main() { // Handle shutdown const shutdown = async () => { console.log('\nShutting down...'); + groupBatcher.stop(); heartbeatService?.stop(); cronService?.stop(); await bot.stop(); diff --git a/src/pairing/group-store.ts b/src/pairing/group-store.ts new file mode 100644 index 0000000..921c377 --- /dev/null +++ b/src/pairing/group-store.ts @@ -0,0 +1,68 @@ +/** + * Approved Groups Store + * + * Tracks which groups have been approved (activated by a paired user). + * Only relevant when dmPolicy === 'pairing'. + * + * Storage: ~/.lettabot/credentials/{channel}-approvedGroups.json + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; + +interface ApprovedGroupsStore { + version: 1; + groups: string[]; +} + +function getCredentialsDir(): string { + return path.join(os.homedir(), '.lettabot', 'credentials'); +} + +function getStorePath(channel: string): string { + return path.join(getCredentialsDir(), `${channel}-approvedGroups.json`); +} + +async function ensureDir(dir: string): Promise { + await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); +} + +async function readJson(filePath: string, fallback: T): Promise { + try { + const raw = await fs.promises.readFile(filePath, 'utf-8'); + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +async function writeJson(filePath: string, data: unknown): Promise { + await ensureDir(path.dirname(filePath)); + const tmp = `${filePath}.${crypto.randomUUID()}.tmp`; + await fs.promises.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf-8' }); + await fs.promises.chmod(tmp, 0o600); + await fs.promises.rename(tmp, filePath); +} + +/** + * Check if a group has been approved for a given channel. + */ +export async function isGroupApproved(channel: string, chatId: string): Promise { + const filePath = getStorePath(channel); + const store = await readJson(filePath, { version: 1, groups: [] }); + return (store.groups || []).includes(chatId); +} + +/** + * Approve a group for a given channel. + */ +export async function approveGroup(channel: string, chatId: string): Promise { + const filePath = getStorePath(channel); + const store = await readJson(filePath, { version: 1, groups: [] }); + const groups = store.groups || []; + if (groups.includes(chatId)) return; + groups.push(chatId); + await writeJson(filePath, { version: 1, groups }); +} From 56e3df17d2577539e016b75c1a578bd3be7d4a36 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 8 Feb 2026 16:48:21 -0800 Subject: [PATCH 06/21] feat: persist voice message audio files to disk (#207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice messages are now saved to the attachments directory regardless of transcription outcome. The audio file path is included in the message envelope so agents always have access to the original audio, even when transcription fails or returns empty. 🐾 Generated with [Letta Code](https://letta.com) Co-authored-by: Letta --- src/channels/signal.ts | 45 ++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/channels/signal.ts b/src/channels/signal.ts index a69fecc..57fadee 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -599,24 +599,49 @@ This code expires in 1 hour.`; const voiceAttachment = attachments?.find(a => a.contentType?.startsWith('audio/')); if (voiceAttachment?.id) { console.log(`[Signal] Voice attachment detected: ${voiceAttachment.contentType}, id: ${voiceAttachment.id}`); + + // Always persist voice audio to attachments directory + let savedAudioPath: string | undefined; + const signalAttDir = join(homedir(), '.local/share/signal-cli/attachments'); + const voiceSourcePath = join(signalAttDir, voiceAttachment.id); + + if (this.config.attachmentsDir) { + const rawExt = voiceAttachment.contentType?.split('/')[1] || 'ogg'; + // Clean extension: "aac" not "aac.aac" (filename may already have extension) + const ext = rawExt.replace(/;.*$/, ''); // strip codec params like "ogg;codecs=opus" + const voiceFileName = `voice-${voiceAttachment.id}.${ext}`; + const voiceTargetPath = buildAttachmentPath(this.config.attachmentsDir, 'signal', chatId, voiceFileName); + try { + const voiceFileReady = await waitForFile(voiceSourcePath, 5000); + if (voiceFileReady) { + await copyFile(voiceSourcePath, voiceTargetPath); + savedAudioPath = voiceTargetPath; + console.log(`[Signal] Voice audio saved to ${voiceTargetPath}`); + } + } catch (err) { + console.warn('[Signal] Failed to save voice audio:', err); + } + } + try { const { loadConfig } = await import('../config/index.js'); const config = loadConfig(); if (!config.transcription?.apiKey && !process.env.OPENAI_API_KEY) { if (chatId) { + const audioInfo = savedAudioPath ? ` Audio saved to: ${savedAudioPath}` : ''; await this.sendMessage({ chatId, - text: 'Voice messages require OpenAI API key for transcription. See: https://github.com/letta-ai/lettabot#voice-messages' + text: `Voice messages require OpenAI API key for transcription.${audioInfo} See: https://github.com/letta-ai/lettabot#voice-messages` }); } } else { // Read attachment from signal-cli attachments directory // Note: signal-cli may still be downloading when SSE event fires, so we wait const { readFileSync } = await import('node:fs'); - const { homedir } = await import('node:os'); - const { join } = await import('node:path'); + const { homedir: hd } = await import('node:os'); + const { join: pjoin } = await import('node:path'); - const attachmentPath = join(homedir(), '.local/share/signal-cli/attachments', voiceAttachment.id); + const attachmentPath = pjoin(hd(), '.local/share/signal-cli/attachments', voiceAttachment.id); console.log(`[Signal] Waiting for attachment: ${attachmentPath}`); // Wait for file to be available (signal-cli may still be downloading) @@ -634,26 +659,26 @@ This code expires in 1 hour.`; const ext = voiceAttachment.contentType?.split('/')[1] || 'ogg'; const result = await transcribeAudio(buffer, `voice.${ext}`, { audioPath: attachmentPath }); + const audioRef = savedAudioPath ? ` (audio: ${savedAudioPath})` : ''; + if (result.success) { if (result.text) { console.log(`[Signal] Transcribed voice message: "${result.text.slice(0, 50)}..."`); messageText = (messageText ? messageText + '\n' : '') + `[Voice message]: ${result.text}`; } else { console.warn(`[Signal] Transcription returned empty text`); - messageText = (messageText ? messageText + '\n' : '') + `[Voice message - transcription returned empty]`; + messageText = (messageText ? messageText + '\n' : '') + `[Voice message - transcription returned empty${audioRef}]`; } } else { const errorMsg = result.error || 'Unknown transcription error'; console.error(`[Signal] Transcription failed: ${errorMsg}`); - const errorInfo = result.audioPath - ? `[Voice message - transcription failed: ${errorMsg}. Audio saved to: ${result.audioPath}]` - : `[Voice message - transcription failed: ${errorMsg}]`; - messageText = (messageText ? messageText + '\n' : '') + errorInfo; + messageText = (messageText ? messageText + '\n' : '') + `[Voice message - transcription failed: ${errorMsg}${audioRef}]`; } } } catch (error) { console.error('[Signal] Error transcribing voice message:', error); - messageText = (messageText ? messageText + '\n' : '') + `[Voice message - error: ${error instanceof Error ? error.message : 'unknown error'}]`; + const audioRef = savedAudioPath ? ` Audio saved to: ${savedAudioPath}` : ''; + messageText = (messageText ? messageText + '\n' : '') + `[Voice message - error: ${error instanceof Error ? error.message : 'unknown error'}.${audioRef}]`; } } else if (attachments?.some(a => a.contentType?.startsWith('audio/'))) { // Audio attachment exists but has no ID From 64f12be6cd622e7f7da76ec772812b236016aac2 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 8 Feb 2026 16:48:32 -0800 Subject: [PATCH 07/21] docs: add api.port config reference + Telegram message splitting note (#206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document api.port/host/corsOrigin in configuration.md (example, reference table, and env var mapping) - Add "Long Messages" section to telegram-setup.md noting the automatic 4096-char splitting behavior Written by Cameron ◯ Letta Code "The best time to plant a tree was 20 years ago. The second best time is now." - Chinese Proverb --- docs/configuration.md | 26 ++++++++++++++++++++++++++ docs/telegram-setup.md | 4 ++++ 2 files changed, 30 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index ef20829..db9902a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -84,6 +84,12 @@ transcription: attachments: maxMB: 20 maxAgeDays: 14 + +# API server (health checks, CLI messaging) +api: + port: 8080 # Default: 8080 (or PORT env var) + # host: 0.0.0.0 # Uncomment for Docker/Railway + # corsOrigin: https://my.app # Uncomment for cross-origin access ``` ## Server Configuration @@ -229,6 +235,9 @@ The top-level `polling` section takes priority if both are present. |--------------|--------------------------| | `GMAIL_ACCOUNT` | `polling.gmail.account` | | `POLLING_INTERVAL_MS` | `polling.intervalMs` | +| `PORT` | `api.port` | +| `API_HOST` | `api.host` | +| `API_CORS_ORIGIN` | `api.corsOrigin` | ## Transcription Configuration @@ -251,6 +260,23 @@ attachments: Attachments are stored in `/tmp/lettabot/attachments/`. +## API Server Configuration + +The built-in API server provides health checks and CLI messaging endpoints. + +```yaml +api: + port: 9090 # Default: 8080 + host: 0.0.0.0 # Default: 127.0.0.1 (localhost only) + corsOrigin: "*" # Default: same-origin only +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `api.port` | number | `8080` | Port for the API/health server | +| `api.host` | string | `127.0.0.1` | Bind address. Use `0.0.0.0` for Docker/Railway | +| `api.corsOrigin` | string | _(none)_ | CORS origin header for cross-origin access | + ## Environment Variables Environment variables override config file values: diff --git a/docs/telegram-setup.md b/docs/telegram-setup.md index d378a44..fa60222 100644 --- a/docs/telegram-setup.md +++ b/docs/telegram-setup.md @@ -137,6 +137,10 @@ The bot can receive and process: Attachments are downloaded to `/tmp/lettabot/attachments/telegram/` and the agent can view images using its Read tool. +### Long Messages + +Telegram limits messages to 4096 characters. LettaBot automatically splits longer responses into multiple messages at paragraph or line boundaries, so no content is lost. + ### Reactions LettaBot can react to messages using the `lettabot-react` CLI: From 110681e9791377d977b3c88421773a09e8781f2f Mon Sep 17 00:00:00 2001 From: Gabriele Sarti Date: Sun, 8 Feb 2026 23:22:32 -0500 Subject: [PATCH 08/21] feat: pass images to the LLM via multimodal API (#184) feat: pass images to the LLM via multimodal API When users send images through any channel, the actual image content is now passed to the LLM via the SDK's multimodal API (imageFromFile/imageFromURL) instead of just text metadata. - Graceful fallback for unsupported MIME types, missing files, and load errors - Opt-out via features.inlineImages: false in config - Warns when model doesn't support vision (detects [Image omitted] in response) --- src/config/io.ts | 5 +++- src/config/types.ts | 1 + src/core/bot.ts | 61 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/config/io.ts b/src/config/io.ts index f4f2d1c..2c13ca4 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -203,10 +203,13 @@ export function configToEnv(config: LettaBotConfig): Record { if (config.features?.heartbeat?.enabled) { env.HEARTBEAT_INTERVAL_MIN = String(config.features.heartbeat.intervalMin || 30); } + if (config.features?.inlineImages === false) { + env.INLINE_IMAGES = 'false'; + } if (config.features?.maxToolCalls !== undefined) { env.MAX_TOOL_CALLS = String(config.features.maxToolCalls); } - + // Polling - top-level polling config (preferred) if (config.polling?.gmail?.enabled && config.polling.gmail.account) { env.GMAIL_ACCOUNT = config.polling.gmail.account; diff --git a/src/config/types.ts b/src/config/types.ts index 0c575ad..e25267f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -43,6 +43,7 @@ export interface LettaBotConfig { enabled: boolean; intervalMin?: number; }; + inlineImages?: boolean; // Send images directly to the LLM (default: true). Set false to only send file paths. maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100) }; diff --git a/src/core/bot.ts b/src/core/bot.ts index 0a21fc4..980dd5b 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -4,7 +4,7 @@ * Single agent, single conversation - chat continues across all channels. */ -import { createAgent, createSession, resumeSession, type Session } from '@letta-ai/letta-code-sdk'; +import { createAgent, createSession, resumeSession, imageFromFile, imageFromURL, type Session, type MessageContentItem, type SendMessage } from '@letta-ai/letta-code-sdk'; import { mkdirSync } from 'node:fs'; import type { ChannelAdapter } from '../channels/types.js'; import type { BotConfig, InboundMessage, TriggerContext } from './types.js'; @@ -33,6 +33,52 @@ function isApprovalConflictError(error: unknown): boolean { return false; } +const SUPPORTED_IMAGE_MIMES = new Set([ + 'image/png', 'image/jpeg', 'image/gif', 'image/webp', +]); + +async function buildMultimodalMessage( + formattedText: string, + msg: InboundMessage, +): Promise { + // Respect opt-out: when INLINE_IMAGES=false, skip multimodal and only send file paths in envelope + if (process.env.INLINE_IMAGES === 'false') { + return formattedText; + } + + const imageAttachments = (msg.attachments ?? []).filter( + (a) => a.kind === 'image' + && (a.localPath || a.url) + && (!a.mimeType || SUPPORTED_IMAGE_MIMES.has(a.mimeType)) + ); + + if (imageAttachments.length === 0) { + return formattedText; + } + + const content: MessageContentItem[] = [ + { type: 'text', text: formattedText }, + ]; + + for (const attachment of imageAttachments) { + try { + if (attachment.localPath) { + content.push(imageFromFile(attachment.localPath)); + } else if (attachment.url) { + content.push(await imageFromURL(attachment.url)); + } + } catch (err) { + console.warn(`[Bot] Failed to load image ${attachment.name || 'unknown'}: ${err instanceof Error ? err.message : err}`); + } + } + + if (content.length > 1) { + console.log(`[Bot] Sending ${content.length - 1} inline image(s) to LLM`); + } + + return content.length > 1 ? content : formattedText; +} + export class LettaBot { private store: Store; private config: BotConfig; @@ -440,11 +486,12 @@ export class LettaBot { } : undefined; // Send message to agent with metadata envelope - const formattedMessage = msg.isBatch && msg.batchedMessages + const formattedText = msg.isBatch && msg.batchedMessages ? formatGroupBatchEnvelope(msg.batchedMessages) - : formatMessageEnvelope(msg); + : formatMessageEnvelope(msg, {}, sessionContext); + const messageToSend = await buildMultimodalMessage(formattedText, msg); try { - await withTimeout(session.send(formattedMessage), 'Session send'); + await withTimeout(session.send(messageToSend), 'Session send'); } catch (sendError) { // Check for 409 CONFLICT from orphaned approval_request_message if (!retried && isApprovalConflictError(sendError) && this.store.agentId && this.store.conversationId) { @@ -658,6 +705,12 @@ export class LettaBot { response = ''; } + // Detect unsupported multimodal: images were sent but server replaced them + const sentImages = Array.isArray(messageToSend); + if (sentImages && response.includes('[Image omitted]')) { + console.warn('[Bot] Model does not support images — server replaced inline images with "[Image omitted]". Consider using a vision-capable model or setting features.inlineImages: false in config.'); + } + // Send final response if (response.trim()) { try { From 3339a880f114a391bf36bb18c999f8c5447dbf03 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 8 Feb 2026 21:39:48 -0800 Subject: [PATCH 09/21] feat: multi-agent config types, normalizeAgents, and Store v2 (Phase 1a) (#215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add multi-agent config types, normalizeAgents, and Store v2 Foundation for multi-agent support (Phase 1a). No behavioral changes. - Add AgentConfig interface for per-agent configuration - Add agents[] field to LettaBotConfig for docker-compose style multi-agent configs - Add normalizeAgents() to convert legacy single-agent config to agents[] array - Evolve Store to v2 format with per-agent state isolation - Auto-migrate v1 store files to v2 transparently - 18 new tests for normalization and store migration Part of #109 Written by Cameron ◯ Letta Code "The only way to do great work is to love what you do." -- Steve Jobs * fix: harden multi-agent normalization and store isolation * style: simplify redundant WhatsApp enabled check WhatsApp has no credential to check (uses QR pairing), so the `enabled !== false && enabled` condition simplifies to just `enabled`. Written by Cameron ◯ Letta Code "Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away." -- Antoine de Saint-Exupery --- src/config/normalize.test.ts | 190 +++++++++++++++++++++++++++++ src/config/types.ts | 98 +++++++++++++++ src/core/store.test.ts | 228 +++++++++++++++++++++++++++++++++++ src/core/store.ts | 137 +++++++++++++++------ 4 files changed, 617 insertions(+), 36 deletions(-) create mode 100644 src/config/normalize.test.ts create mode 100644 src/core/store.test.ts diff --git a/src/config/normalize.test.ts b/src/config/normalize.test.ts new file mode 100644 index 0000000..24c55e1 --- /dev/null +++ b/src/config/normalize.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeAgents, type LettaBotConfig, type AgentConfig } from './types.js'; + +describe('normalizeAgents', () => { + it('should normalize legacy single-agent config to one-entry array', () => { + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { + name: 'TestBot', + model: 'anthropic/claude-sonnet-4', + }, + channels: { + telegram: { + enabled: true, + token: 'test-token', + }, + }, + }; + + const agents = normalizeAgents(config); + + expect(agents).toHaveLength(1); + expect(agents[0].name).toBe('TestBot'); + expect(agents[0].model).toBe('anthropic/claude-sonnet-4'); + expect(agents[0].channels.telegram?.token).toBe('test-token'); + }); + + it('should drop channels with enabled: false', () => { + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: { + telegram: { + enabled: true, + token: 'test-token', + }, + slack: { + enabled: false, + botToken: 'should-be-dropped', + }, + }, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].channels.telegram).toBeDefined(); + expect(agents[0].channels.slack).toBeUndefined(); + }); + + it('should normalize multi-agent config channels', () => { + const agentsArray: AgentConfig[] = [ + { + name: 'Bot1', + channels: { + telegram: { enabled: true, token: 'token1' }, + slack: { enabled: true, botToken: 'missing-app-token' }, + }, + }, + { + name: 'Bot2', + channels: { + slack: { enabled: true, botToken: 'token2', appToken: 'app2' }, + discord: { enabled: false, token: 'disabled' }, + }, + }, + ]; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agents: agentsArray, + // Legacy fields (ignored when agents[] is present) + agent: { name: 'Unused', model: 'unused' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents).toHaveLength(2); + expect(agents[0].channels.telegram?.token).toBe('token1'); + expect(agents[0].channels.slack).toBeUndefined(); + expect(agents[1].channels.slack?.botToken).toBe('token2'); + expect(agents[1].channels.discord).toBeUndefined(); + }); + + it('should produce empty channels object when no channels configured', () => { + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].channels).toEqual({}); + }); + + it('should default agent name to "LettaBot" when not provided', () => { + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: '', model: '' }, // Empty name should fall back to 'LettaBot' + channels: {}, + }; + + // Override with empty name to test default + const agents = normalizeAgents({ + ...config, + agent: undefined as any, // Test fallback when agent is missing + }); + + expect(agents[0].name).toBe('LettaBot'); + }); + + it('should drop channels without required credentials', () => { + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: { + telegram: { + enabled: true, + // Missing token + }, + slack: { + enabled: true, + botToken: 'has-bot-token-only', + // Missing appToken + }, + signal: { + enabled: true, + // Missing phone + }, + discord: { + enabled: true, + // Missing token + }, + }, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].channels).toEqual({}); + }); + + it('should preserve agent id when provided', () => { + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { + id: 'agent-123', + name: 'TestBot', + model: 'test', + }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].id).toBe('agent-123'); + }); + + it('should preserve features, polling, and integrations', () => { + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + features: { + cron: true, + heartbeat: { + enabled: true, + intervalMin: 10, + }, + maxToolCalls: 50, + }, + polling: { + enabled: true, + intervalMs: 30000, + }, + integrations: { + google: { + enabled: true, + account: 'test@example.com', + }, + }, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].features).toEqual(config.features); + expect(agents[0].polling).toEqual(config.polling); + expect(agents[0].integrations).toEqual(config.integrations); + }); +}); diff --git a/src/config/types.ts b/src/config/types.ts index e25267f..3454ad2 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -6,6 +6,42 @@ * 2. Letta Cloud: Uses apiKey, optional BYOK providers */ +/** + * Configuration for a single agent in multi-agent mode. + * Each agent has its own name, channels, and features. + */ +export interface AgentConfig { + /** Agent name (used for display, agent creation, and store keying) */ + name: string; + /** Use existing agent ID (skip creation) */ + id?: string; + /** Model for initial agent creation */ + model?: string; + /** Channels this agent connects to */ + channels: { + telegram?: TelegramConfig; + slack?: SlackConfig; + whatsapp?: WhatsAppConfig; + signal?: SignalConfig; + discord?: DiscordConfig; + }; + /** Features for this agent */ + features?: { + cron?: boolean; + heartbeat?: { + enabled: boolean; + intervalMin?: number; + }; + maxToolCalls?: number; + }; + /** Polling config */ + polling?: PollingYamlConfig; + /** Integrations */ + integrations?: { + google?: GoogleConfig; + }; +} + export interface LettaBotConfig { // Server connection server: { @@ -17,6 +53,9 @@ export interface LettaBotConfig { apiKey?: string; }; + // Multi-agent configuration + agents?: AgentConfig[]; + // Agent configuration agent: { id?: string; @@ -168,3 +207,62 @@ export const DEFAULT_CONFIG: LettaBotConfig = { }, channels: {}, }; + +/** + * Normalize config to multi-agent format. + * + * If the config uses legacy single-agent format (agent: + channels:), + * it's converted to an agents[] array with one entry. + * Channels with `enabled: false` are dropped during normalization. + */ +export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { + const normalizeChannels = (channels?: AgentConfig['channels']): AgentConfig['channels'] => { + const normalized: AgentConfig['channels'] = {}; + if (!channels) return normalized; + + if (channels.telegram?.enabled !== false && channels.telegram?.token) { + normalized.telegram = channels.telegram; + } + if (channels.slack?.enabled !== false && channels.slack?.botToken && channels.slack?.appToken) { + normalized.slack = channels.slack; + } + // WhatsApp has no credential to check (uses QR pairing), so just check enabled + if (channels.whatsapp?.enabled) { + normalized.whatsapp = channels.whatsapp; + } + if (channels.signal?.enabled !== false && channels.signal?.phone) { + normalized.signal = channels.signal; + } + if (channels.discord?.enabled !== false && channels.discord?.token) { + normalized.discord = channels.discord; + } + + return normalized; + }; + + // Multi-agent mode: normalize channels for each configured agent + if (config.agents && config.agents.length > 0) { + return config.agents.map(agent => ({ + ...agent, + channels: normalizeChannels(agent.channels), + })); + } + + // Legacy single-agent mode: normalize to agents[] + const agentName = config.agent?.name || 'LettaBot'; + const model = config.agent?.model; + const id = config.agent?.id; + + // Filter out disabled/misconfigured channels + const channels = normalizeChannels(config.channels); + + return [{ + name: agentName, + id, + model, + channels, + features: config.features, + polling: config.polling, + integrations: config.integrations, + }]; +} diff --git a/src/core/store.test.ts b/src/core/store.test.ts new file mode 100644 index 0000000..3466512 --- /dev/null +++ b/src/core/store.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Store } from './store.js'; +import { existsSync, unlinkSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { AgentStore } from './types.js'; + +describe('Store', () => { + const testDir = join(tmpdir(), 'lettabot-test-' + Date.now() + '-' + Math.random().toString(36).substring(7)); + const testStorePath = join(testDir, 'test-store.json'); + let originalLettaAgentId: string | undefined; + + beforeEach(() => { + // Create test directory + mkdirSync(testDir, { recursive: true }); + + // Clear LETTA_AGENT_ID env var to avoid interference + originalLettaAgentId = process.env.LETTA_AGENT_ID; + delete process.env.LETTA_AGENT_ID; + }); + + afterEach(() => { + // Clean up test files + if (existsSync(testStorePath)) { + unlinkSync(testStorePath); + } + + // Restore LETTA_AGENT_ID env var + if (originalLettaAgentId !== undefined) { + process.env.LETTA_AGENT_ID = originalLettaAgentId; + } + }); + + it('should auto-migrate v1 format to v2', () => { + // Write v1 format store + const v1Data: AgentStore = { + agentId: 'agent-123', + conversationId: 'conv-456', + baseUrl: 'http://localhost:8283', + createdAt: '2026-01-01T00:00:00.000Z', + lastUsedAt: '2026-01-02T00:00:00.000Z', + }; + writeFileSync(testStorePath, JSON.stringify(v1Data, null, 2)); + + // Load store (should trigger migration) + const store = new Store(testStorePath); + + // Verify data is accessible + expect(store.agentId).toBe('agent-123'); + expect(store.conversationId).toBe('conv-456'); + expect(store.baseUrl).toBe('http://localhost:8283'); + + // Verify file was migrated to v2 + const fs = require('node:fs'); + const migrated = JSON.parse(fs.readFileSync(testStorePath, 'utf-8')); + expect(migrated.version).toBe(2); + expect(migrated.agents.LettaBot).toBeDefined(); + expect(migrated.agents.LettaBot.agentId).toBe('agent-123'); + }); + + it('should load v2 format correctly', () => { + // Write v2 format store + const v2Data = { + version: 2, + agents: { + TestBot: { + agentId: 'agent-789', + conversationId: 'conv-abc', + baseUrl: 'http://localhost:8283', + }, + }, + }; + writeFileSync(testStorePath, JSON.stringify(v2Data, null, 2)); + + // Load store with agent name + const store = new Store(testStorePath, 'TestBot'); + + // Verify data is accessible + expect(store.agentId).toBe('agent-789'); + expect(store.conversationId).toBe('conv-abc'); + }); + + it('should isolate per-agent state', () => { + // Create two stores with different agent names + const store1 = new Store(testStorePath, 'Bot1'); + const store2 = new Store(testStorePath, 'Bot2'); + + // Set different data for each + store1.agentId = 'agent-1'; + store1.conversationId = 'conv-1'; + + store2.agentId = 'agent-2'; + store2.conversationId = 'conv-2'; + + // Verify isolation + expect(store1.agentId).toBe('agent-1'); + expect(store2.agentId).toBe('agent-2'); + expect(store1.conversationId).toBe('conv-1'); + expect(store2.conversationId).toBe('conv-2'); + + // Reload and verify persistence + const store1Reloaded = new Store(testStorePath, 'Bot1'); + const store2Reloaded = new Store(testStorePath, 'Bot2'); + + expect(store1Reloaded.agentId).toBe('agent-1'); + expect(store2Reloaded.agentId).toBe('agent-2'); + }); + + it('should maintain backward compatibility with no agent name', () => { + // Create store without agent name (legacy mode) + const store = new Store(testStorePath); + + // Set data + store.agentId = 'legacy-agent'; + store.conversationId = 'legacy-conv'; + + // Verify it works + expect(store.agentId).toBe('legacy-agent'); + expect(store.conversationId).toBe('legacy-conv'); + + // Verify it uses default agent name 'LettaBot' + const fs = require('node:fs'); + const data = JSON.parse(fs.readFileSync(testStorePath, 'utf-8')); + expect(data.agents.LettaBot).toBeDefined(); + expect(data.agents.LettaBot.agentId).toBe('legacy-agent'); + }); + + it('should handle empty store initialization', () => { + const store = new Store(testStorePath, 'NewBot'); + + expect(store.agentId).toBeNull(); + expect(store.conversationId).toBeNull(); + expect(store.recoveryAttempts).toBe(0); + }); + + it('should track recovery attempts per agent', () => { + const store1 = new Store(testStorePath, 'Bot1'); + const store2 = new Store(testStorePath, 'Bot2'); + + // Increment for Bot1 + store1.incrementRecoveryAttempts(); + store1.incrementRecoveryAttempts(); + + // Increment for Bot2 + store2.incrementRecoveryAttempts(); + + // Verify isolation + expect(store1.recoveryAttempts).toBe(2); + expect(store2.recoveryAttempts).toBe(1); + + // Reset Bot1 + store1.resetRecoveryAttempts(); + expect(store1.recoveryAttempts).toBe(0); + expect(store2.recoveryAttempts).toBe(1); + }); + + it('should handle lastMessageTarget per agent', () => { + const store1 = new Store(testStorePath, 'Bot1'); + const store2 = new Store(testStorePath, 'Bot2'); + + const target1 = { + channel: 'telegram' as const, + chatId: 'chat1', + updatedAt: new Date().toISOString(), + }; + + const target2 = { + channel: 'slack' as const, + chatId: 'chat2', + updatedAt: new Date().toISOString(), + }; + + store1.lastMessageTarget = target1; + store2.lastMessageTarget = target2; + + expect(store1.lastMessageTarget?.chatId).toBe('chat1'); + expect(store2.lastMessageTarget?.chatId).toBe('chat2'); + }); + + it('should handle reset() per agent', () => { + const store1 = new Store(testStorePath, 'Bot1'); + const store2 = new Store(testStorePath, 'Bot2'); + + // Set data for both + store1.agentId = 'agent-1'; + store2.agentId = 'agent-2'; + + // Reset only Bot1 + store1.reset(); + + expect(store1.agentId).toBeNull(); + expect(store2.agentId).toBe('agent-2'); + }); + + it('should handle setAgent() per agent', () => { + const store = new Store(testStorePath, 'TestBot'); + + store.setAgent('agent-xyz', 'http://localhost:8283', 'conv-123'); + + expect(store.agentId).toBe('agent-xyz'); + expect(store.baseUrl).toBe('http://localhost:8283'); + expect(store.conversationId).toBe('conv-123'); + + const info = store.getInfo(); + expect(info.agentId).toBe('agent-xyz'); + expect(info.createdAt).toBeDefined(); + expect(info.lastUsedAt).toBeDefined(); + }); + + it('should handle isServerMismatch() per agent', () => { + const store = new Store(testStorePath, 'TestBot'); + + store.setAgent('agent-123', 'http://localhost:8283'); + + expect(store.isServerMismatch('http://localhost:8283')).toBe(false); + expect(store.isServerMismatch('http://localhost:8284')).toBe(true); + expect(store.isServerMismatch('https://api.letta.com')).toBe(true); + }); + + it('should not apply LETTA_AGENT_ID override to non-default agent keys', () => { + process.env.LETTA_AGENT_ID = 'global-agent'; + const defaultStore = new Store(testStorePath, 'LettaBot'); + const namedStore = new Store(testStorePath, 'Bot2'); + + expect(defaultStore.agentId).toBe('global-agent'); + expect(namedStore.agentId).toBeNull(); + }); +}); diff --git a/src/core/store.ts b/src/core/store.ts index 4a38f36..329c197 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -1,7 +1,8 @@ /** - * Agent Store - Persists the single agent ID + * Agent Store - Persists agent state with multi-agent support * - * Since we use dmScope: "main", there's only ONE agent shared across all channels. + * V2 format: { version: 2, agents: { [name]: AgentStore } } + * V1 format (legacy): { agentId: ..., ... } - auto-migrated to V2 */ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; @@ -11,66 +12,127 @@ import { getDataDir } from '../utils/paths.js'; const DEFAULT_STORE_PATH = 'lettabot-agent.json'; +interface StoreV2 { + version: 2; + agents: Record; +} + export class Store { private storePath: string; - private data: AgentStore; + private data: StoreV2; + private agentName: string; - constructor(storePath?: string) { + constructor(storePath?: string, agentName?: string) { this.storePath = resolve(getDataDir(), storePath || DEFAULT_STORE_PATH); + this.agentName = agentName || 'LettaBot'; this.data = this.load(); } - private load(): AgentStore { + private load(): StoreV2 { try { if (existsSync(this.storePath)) { const raw = readFileSync(this.storePath, 'utf-8'); - return JSON.parse(raw) as AgentStore; + const rawData = JSON.parse(raw) as any; + + // V1 -> V2 auto-migration + if (!rawData.version && rawData.agentId !== undefined) { + const migrated: StoreV2 = { + version: 2, + agents: { [this.agentName]: rawData } + }; + // Write back migrated data + this.writeRaw(migrated); + return migrated; + } + + // Already V2 + if (rawData.version === 2) { + return rawData as StoreV2; + } } } catch (e) { console.error('Failed to load agent store:', e); } - return { agentId: null }; + + // Return empty V2 structure + return { version: 2, agents: {} }; } - private save(): void { + private writeRaw(data: StoreV2): void { try { // Ensure directory exists (important for Railway volumes) mkdirSync(dirname(this.storePath), { recursive: true }); - writeFileSync(this.storePath, JSON.stringify(this.data, null, 2)); + writeFileSync(this.storePath, JSON.stringify(data, null, 2)); } catch (e) { console.error('Failed to save agent store:', e); } } + private save(): void { + // Reload file to get latest data from other Store instances + const current = existsSync(this.storePath) + ? (() => { + try { + const raw = readFileSync(this.storePath, 'utf-8'); + const data = JSON.parse(raw); + return data.version === 2 ? data : { version: 2, agents: {} }; + } catch { + return { version: 2, agents: {} }; + } + })() + : { version: 2, agents: {} }; + + // Merge our agent's data + current.agents[this.agentName] = this.data.agents[this.agentName]; + + // Write merged data + this.writeRaw(current); + } + + /** + * Get agent-specific data (creates entry if doesn't exist) + */ + private agentData(): AgentStore { + if (!this.data.agents[this.agentName]) { + this.data.agents[this.agentName] = { agentId: null }; + } + return this.data.agents[this.agentName]; + } + get agentId(): string | null { - // Allow env var override (useful for local server testing with specific agent) - return this.data.agentId || process.env.LETTA_AGENT_ID || null; + // Keep legacy env var override only for default single-agent key. + // In multi-agent mode, a global LETTA_AGENT_ID would leak across agents. + if (this.agentName === 'LettaBot') { + return this.agentData().agentId || process.env.LETTA_AGENT_ID || null; + } + return this.agentData().agentId || null; } set agentId(id: string | null) { - this.data.agentId = id; - this.data.lastUsedAt = new Date().toISOString(); - if (id && !this.data.createdAt) { - this.data.createdAt = new Date().toISOString(); + const agent = this.agentData(); + agent.agentId = id; + agent.lastUsedAt = new Date().toISOString(); + if (id && !agent.createdAt) { + agent.createdAt = new Date().toISOString(); } this.save(); } get conversationId(): string | null { - return this.data.conversationId || null; + return this.agentData().conversationId || null; } set conversationId(id: string | null) { - this.data.conversationId = id; + this.agentData().conversationId = id; this.save(); } get baseUrl(): string | undefined { - return this.data.baseUrl; + return this.agentData().baseUrl; } set baseUrl(url: string | undefined) { - this.data.baseUrl = url; + this.agentData().baseUrl = url; this.save(); } @@ -78,12 +140,13 @@ export class Store { * Set agent ID and associated server URL together */ setAgent(id: string | null, baseUrl?: string, conversationId?: string): void { - this.data.agentId = id; - this.data.baseUrl = baseUrl; - this.data.conversationId = conversationId || this.data.conversationId; - this.data.lastUsedAt = new Date().toISOString(); - if (id && !this.data.createdAt) { - this.data.createdAt = new Date().toISOString(); + const agent = this.agentData(); + agent.agentId = id; + agent.baseUrl = baseUrl; + agent.conversationId = conversationId || agent.conversationId; + agent.lastUsedAt = new Date().toISOString(); + if (id && !agent.createdAt) { + agent.createdAt = new Date().toISOString(); } this.save(); } @@ -92,48 +155,50 @@ export class Store { * Check if stored agent matches current server */ isServerMismatch(currentBaseUrl?: string): boolean { - if (!this.data.agentId || !this.data.baseUrl) return false; + const agent = this.agentData(); + if (!agent.agentId || !agent.baseUrl) return false; // Normalize URLs for comparison - const stored = this.data.baseUrl.replace(/\/$/, ''); + const stored = agent.baseUrl.replace(/\/$/, ''); const current = (currentBaseUrl || 'https://api.letta.com').replace(/\/$/, ''); return stored !== current; } reset(): void { - this.data = { agentId: null }; + this.data.agents[this.agentName] = { agentId: null }; this.save(); } getInfo(): AgentStore { - return { ...this.data }; + return { ...this.agentData() }; } get lastMessageTarget(): LastMessageTarget | null { - return this.data.lastMessageTarget || null; + return this.agentData().lastMessageTarget || null; } set lastMessageTarget(target: LastMessageTarget | null) { - this.data.lastMessageTarget = target || undefined; + this.agentData().lastMessageTarget = target || undefined; this.save(); } // Recovery tracking get recoveryAttempts(): number { - return this.data.recoveryAttempts || 0; + return this.agentData().recoveryAttempts || 0; } incrementRecoveryAttempts(): number { - this.data.recoveryAttempts = (this.data.recoveryAttempts || 0) + 1; - this.data.lastRecoveryAt = new Date().toISOString(); + const agent = this.agentData(); + agent.recoveryAttempts = (agent.recoveryAttempts || 0) + 1; + agent.lastRecoveryAt = new Date().toISOString(); this.save(); - return this.data.recoveryAttempts; + return agent.recoveryAttempts; } resetRecoveryAttempts(): void { - this.data.recoveryAttempts = 0; + this.agentData().recoveryAttempts = 0; this.save(); } } From 2fbd767c50060d38bc08c02b1b80853709e97602 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 8 Feb 2026 21:41:45 -0800 Subject: [PATCH 10/21] feat: add AgentSession interface and LettaGateway orchestrator (Phase 1b) (#216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interface-first multi-agent orchestration layer. - Define AgentSession interface capturing the contract consumers depend on - LettaBot implements AgentSession (already has all methods, now explicit) - LettaGateway manages multiple named AgentSession instances - Update heartbeat, cron, polling, API server to depend on interface, not concrete class - 8 new gateway tests No behavioral changes. Consumers that used LettaBot now use AgentSession interface, enabling multi-agent without modifying consumer code. Part of #109 Written by Cameron ◯ Letta Code "First, solve the problem. Then, write the code." -- John Johnson --- src/api/server.ts | 4 +- src/core/bot.ts | 3 +- src/core/gateway.test.ts | 92 ++++++++++++++++++++++++++++++++++++++++ src/core/gateway.ts | 92 ++++++++++++++++++++++++++++++++++++++++ src/core/index.ts | 2 + src/core/interfaces.ts | 56 ++++++++++++++++++++++++ src/cron/heartbeat.ts | 6 +-- src/cron/service.ts | 6 +-- src/polling/service.ts | 6 +-- 9 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 src/core/gateway.test.ts create mode 100644 src/core/gateway.ts create mode 100644 src/core/interfaces.ts diff --git a/src/api/server.ts b/src/api/server.ts index 443f3dc..d371fe2 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import { validateApiKey } from './auth.js'; import type { SendMessageRequest, SendMessageResponse, SendFileResponse } from './types.js'; import { parseMultipart } from './multipart.js'; -import type { LettaBot } from '../core/bot.js'; +import type { AgentSession } from '../core/interfaces.js'; import type { ChannelId } from '../core/types.js'; const VALID_CHANNELS: ChannelId[] = ['telegram', 'slack', 'discord', 'whatsapp', 'signal']; @@ -26,7 +26,7 @@ interface ServerOptions { /** * Create and start the HTTP API server */ -export function createApiServer(bot: LettaBot, options: ServerOptions): http.Server { +export function createApiServer(bot: AgentSession, options: ServerOptions): http.Server { const server = http.createServer(async (req, res) => { // Set CORS headers (configurable origin, defaults to same-origin for security) const corsOrigin = options.corsOrigin || req.headers.origin || 'null'; diff --git a/src/core/bot.ts b/src/core/bot.ts index 980dd5b..7773616 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -8,6 +8,7 @@ import { createAgent, createSession, resumeSession, imageFromFile, imageFromURL, import { mkdirSync } from 'node:fs'; import type { ChannelAdapter } from '../channels/types.js'; import type { BotConfig, InboundMessage, TriggerContext } from './types.js'; +import type { AgentSession } from './interfaces.js'; import { Store } from './store.js'; import { updateAgentName, getPendingApprovals, rejectApproval, cancelRuns, recoverOrphanedConversationApproval } from '../tools/letta-api.js'; import { installSkillsToAgent } from '../skills/loader.js'; @@ -79,7 +80,7 @@ async function buildMultimodalMessage( return content.length > 1 ? content : formattedText; } -export class LettaBot { +export class LettaBot implements AgentSession { private store: Store; private config: BotConfig; private channels: Map = new Map(); diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts new file mode 100644 index 0000000..c347431 --- /dev/null +++ b/src/core/gateway.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { LettaGateway } from './gateway.js'; +import type { AgentSession } from './interfaces.js'; + +function createMockSession(channels: string[] = ['telegram']): AgentSession { + return { + registerChannel: vi.fn(), + setGroupBatcher: vi.fn(), + processGroupBatch: vi.fn(), + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + sendToAgent: vi.fn().mockResolvedValue('response'), + deliverToChannel: vi.fn().mockResolvedValue('msg-123'), + getStatus: vi.fn().mockReturnValue({ agentId: 'agent-123', channels }), + setAgentId: vi.fn(), + reset: vi.fn(), + getLastMessageTarget: vi.fn().mockReturnValue(null), + getLastUserMessageTime: vi.fn().mockReturnValue(null), + }; +} + +describe('LettaGateway', () => { + let gateway: LettaGateway; + + beforeEach(() => { + gateway = new LettaGateway(); + }); + + it('adds and retrieves agents', () => { + const session = createMockSession(); + gateway.addAgent('test', session); + expect(gateway.getAgent('test')).toBe(session); + expect(gateway.getAgentNames()).toEqual(['test']); + expect(gateway.size).toBe(1); + }); + + it('rejects empty agent names', () => { + expect(() => gateway.addAgent('', createMockSession())).toThrow('empty'); + }); + + it('rejects duplicate agent names', () => { + gateway.addAgent('test', createMockSession()); + expect(() => gateway.addAgent('test', createMockSession())).toThrow('already exists'); + }); + + it('starts all agents', async () => { + const s1 = createMockSession(); + const s2 = createMockSession(); + gateway.addAgent('a', s1); + gateway.addAgent('b', s2); + await gateway.start(); + expect(s1.start).toHaveBeenCalled(); + expect(s2.start).toHaveBeenCalled(); + }); + + it('stops all agents', async () => { + const s1 = createMockSession(); + const s2 = createMockSession(); + gateway.addAgent('a', s1); + gateway.addAgent('b', s2); + await gateway.stop(); + expect(s1.stop).toHaveBeenCalled(); + expect(s2.stop).toHaveBeenCalled(); + }); + + it('routes deliverToChannel to correct agent', async () => { + const s1 = createMockSession(['telegram']); + const s2 = createMockSession(['discord']); + gateway.addAgent('a', s1); + gateway.addAgent('b', s2); + + await gateway.deliverToChannel('discord', 'chat-1', { text: 'hello' }); + expect(s2.deliverToChannel).toHaveBeenCalledWith('discord', 'chat-1', { text: 'hello' }); + expect(s1.deliverToChannel).not.toHaveBeenCalled(); + }); + + it('throws when no agent owns channel', async () => { + gateway.addAgent('a', createMockSession(['telegram'])); + await expect(gateway.deliverToChannel('slack', 'ch-1', { text: 'hi' })).rejects.toThrow('No agent owns channel'); + }); + + it('handles start failures gracefully', async () => { + const good = createMockSession(); + const bad = createMockSession(); + (bad.start as any).mockRejectedValue(new Error('boom')); + gateway.addAgent('good', good); + gateway.addAgent('bad', bad); + // Should not throw -- uses Promise.allSettled + await gateway.start(); + expect(good.start).toHaveBeenCalled(); + }); +}); diff --git a/src/core/gateway.ts b/src/core/gateway.ts new file mode 100644 index 0000000..b7609e0 --- /dev/null +++ b/src/core/gateway.ts @@ -0,0 +1,92 @@ +/** + * LettaGateway - Orchestrates multiple agent sessions. + * + * In multi-agent mode, the gateway manages multiple AgentSession instances, + * each with their own channels, message queue, and state. + * + * See: docs/multi-agent-architecture.md + */ + +import type { AgentSession } from './interfaces.js'; + +export class LettaGateway { + private agents: Map = new Map(); + + /** + * Add a named agent session to the gateway. + * @throws if name is empty or already exists + */ + addAgent(name: string, session: AgentSession): void { + if (!name?.trim()) { + throw new Error('Agent name cannot be empty'); + } + if (this.agents.has(name)) { + throw new Error(`Agent "${name}" already exists`); + } + this.agents.set(name, session); + console.log(`[Gateway] Added agent: ${name}`); + } + + /** Get an agent session by name */ + getAgent(name: string): AgentSession | undefined { + return this.agents.get(name); + } + + /** Get all agent names */ + getAgentNames(): string[] { + return Array.from(this.agents.keys()); + } + + /** Get agent count */ + get size(): number { + return this.agents.size; + } + + /** Start all agents */ + async start(): Promise { + console.log(`[Gateway] Starting ${this.agents.size} agent(s)...`); + const results = await Promise.allSettled( + Array.from(this.agents.entries()).map(async ([name, session]) => { + await session.start(); + console.log(`[Gateway] Started: ${name}`); + }) + ); + const failed = results.filter(r => r.status === 'rejected'); + if (failed.length > 0) { + console.error(`[Gateway] ${failed.length} agent(s) failed to start`); + } + console.log(`[Gateway] ${results.length - failed.length}/${results.length} agents started`); + } + + /** Stop all agents */ + async stop(): Promise { + console.log(`[Gateway] Stopping all agents...`); + for (const [name, session] of this.agents) { + try { + await session.stop(); + console.log(`[Gateway] Stopped: ${name}`); + } catch (e) { + console.error(`[Gateway] Failed to stop ${name}:`, e); + } + } + } + + /** + * Deliver a message to a channel. + * Finds the agent that owns the channel and delegates. + */ + async deliverToChannel( + channelId: string, + chatId: string, + options: { text?: string; filePath?: string; kind?: 'image' | 'file' } + ): Promise { + // Try each agent until one owns the channel + for (const [name, session] of this.agents) { + const status = session.getStatus(); + if (status.channels.includes(channelId)) { + return session.deliverToChannel(channelId, chatId, options); + } + } + throw new Error(`No agent owns channel: ${channelId}`); + } +} diff --git a/src/core/index.ts b/src/core/index.ts index 41d9732..e33ddae 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -5,5 +5,7 @@ export * from './types.js'; export * from './store.js'; export * from './bot.js'; +export * from './interfaces.js'; +export * from './gateway.js'; export * from './formatter.js'; export * from './prompts.js'; diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts new file mode 100644 index 0000000..b0f2cb9 --- /dev/null +++ b/src/core/interfaces.ts @@ -0,0 +1,56 @@ +/** + * AgentSession interface - the contract for agent communication. + * + * Consumers (cron, heartbeat, polling, API server) depend on this interface, + * not the concrete LettaBot class. This enables multi-agent orchestration + * via LettaGateway without changing consumer code. + */ + +import type { ChannelAdapter } from '../channels/types.js'; +import type { InboundMessage, TriggerContext } from './types.js'; +import type { GroupBatcher } from './group-batcher.js'; + +export interface AgentSession { + /** Register a channel adapter */ + registerChannel(adapter: ChannelAdapter): void; + + /** Configure group message batching */ + setGroupBatcher(batcher: GroupBatcher, intervals: Map, instantGroupIds?: Set): void; + + /** Process a batched group message */ + processGroupBatch(msg: InboundMessage, adapter: ChannelAdapter): void; + + /** Start all registered channels */ + start(): Promise; + + /** Stop all channels */ + stop(): Promise; + + /** Send a message to the agent (used by cron, heartbeat, polling) */ + sendToAgent(text: string, context?: TriggerContext): Promise; + + /** Deliver a message/file to a specific channel */ + deliverToChannel(channelId: string, chatId: string, options: { + text?: string; + filePath?: string; + kind?: 'image' | 'file'; + }): Promise; + + /** Get agent status */ + getStatus(): { agentId: string | null; channels: string[] }; + + /** Set agent ID (for container deploys) */ + setAgentId(agentId: string): void; + + /** Reset agent state */ + reset(): void; + + /** Get the last message target (for heartbeat delivery) */ + getLastMessageTarget(): { channel: string; chatId: string } | null; + + /** Get the time of the last user message (for heartbeat skip logic) */ + getLastUserMessageTime(): Date | null; + + /** Callback to trigger heartbeat */ + onTriggerHeartbeat?: () => Promise; +} diff --git a/src/cron/heartbeat.ts b/src/cron/heartbeat.ts index 494405d..b9e6845 100644 --- a/src/cron/heartbeat.ts +++ b/src/cron/heartbeat.ts @@ -9,7 +9,7 @@ import { appendFileSync, mkdirSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; -import type { LettaBot } from '../core/bot.js'; +import type { AgentSession } from '../core/interfaces.js'; import type { TriggerContext } from '../core/types.js'; import { buildHeartbeatPrompt } from '../core/prompts.js'; import { getDataDir } from '../utils/paths.js'; @@ -57,11 +57,11 @@ export interface HeartbeatConfig { * Heartbeat Service */ export class HeartbeatService { - private bot: LettaBot; + private bot: AgentSession; private config: HeartbeatConfig; private intervalId: NodeJS.Timeout | null = null; - constructor(bot: LettaBot, config: HeartbeatConfig) { + constructor(bot: AgentSession, config: HeartbeatConfig) { this.bot = bot; this.config = config; } diff --git a/src/cron/service.ts b/src/cron/service.ts index f9fbb46..c46ea88 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -7,7 +7,7 @@ import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, watch, type FSWatcher } from 'node:fs'; import { resolve, dirname } from 'node:path'; -import type { LettaBot } from '../core/bot.js'; +import type { AgentSession } from '../core/interfaces.js'; import type { CronJob, CronJobCreate, CronSchedule, CronConfig, HeartbeatConfig } from './types.js'; import { DEFAULT_HEARTBEAT_MESSAGES } from './types.js'; import { getDataDir } from '../utils/paths.js'; @@ -49,7 +49,7 @@ const DEFAULT_HEARTBEAT: HeartbeatConfig = { export class CronService { private jobs: Map = new Map(); private scheduledJobs: Map = new Map(); - private bot: LettaBot; + private bot: AgentSession; private storePath: string; private config: CronConfig; private started = false; @@ -57,7 +57,7 @@ export class CronService { private fileWatcher: FSWatcher | null = null; private lastFileContent: string = ''; - constructor(bot: LettaBot, config?: CronConfig) { + constructor(bot: AgentSession, config?: CronConfig) { this.bot = bot; this.config = config || {}; this.storePath = config?.storePath diff --git a/src/polling/service.ts b/src/polling/service.ts index 2326dab..7dd8c37 100644 --- a/src/polling/service.ts +++ b/src/polling/service.ts @@ -8,7 +8,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { LettaBot } from '../core/bot.js'; +import type { AgentSession } from '../core/interfaces.js'; export interface PollingConfig { intervalMs: number; // Polling interval in milliseconds @@ -21,14 +21,14 @@ export interface PollingConfig { export class PollingService { private intervalId: ReturnType | null = null; - private bot: LettaBot; + private bot: AgentSession; private config: PollingConfig; // Track seen email IDs to detect new emails (persisted to disk) private seenEmailIds: Set = new Set(); private seenEmailsPath: string; - constructor(bot: LettaBot, config: PollingConfig) { + constructor(bot: AgentSession, config: PollingConfig) { this.bot = bot; this.config = config; this.seenEmailsPath = join(config.workingDir, 'seen-emails.json'); From 2f5242decdd93070c8e9fb5a49b35b2455c59397 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 8 Feb 2026 21:42:42 -0800 Subject: [PATCH 11/21] feat: wire up multi-agent in main.ts (Phase 1c) (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-agent configs now work end-to-end. Users can write `agents:` in lettabot.yaml and each agent gets its own channels, cron, heartbeat, and polling services. - Rewrite main() to loop over normalizeAgents() output - Extract createChannelsForAgent() and createGroupBatcher() helpers - Replace env-var config parsing with YAML-direct for per-agent config - LettaGateway manages all agents, API server uses gateway for delivery - Per-agent services: cron, heartbeat, polling are agent-scoped - Store v2 format handled at startup for LETTA_AGENT_ID loading - Legacy single-agent configs work unchanged via normalizeAgents() Part of #109 Written by Cameron ◯ Letta Code "Make it work, make it right, make it fast." -- Kent Beck --- src/api/server.ts | 8 +- src/core/gateway.ts | 4 +- src/core/interfaces.ts | 12 + src/main.ts | 669 ++++++++++++++++++++--------------------- 4 files changed, 351 insertions(+), 342 deletions(-) diff --git a/src/api/server.ts b/src/api/server.ts index d371fe2..e0855f7 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import { validateApiKey } from './auth.js'; import type { SendMessageRequest, SendMessageResponse, SendFileResponse } from './types.js'; import { parseMultipart } from './multipart.js'; -import type { AgentSession } from '../core/interfaces.js'; +import type { MessageDeliverer } from '../core/interfaces.js'; import type { ChannelId } from '../core/types.js'; const VALID_CHANNELS: ChannelId[] = ['telegram', 'slack', 'discord', 'whatsapp', 'signal']; @@ -26,7 +26,7 @@ interface ServerOptions { /** * Create and start the HTTP API server */ -export function createApiServer(bot: AgentSession, options: ServerOptions): http.Server { +export function createApiServer(deliverer: MessageDeliverer, options: ServerOptions): http.Server { const server = http.createServer(async (req, res) => { // Set CORS headers (configurable origin, defaults to same-origin for security) const corsOrigin = options.corsOrigin || req.headers.origin || 'null'; @@ -87,8 +87,8 @@ export function createApiServer(bot: AgentSession, options: ServerOptions): http const file = files.length > 0 ? files[0] : undefined; - // Send via unified bot method - const messageId = await bot.deliverToChannel( + // Send via unified deliverer method + const messageId = await deliverer.deliverToChannel( fields.channel as ChannelId, fields.chatId, { diff --git a/src/core/gateway.ts b/src/core/gateway.ts index b7609e0..802700c 100644 --- a/src/core/gateway.ts +++ b/src/core/gateway.ts @@ -7,9 +7,9 @@ * See: docs/multi-agent-architecture.md */ -import type { AgentSession } from './interfaces.js'; +import type { AgentSession, MessageDeliverer } from './interfaces.js'; -export class LettaGateway { +export class LettaGateway implements MessageDeliverer { private agents: Map = new Map(); /** diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index b0f2cb9..b2ce883 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -54,3 +54,15 @@ export interface AgentSession { /** Callback to trigger heartbeat */ onTriggerHeartbeat?: () => Promise; } + +/** + * Minimal interface for message delivery. + * Satisfied by both AgentSession and LettaGateway. + */ +export interface MessageDeliverer { + deliverToChannel(channelId: string, chatId: string, options: { + text?: string; + filePath?: string; + kind?: 'image' | 'file'; + }): Promise; +} diff --git a/src/main.ts b/src/main.ts index bbd5f97..39fb08e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,7 +20,11 @@ import { getDataDir, getWorkingDir, hasRailwayVolume } from './utils/paths.js'; const yamlConfig = loadConfig(); const configSource = existsSync(resolveConfigPath()) ? resolveConfigPath() : 'defaults + environment variables'; console.log(`[Config] Loaded from ${configSource}`); -console.log(`[Config] Mode: ${yamlConfig.server.mode}, Agent: ${yamlConfig.agent.name}, Model: ${yamlConfig.agent.model}`); +if (yamlConfig.agents?.length) { + console.log(`[Config] Mode: ${yamlConfig.server.mode}, Agents: ${yamlConfig.agents.map(a => a.name).join(', ')}`); +} else { + console.log(`[Config] Mode: ${yamlConfig.server.mode}, Agent: ${yamlConfig.agent.name}, Model: ${yamlConfig.agent.model}`); +} applyConfigToEnv(yamlConfig); // Sync BYOK providers on startup (async, don't block) @@ -33,24 +37,43 @@ const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; if (existsSync(STORE_PATH)) { try { - const store = JSON.parse(readFileSync(STORE_PATH, 'utf-8')); + const raw = JSON.parse(readFileSync(STORE_PATH, 'utf-8')); - // Check for server mismatch - if (store.agentId && store.baseUrl) { - const storedUrl = store.baseUrl.replace(/\/$/, ''); - const currentUrl = currentBaseUrl.replace(/\/$/, ''); - - if (storedUrl !== currentUrl) { - console.warn(`\n⚠️ Server mismatch detected!`); - console.warn(` Stored agent was created on: ${storedUrl}`); - console.warn(` Current server: ${currentUrl}`); - console.warn(` The agent ${store.agentId} may not exist on this server.`); - console.warn(` Run 'lettabot onboard' to select or create an agent for this server.\n`); + // V2 format: get first agent's ID + if (raw.version === 2 && raw.agents) { + const firstAgent = Object.values(raw.agents)[0] as any; + if (firstAgent?.agentId) { + process.env.LETTA_AGENT_ID = firstAgent.agentId; + } + // Check server mismatch on first agent + if (firstAgent?.agentId && firstAgent?.baseUrl) { + const storedUrl = firstAgent.baseUrl.replace(/\/$/, ''); + const currentUrl = currentBaseUrl.replace(/\/$/, ''); + + if (storedUrl !== currentUrl) { + console.warn(`\n⚠️ Server mismatch detected!`); + console.warn(` Stored agent was created on: ${storedUrl}`); + console.warn(` Current server: ${currentUrl}`); + console.warn(` The agent ${firstAgent.agentId} may not exist on this server.`); + console.warn(` Run 'lettabot onboard' to select or create an agent for this server.\n`); + } + } + } else if (raw.agentId) { + // V1 format (legacy) + process.env.LETTA_AGENT_ID = raw.agentId; + // Check server mismatch + if (raw.agentId && raw.baseUrl) { + const storedUrl = raw.baseUrl.replace(/\/$/, ''); + const currentUrl = currentBaseUrl.replace(/\/$/, ''); + + if (storedUrl !== currentUrl) { + console.warn(`\n⚠️ Server mismatch detected!`); + console.warn(` Stored agent was created on: ${storedUrl}`); + console.warn(` Current server: ${currentUrl}`); + console.warn(` The agent ${raw.agentId} may not exist on this server.`); + console.warn(` Run 'lettabot onboard' to select or create an agent for this server.\n`); + } } - } - - if (store.agentId) { - process.env.LETTA_AGENT_ID = store.agentId; } } catch {} } @@ -113,6 +136,8 @@ async function refreshTokensIfNeeded(): Promise { // Run token refresh before importing SDK (which reads LETTA_API_KEY) await refreshTokensIfNeeded(); +import { normalizeAgents } from './config/types.js'; +import { LettaGateway } from './core/gateway.js'; import { LettaBot } from './core/bot.js'; import { TelegramAdapter } from './channels/telegram.js'; import { SlackAdapter } from './channels/slack.js'; @@ -229,112 +254,159 @@ async function pruneAttachmentsDir(baseDir: string, maxAgeDays: number): Promise } } +/** + * Create channel adapters for an agent from its config + */ +function createChannelsForAgent( + agentConfig: import('./config/types.js').AgentConfig, + attachmentsDir: string, + attachmentsMaxBytes: number, +): import('./channels/types.js').ChannelAdapter[] { + const adapters: import('./channels/types.js').ChannelAdapter[] = []; + + if (agentConfig.channels.telegram?.token) { + adapters.push(new TelegramAdapter({ + token: agentConfig.channels.telegram.token, + dmPolicy: agentConfig.channels.telegram.dmPolicy || 'pairing', + allowedUsers: agentConfig.channels.telegram.allowedUsers && agentConfig.channels.telegram.allowedUsers.length > 0 + ? agentConfig.channels.telegram.allowedUsers.map(u => typeof u === 'string' ? parseInt(u, 10) : u) + : undefined, + attachmentsDir, + attachmentsMaxBytes, + })); + } + + if (agentConfig.channels.slack?.botToken && agentConfig.channels.slack?.appToken) { + adapters.push(new SlackAdapter({ + botToken: agentConfig.channels.slack.botToken, + appToken: agentConfig.channels.slack.appToken, + dmPolicy: agentConfig.channels.slack.dmPolicy || 'pairing', + allowedUsers: agentConfig.channels.slack.allowedUsers && agentConfig.channels.slack.allowedUsers.length > 0 + ? agentConfig.channels.slack.allowedUsers + : undefined, + attachmentsDir, + attachmentsMaxBytes, + })); + } + + if (agentConfig.channels.whatsapp?.enabled) { + const selfChatMode = agentConfig.channels.whatsapp.selfChat ?? true; + if (!selfChatMode) { + console.warn('[WhatsApp] WARNING: selfChatMode is OFF - bot will respond to ALL incoming messages!'); + console.warn('[WhatsApp] Only use this if this is a dedicated bot number, not your personal WhatsApp.'); + } + adapters.push(new WhatsAppAdapter({ + sessionPath: process.env.WHATSAPP_SESSION_PATH || './data/whatsapp-session', + dmPolicy: agentConfig.channels.whatsapp.dmPolicy || 'pairing', + allowedUsers: agentConfig.channels.whatsapp.allowedUsers && agentConfig.channels.whatsapp.allowedUsers.length > 0 + ? agentConfig.channels.whatsapp.allowedUsers + : undefined, + selfChatMode, + attachmentsDir, + attachmentsMaxBytes, + })); + } + + if (agentConfig.channels.signal?.phone) { + const selfChatMode = agentConfig.channels.signal.selfChat ?? true; + if (!selfChatMode) { + console.warn('[Signal] WARNING: selfChatMode is OFF - bot will respond to ALL incoming messages!'); + console.warn('[Signal] Only use this if this is a dedicated bot number, not your personal Signal.'); + } + adapters.push(new SignalAdapter({ + phoneNumber: agentConfig.channels.signal.phone, + cliPath: process.env.SIGNAL_CLI_PATH || 'signal-cli', + httpHost: process.env.SIGNAL_HTTP_HOST || '127.0.0.1', + httpPort: parseInt(process.env.SIGNAL_HTTP_PORT || '8090', 10), + dmPolicy: agentConfig.channels.signal.dmPolicy || 'pairing', + allowedUsers: agentConfig.channels.signal.allowedUsers && agentConfig.channels.signal.allowedUsers.length > 0 + ? agentConfig.channels.signal.allowedUsers + : undefined, + selfChatMode, + attachmentsDir, + attachmentsMaxBytes, + })); + } + + if (agentConfig.channels.discord?.token) { + adapters.push(new DiscordAdapter({ + token: agentConfig.channels.discord.token, + dmPolicy: agentConfig.channels.discord.dmPolicy || 'pairing', + allowedUsers: agentConfig.channels.discord.allowedUsers && agentConfig.channels.discord.allowedUsers.length > 0 + ? agentConfig.channels.discord.allowedUsers + : undefined, + attachmentsDir, + attachmentsMaxBytes, + })); + } + + return adapters; +} + +/** + * Create and configure a group batcher for an agent + */ +function createGroupBatcher( + agentConfig: import('./config/types.js').AgentConfig, + bot: import('./core/interfaces.js').AgentSession, +): { batcher: GroupBatcher | null; intervals: Map; instantIds: Set } { + const intervals = new Map(); + const instantIds = new Set(); + + // Collect intervals from channel configs + if (agentConfig.channels.telegram) { + intervals.set('telegram', agentConfig.channels.telegram.groupPollIntervalMin ?? 10); + for (const id of agentConfig.channels.telegram.instantGroups || []) { + instantIds.add(`telegram:${id}`); + } + } + if (agentConfig.channels.slack) { + intervals.set('slack', agentConfig.channels.slack.groupPollIntervalMin ?? 10); + for (const id of agentConfig.channels.slack.instantGroups || []) { + instantIds.add(`slack:${id}`); + } + } + if (agentConfig.channels.whatsapp) { + intervals.set('whatsapp', agentConfig.channels.whatsapp.groupPollIntervalMin ?? 10); + for (const id of agentConfig.channels.whatsapp.instantGroups || []) { + instantIds.add(`whatsapp:${id}`); + } + } + if (agentConfig.channels.signal) { + intervals.set('signal', agentConfig.channels.signal.groupPollIntervalMin ?? 10); + for (const id of agentConfig.channels.signal.instantGroups || []) { + instantIds.add(`signal:${id}`); + } + } + if (agentConfig.channels.discord) { + intervals.set('discord', agentConfig.channels.discord.groupPollIntervalMin ?? 10); + for (const id of agentConfig.channels.discord.instantGroups || []) { + instantIds.add(`discord:${id}`); + } + } + + if (instantIds.size > 0) { + console.log(`[Groups] Instant groups: ${[...instantIds].join(', ')}`); + } + + const batcher = intervals.size > 0 ? new GroupBatcher((msg, adapter) => { + bot.processGroupBatch(msg, adapter); + }) : null; + + return { batcher, intervals, instantIds }; +} + // Skills are installed to agent-scoped directory when agent is created (see core/bot.ts) -// Configuration from environment -const config = { +// Global config (shared across all agents) +const globalConfig = { workingDir: getWorkingDir(), - model: process.env.MODEL, // e.g., 'claude-sonnet-4-20250514' allowedTools: (process.env.ALLOWED_TOOLS || 'Bash,Read,Edit,Write,Glob,Grep,Task,web_search,conversation_search').split(','), attachmentsMaxBytes: resolveAttachmentsMaxBytes(), attachmentsMaxAgeDays: resolveAttachmentsMaxAgeDays(), - - // Channel configs - telegram: { - enabled: !!process.env.TELEGRAM_BOT_TOKEN, - token: process.env.TELEGRAM_BOT_TOKEN || '', - dmPolicy: (process.env.TELEGRAM_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open', - allowedUsers: process.env.TELEGRAM_ALLOWED_USERS?.split(',').filter(Boolean).map(Number) || [], - groupPollIntervalMin: process.env.TELEGRAM_GROUP_POLL_INTERVAL_MIN !== undefined - ? parseInt(process.env.TELEGRAM_GROUP_POLL_INTERVAL_MIN, 10) - : 10, - instantGroups: process.env.TELEGRAM_INSTANT_GROUPS?.split(',').filter(Boolean) || [], - }, - slack: { - enabled: !!process.env.SLACK_BOT_TOKEN && !!process.env.SLACK_APP_TOKEN, - botToken: process.env.SLACK_BOT_TOKEN || '', - appToken: process.env.SLACK_APP_TOKEN || '', - dmPolicy: (process.env.SLACK_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open', - allowedUsers: process.env.SLACK_ALLOWED_USERS?.split(',').filter(Boolean) || [], - groupPollIntervalMin: process.env.SLACK_GROUP_POLL_INTERVAL_MIN !== undefined - ? parseInt(process.env.SLACK_GROUP_POLL_INTERVAL_MIN, 10) - : 10, - instantGroups: process.env.SLACK_INSTANT_GROUPS?.split(',').filter(Boolean) || [], - }, - whatsapp: { - enabled: process.env.WHATSAPP_ENABLED === 'true', - sessionPath: process.env.WHATSAPP_SESSION_PATH || './data/whatsapp-session', - dmPolicy: (process.env.WHATSAPP_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open', - allowedUsers: process.env.WHATSAPP_ALLOWED_USERS?.split(',').filter(Boolean) || [], - selfChatMode: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false', // Default true (safe - only self-chat) - groupPollIntervalMin: process.env.WHATSAPP_GROUP_POLL_INTERVAL_MIN !== undefined - ? parseInt(process.env.WHATSAPP_GROUP_POLL_INTERVAL_MIN, 10) - : 10, - instantGroups: process.env.WHATSAPP_INSTANT_GROUPS?.split(',').filter(Boolean) || [], - }, - signal: { - enabled: !!process.env.SIGNAL_PHONE_NUMBER, - phoneNumber: process.env.SIGNAL_PHONE_NUMBER || '', - cliPath: process.env.SIGNAL_CLI_PATH || 'signal-cli', - httpHost: process.env.SIGNAL_HTTP_HOST || '127.0.0.1', - httpPort: parseInt(process.env.SIGNAL_HTTP_PORT || '8090', 10), - dmPolicy: (process.env.SIGNAL_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open', - allowedUsers: process.env.SIGNAL_ALLOWED_USERS?.split(',').filter(Boolean) || [], - selfChatMode: process.env.SIGNAL_SELF_CHAT_MODE !== 'false', // Default true - groupPollIntervalMin: process.env.SIGNAL_GROUP_POLL_INTERVAL_MIN !== undefined - ? parseInt(process.env.SIGNAL_GROUP_POLL_INTERVAL_MIN, 10) - : 10, - instantGroups: process.env.SIGNAL_INSTANT_GROUPS?.split(',').filter(Boolean) || [], - }, - discord: { - enabled: !!process.env.DISCORD_BOT_TOKEN, - token: process.env.DISCORD_BOT_TOKEN || '', - dmPolicy: (process.env.DISCORD_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open', - allowedUsers: process.env.DISCORD_ALLOWED_USERS?.split(',').filter(Boolean) || [], - groupPollIntervalMin: process.env.DISCORD_GROUP_POLL_INTERVAL_MIN !== undefined - ? parseInt(process.env.DISCORD_GROUP_POLL_INTERVAL_MIN, 10) - : 10, - instantGroups: process.env.DISCORD_INSTANT_GROUPS?.split(',').filter(Boolean) || [], - }, - - // Cron - cronEnabled: process.env.CRON_ENABLED === 'true', - - // Heartbeat - simpler config - heartbeat: { - enabled: !!process.env.HEARTBEAT_INTERVAL_MIN, - intervalMinutes: parseInt(process.env.HEARTBEAT_INTERVAL_MIN || '0', 10) || 30, - prompt: process.env.HEARTBEAT_PROMPT, - target: parseHeartbeatTarget(process.env.HEARTBEAT_TARGET), - }, - - // Polling - system-level background checks - // Priority: YAML polling section > YAML integrations.google (legacy) > env vars - polling: (() => { - const gmailAccount = yamlConfig.polling?.gmail?.account - || process.env.GMAIL_ACCOUNT || ''; - const gmailEnabled = yamlConfig.polling?.gmail?.enabled ?? !!gmailAccount; - const intervalMs = yamlConfig.polling?.intervalMs - ?? parseInt(process.env.POLLING_INTERVAL_MS || '60000', 10); - const enabled = yamlConfig.polling?.enabled ?? gmailEnabled; - return { - enabled, - intervalMs, - gmail: { - enabled: gmailEnabled, - account: gmailAccount, - }, - }; - })(), + cronEnabled: process.env.CRON_ENABLED === 'true', // Legacy env var fallback }; -// Validate at least one channel is configured -if (!config.telegram.enabled && !config.slack.enabled && !config.whatsapp.enabled && !config.signal.enabled && !config.discord.enabled) { - console.error('\n Error: No channels configured.'); - console.error(' Set TELEGRAM_BOT_TOKEN, SLACK_BOT_TOKEN+SLACK_APP_TOKEN, WHATSAPP_ENABLED=true, SIGNAL_PHONE_NUMBER, or DISCORD_BOT_TOKEN\n'); - process.exit(1); -} - // Validate LETTA_API_KEY is set for cloud mode (selfhosted mode doesn't require it) if (yamlConfig.server.mode !== 'selfhosted' && !process.env.LETTA_API_KEY) { console.error('\n Error: LETTA_API_KEY is required for Letta Cloud.'); @@ -352,267 +424,192 @@ async function main() { console.log(`[Storage] Railway volume detected at ${process.env.RAILWAY_VOLUME_MOUNT_PATH}`); } console.log(`[Storage] Data directory: ${dataDir}`); - console.log(`[Storage] Working directory: ${config.workingDir}`); + console.log(`[Storage] Working directory: ${globalConfig.workingDir}`); - // Create bot with skills config (skills installed to agent-scoped location after agent creation) - const bot = new LettaBot({ - workingDir: config.workingDir, - model: config.model, - agentName: process.env.AGENT_NAME || 'LettaBot', - allowedTools: config.allowedTools, - maxToolCalls: process.env.MAX_TOOL_CALLS ? Number(process.env.MAX_TOOL_CALLS) : undefined, - skills: { - cronEnabled: config.cronEnabled, - googleEnabled: config.polling.gmail.enabled, - }, - }); + // Normalize config to agents array + const agents = normalizeAgents(yamlConfig); + const isMultiAgent = agents.length > 1; + console.log(`[Config] ${agents.length} agent(s) configured: ${agents.map(a => a.name).join(', ')}`); + + // Validate at least one agent has channels + const totalChannels = agents.reduce((sum, a) => sum + Object.keys(a.channels).length, 0); + if (totalChannels === 0) { + console.error('\n Error: No channels configured in any agent.'); + console.error(' Configure channels in lettabot.yaml or set environment variables.\n'); + process.exit(1); + } - const attachmentsDir = resolve(config.workingDir, 'attachments'); - pruneAttachmentsDir(attachmentsDir, config.attachmentsMaxAgeDays).catch((err) => { + const attachmentsDir = resolve(globalConfig.workingDir, 'attachments'); + pruneAttachmentsDir(attachmentsDir, globalConfig.attachmentsMaxAgeDays).catch((err) => { console.warn('[Attachments] Prune failed:', err); }); - if (config.attachmentsMaxAgeDays > 0) { + if (globalConfig.attachmentsMaxAgeDays > 0) { const timer = setInterval(() => { - pruneAttachmentsDir(attachmentsDir, config.attachmentsMaxAgeDays).catch((err) => { + pruneAttachmentsDir(attachmentsDir, globalConfig.attachmentsMaxAgeDays).catch((err) => { console.warn('[Attachments] Prune failed:', err); }); }, ATTACHMENTS_PRUNE_INTERVAL_MS); timer.unref?.(); } - // Verify agent exists (clear stale ID if deleted) - let initialStatus = bot.getStatus(); - if (initialStatus.agentId) { - const exists = await agentExists(initialStatus.agentId); - if (!exists) { - console.log(`[Agent] Stored agent ${initialStatus.agentId} not found on server`); - bot.reset(); - // Also clear env var so search-by-name can run - delete process.env.LETTA_AGENT_ID; - initialStatus = bot.getStatus(); + const gateway = new LettaGateway(); + const services: { + cronServices: CronService[], + heartbeatServices: HeartbeatService[], + pollingServices: PollingService[], + groupBatchers: GroupBatcher[] + } = { + cronServices: [], + heartbeatServices: [], + pollingServices: [], + groupBatchers: [], + }; + + for (const agentConfig of agents) { + console.log(`\n[Setup] Configuring agent: ${agentConfig.name}`); + + // Create LettaBot for this agent + const bot = new LettaBot({ + workingDir: globalConfig.workingDir, + agentName: agentConfig.name, + model: agentConfig.model, + allowedTools: globalConfig.allowedTools, + maxToolCalls: agentConfig.features?.maxToolCalls, + skills: { + cronEnabled: agentConfig.features?.cron ?? globalConfig.cronEnabled, + googleEnabled: !!agentConfig.integrations?.google?.enabled || !!agentConfig.polling?.gmail?.enabled, + }, + }); + + // Verify agent exists (clear stale ID if deleted) + let initialStatus = bot.getStatus(); + if (initialStatus.agentId) { + const exists = await agentExists(initialStatus.agentId); + if (!exists) { + console.log(`[Agent:${agentConfig.name}] Stored agent ${initialStatus.agentId} not found on server`); + bot.reset(); + initialStatus = bot.getStatus(); + } } - } - - // Container deploy: try to find existing agent by name if no ID set - const agentName = process.env.AGENT_NAME || 'LettaBot'; - if (!initialStatus.agentId && isContainerDeploy) { - console.log(`[Agent] Searching for existing agent named "${agentName}"...`); - const found = await findAgentByName(agentName); - if (found) { - console.log(`[Agent] Found existing agent: ${found.id}`); - process.env.LETTA_AGENT_ID = found.id; - // Reinitialize bot with found agent - bot.setAgentId(found.id); - initialStatus = bot.getStatus(); + + // Container deploy: discover by name + if (!initialStatus.agentId && isContainerDeploy) { + const found = await findAgentByName(agentConfig.name); + if (found) { + console.log(`[Agent:${agentConfig.name}] Found existing agent: ${found.id}`); + bot.setAgentId(found.id); + initialStatus = bot.getStatus(); + } } - } - - // Agent will be created on first user message (lazy initialization) - if (!initialStatus.agentId) { - console.log(`[Agent] No agent found - will create "${agentName}" on first message`); - } - - // Proactively disable tool approvals for headless operation - // Prevents stuck states from server-side requires_approval=true (SDK issue #25) - if (initialStatus.agentId) { - ensureNoToolApprovals(initialStatus.agentId).catch(err => { - console.warn('[Agent] Failed to check tool approvals:', err); - }); - } - - // Register enabled channels - if (config.telegram.enabled) { - const telegram = new TelegramAdapter({ - token: config.telegram.token, - dmPolicy: config.telegram.dmPolicy, - allowedUsers: config.telegram.allowedUsers.length > 0 ? config.telegram.allowedUsers : undefined, - attachmentsDir, - attachmentsMaxBytes: config.attachmentsMaxBytes, - }); - bot.registerChannel(telegram); - } - - if (config.slack.enabled) { - const slack = new SlackAdapter({ - botToken: config.slack.botToken, - appToken: config.slack.appToken, - dmPolicy: config.slack.dmPolicy, - allowedUsers: config.slack.allowedUsers.length > 0 ? config.slack.allowedUsers : undefined, - attachmentsDir, - attachmentsMaxBytes: config.attachmentsMaxBytes, - }); - bot.registerChannel(slack); - } - - if (config.whatsapp.enabled) { - if (!config.whatsapp.selfChatMode) { - console.warn('[WhatsApp] WARNING: selfChatMode is OFF - bot will respond to ALL incoming messages!'); - console.warn('[WhatsApp] Only use this if this is a dedicated bot number, not your personal WhatsApp.'); + + if (!initialStatus.agentId) { + console.log(`[Agent:${agentConfig.name}] No agent found - will create on first message`); } - const whatsapp = new WhatsAppAdapter({ - sessionPath: config.whatsapp.sessionPath, - dmPolicy: config.whatsapp.dmPolicy, - allowedUsers: config.whatsapp.allowedUsers.length > 0 ? config.whatsapp.allowedUsers : undefined, - selfChatMode: config.whatsapp.selfChatMode, - attachmentsDir, - attachmentsMaxBytes: config.attachmentsMaxBytes, - }); - bot.registerChannel(whatsapp); - } - - if (config.signal.enabled) { - if (!config.signal.selfChatMode) { - console.warn('[Signal] WARNING: selfChatMode is OFF - bot will respond to ALL incoming messages!'); - console.warn('[Signal] Only use this if this is a dedicated bot number, not your personal Signal.'); + + // Disable tool approvals + if (initialStatus.agentId) { + ensureNoToolApprovals(initialStatus.agentId).catch(err => { + console.warn(`[Agent:${agentConfig.name}] Failed to check tool approvals:`, err); + }); } - const signal = new SignalAdapter({ - phoneNumber: config.signal.phoneNumber, - cliPath: config.signal.cliPath, - httpHost: config.signal.httpHost, - httpPort: config.signal.httpPort, - dmPolicy: config.signal.dmPolicy, - allowedUsers: config.signal.allowedUsers.length > 0 ? config.signal.allowedUsers : undefined, - selfChatMode: config.signal.selfChatMode, - attachmentsDir, - attachmentsMaxBytes: config.attachmentsMaxBytes, - }); - bot.registerChannel(signal); - } - - if (config.discord.enabled) { - const discord = new DiscordAdapter({ - token: config.discord.token, - dmPolicy: config.discord.dmPolicy, - allowedUsers: config.discord.allowedUsers.length > 0 ? config.discord.allowedUsers : undefined, - attachmentsDir, - attachmentsMaxBytes: config.attachmentsMaxBytes, - }); - bot.registerChannel(discord); - } - - // Create and wire group batcher - const groupIntervals = new Map(); - if (config.telegram.enabled) { - groupIntervals.set('telegram', config.telegram.groupPollIntervalMin ?? 10); - } - if (config.slack.enabled) { - groupIntervals.set('slack', config.slack.groupPollIntervalMin ?? 10); - } - if (config.whatsapp.enabled) { - groupIntervals.set('whatsapp', config.whatsapp.groupPollIntervalMin ?? 10); - } - if (config.signal.enabled) { - groupIntervals.set('signal', config.signal.groupPollIntervalMin ?? 10); - } - if (config.discord.enabled) { - groupIntervals.set('discord', config.discord.groupPollIntervalMin ?? 10); - } - // Build instant group IDs set (channel:id format) - const instantGroupIds = new Set(); - const channelInstantGroups: Array<[string, string[]]> = [ - ['telegram', config.telegram.instantGroups], - ['slack', config.slack.instantGroups], - ['whatsapp', config.whatsapp.instantGroups], - ['signal', config.signal.instantGroups], - ['discord', config.discord.instantGroups], - ]; - for (const [channel, ids] of channelInstantGroups) { - for (const id of ids) { - instantGroupIds.add(`${channel}:${id}`); + + // Create and register channels + const adapters = createChannelsForAgent(agentConfig, attachmentsDir, globalConfig.attachmentsMaxBytes); + for (const adapter of adapters) { + bot.registerChannel(adapter); } - } - if (instantGroupIds.size > 0) { - console.log(`[Groups] Instant groups: ${[...instantGroupIds].join(', ')}`); - } - - const groupBatcher = new GroupBatcher((msg, adapter) => { - bot.processGroupBatch(msg, adapter); - }); - bot.setGroupBatcher(groupBatcher, groupIntervals, instantGroupIds); - - // Start cron service if enabled - // Note: CronService uses getDataDir() for cron-jobs.json to match the CLI - let cronService: CronService | null = null; - if (config.cronEnabled) { - cronService = new CronService(bot); - await cronService.start(); - } - - // Create heartbeat service (always available for /heartbeat command) - const heartbeatService = new HeartbeatService(bot, { - enabled: config.heartbeat.enabled, - intervalMinutes: config.heartbeat.intervalMinutes, - prompt: config.heartbeat.prompt, - workingDir: config.workingDir, - target: config.heartbeat.target, - }); - - // Start auto-heartbeats only if interval is configured - if (config.heartbeat.enabled) { - heartbeatService.start(); - } - - // Wire up /heartbeat command (always available) - bot.onTriggerHeartbeat = () => heartbeatService.trigger(); - - // Start polling service if enabled (Gmail, etc.) - let pollingService: PollingService | null = null; - if (config.polling.enabled) { - pollingService = new PollingService(bot, { - intervalMs: config.polling.intervalMs, - workingDir: config.workingDir, - gmail: config.polling.gmail, + + // Setup group batching + const { batcher, intervals, instantIds } = createGroupBatcher(agentConfig, bot); + if (batcher) { + bot.setGroupBatcher(batcher, intervals, instantIds); + services.groupBatchers.push(batcher); + } + + // Per-agent cron + if (agentConfig.features?.cron ?? globalConfig.cronEnabled) { + const cronService = new CronService(bot); + await cronService.start(); + services.cronServices.push(cronService); + } + + // Per-agent heartbeat + const heartbeatConfig = agentConfig.features?.heartbeat; + const heartbeatService = new HeartbeatService(bot, { + enabled: heartbeatConfig?.enabled ?? false, + intervalMinutes: heartbeatConfig?.intervalMin ?? 30, + prompt: process.env.HEARTBEAT_PROMPT, + workingDir: globalConfig.workingDir, + target: parseHeartbeatTarget(process.env.HEARTBEAT_TARGET), }); - pollingService.start(); + if (heartbeatConfig?.enabled) { + heartbeatService.start(); + services.heartbeatServices.push(heartbeatService); + } + bot.onTriggerHeartbeat = () => heartbeatService.trigger(); + + // Per-agent polling + const pollConfig = agentConfig.polling || (agentConfig.integrations?.google ? { + enabled: agentConfig.integrations.google.enabled, + intervalMs: (agentConfig.integrations.google.pollIntervalSec || 60) * 1000, + gmail: { + enabled: agentConfig.integrations.google.enabled, + account: agentConfig.integrations.google.account || '', + }, + } : undefined); + + if (pollConfig?.enabled && pollConfig.gmail?.enabled) { + const pollingService = new PollingService(bot, { + intervalMs: pollConfig.intervalMs || 60000, + workingDir: globalConfig.workingDir, + gmail: { + enabled: pollConfig.gmail.enabled, + account: pollConfig.gmail.account || '', + }, + }); + pollingService.start(); + services.pollingServices.push(pollingService); + } + + gateway.addAgent(agentConfig.name, bot); } - // Start all channels - await bot.start(); + // Start all agents + await gateway.start(); // Load/generate API key for CLI authentication const apiKey = loadOrGenerateApiKey(); console.log(`[API] Key: ${apiKey.slice(0, 8)}... (set LETTABOT_API_KEY to customize)`); - // Start API server (replaces health server, includes health checks) - // Provides endpoints for CLI to send messages across Docker boundaries + // Start API server - uses gateway for delivery const apiPort = parseInt(process.env.PORT || '8080', 10); const apiHost = process.env.API_HOST; // undefined = 127.0.0.1 (secure default) const apiCorsOrigin = process.env.API_CORS_ORIGIN; // undefined = same-origin only - const apiServer = createApiServer(bot, { + const apiServer = createApiServer(gateway, { port: apiPort, apiKey: apiKey, host: apiHost, corsOrigin: apiCorsOrigin, }); - // Log status - const status = bot.getStatus(); + // Status logging console.log('\n================================='); - console.log('LettaBot is running!'); + console.log(`LettaBot is running! (${gateway.size} agent${gateway.size > 1 ? 's' : ''})`); console.log('================================='); - console.log(`Agent ID: ${status.agentId || '(will be created on first message)'}`); - if (isContainerDeploy && status.agentId) { - console.log(`[Agent] Using agent "${agentName}" (auto-discovered by name)`); - } - console.log(`Channels: ${status.channels.join(', ')}`); - console.log(`Cron: ${config.cronEnabled ? 'enabled' : 'disabled'}`); - console.log(`Heartbeat: ${config.heartbeat.enabled ? `every ${config.heartbeat.intervalMinutes} min` : 'disabled'}`); - console.log(`Polling: ${config.polling.enabled ? `every ${config.polling.intervalMs / 1000}s` : 'disabled'}`); - if (config.polling.gmail.enabled) { - console.log(` └─ Gmail: ${config.polling.gmail.account}`); - } - if (config.heartbeat.enabled) { - console.log(`Heartbeat target: ${config.heartbeat.target ? `${config.heartbeat.target.channel}:${config.heartbeat.target.chatId}` : 'last messaged'}`); + for (const name of gateway.getAgentNames()) { + const status = gateway.getAgent(name)!.getStatus(); + console.log(` ${name}: ${status.agentId || '(pending)'} [${status.channels.join(', ')}]`); } console.log('=================================\n'); - // Handle shutdown + // Shutdown const shutdown = async () => { console.log('\nShutting down...'); - groupBatcher.stop(); - heartbeatService?.stop(); - cronService?.stop(); - await bot.stop(); + services.groupBatchers.forEach(b => b.stop()); + services.heartbeatServices.forEach(h => h.stop()); + services.cronServices.forEach(c => c.stop()); + services.pollingServices.forEach(p => p.stop()); + await gateway.stop(); process.exit(0); }; From 40586cdc9a33bfdeeae7b08eb034ec2f18667ad3 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 8 Feb 2026 21:56:47 -0800 Subject: [PATCH 12/21] fix: multi-agent state isolation and config.id wiring (#218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs from the Phase 1 merge: 1. Store not scoped to agent name: LettaBot constructor passed no agent name to Store, so all agents in multi-agent mode shared the same agentId/conversationId state. Now passes config.agentName. 2. agentConfig.id ignored: Users could set `id: agent-abc123` in YAML but it was never applied. Now checked before store verification. Written by Cameron ◯ Letta Code "The best error message is the one that never shows up." -- Thomas Fuchs --- src/core/bot.ts | 2 +- src/main.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/core/bot.ts b/src/core/bot.ts index 7773616..e890360 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -101,7 +101,7 @@ export class LettaBot implements AgentSession { mkdirSync(config.workingDir, { recursive: true }); // Store in project root (same as main.ts reads for LETTA_AGENT_ID) - this.store = new Store('lettabot-agent.json'); + this.store = new Store('lettabot-agent.json', config.agentName); console.log(`LettaBot initialized. Agent ID: ${this.store.agentId || '(new)'}`); } diff --git a/src/main.ts b/src/main.ts index 39fb08e..5933231 100644 --- a/src/main.ts +++ b/src/main.ts @@ -481,8 +481,15 @@ async function main() { }, }); - // Verify agent exists (clear stale ID if deleted) + // Apply explicit agent ID from config (before store verification) let initialStatus = bot.getStatus(); + if (agentConfig.id && !initialStatus.agentId) { + console.log(`[Agent:${agentConfig.name}] Using configured agent ID: ${agentConfig.id}`); + bot.setAgentId(agentConfig.id); + initialStatus = bot.getStatus(); + } + + // Verify agent exists (clear stale ID if deleted) if (initialStatus.agentId) { const exists = await agentExists(initialStatus.agentId); if (!exists) { From 5200a1e7e8918b4ddd51e53bba3ad2443d880ebb Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 8 Feb 2026 22:26:25 -0800 Subject: [PATCH 13/21] fix: false "no response" error after short streaming replies (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the agent sends a short single-chunk response (e.g. "Yep."), the streaming edit path sends it to the channel immediately but never sets sentAnyMessage. When finalizeMessage() then tries to edit the message to identical content, Telegram rejects it ("message not modified"), the catch swallows the error, and the post-loop fallback shows a spurious "(No response)" error alongside the actual reply. Fix: (1) set sentAnyMessage when the streaming path sends a new message, (2) treat edit failures as success when messageId exists (the message is already displayed to the user). Written by Cameron ◯ Letta Code "The stream carried everything -- we just forgot to look." - on debugging --- src/core/bot.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/bot.ts b/src/core/bot.ts index e890360..216e674 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -544,7 +544,8 @@ export class LettaBot implements AgentSession { const preview = response.length > 50 ? response.slice(0, 50) + '...' : response; console.log(`[Bot] Sent: "${preview}"`); } catch { - // Ignore send errors + // Edit failures (e.g. "message not modified") are OK if we already sent the message + if (messageId) sentAnyMessage = true; } } // Reset for next message bubble @@ -630,9 +631,10 @@ export class LettaBot implements AgentSession { } else { const result = await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId }); messageId = result.messageId; + sentAnyMessage = true; } } catch { - // Ignore edit errors + // Ignore edit errors (e.g. rate limits) } lastUpdate = Date.now(); } From 61a7450106099294ccb1354e0e1ac00fe1bbeaf8 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 8 Feb 2026 22:36:25 -0800 Subject: [PATCH 14/21] docs: multi-agent configuration reference (#223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add multi-agent configuration reference Document the agents[] YAML config, per-agent options, migration path from single to multi-agent, and known limitations (#219, #220, #221). Written by Cameron ◯ Letta Code "Documentation is a love letter that you write to your future self." -- Damian Conway * docs: fix channels required claim and soften isolation wording - channels is not strictly required per-agent (validation is global) - isolation has known exceptions, don't claim "fully isolated" Written by Cameron ◯ Letta Code "Clear is kind." -- Brene Brown --- docs/configuration.md | 107 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index db9902a..126ea42 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,7 +24,8 @@ server: mode: cloud # 'cloud' or 'selfhosted' apiKey: letta_... # Required for cloud mode -# Agent settings +# Agent settings (single agent mode) +# For multiple agents, use `agents:` array instead -- see Multi-Agent section agent: name: LettaBot model: claude-sonnet-4 @@ -116,7 +117,9 @@ docker run -v ~/.letta/.persist/pgdata:/var/lib/postgresql/data \ letta/letta:latest ``` -## Agent Configuration +## Agent Configuration (Single Agent) + +The default config uses `agent:` and `channels:` at the top level for a single agent: | Option | Type | Description | |--------|------|-------------| @@ -124,6 +127,106 @@ docker run -v ~/.letta/.persist/pgdata:/var/lib/postgresql/data \ | `agent.name` | string | Name for new agent | | `agent.model` | string | Model ID (e.g., `claude-sonnet-4`) | +For multiple agents, see [Multi-Agent Configuration](#multi-agent-configuration) below. + +## Multi-Agent Configuration + +Run multiple independent agents from a single LettaBot instance. Each agent gets its own channels, state, cron, heartbeat, and polling services. + +Use the `agents:` array instead of the top-level `agent:` and `channels:` keys: + +```yaml +server: + mode: cloud + apiKey: letta_... + +agents: + - name: work-assistant + model: claude-sonnet-4 + # id: agent-abc123 # Optional: use existing agent + channels: + telegram: + token: ${WORK_TELEGRAM_TOKEN} + dmPolicy: pairing + slack: + botToken: ${SLACK_BOT_TOKEN} + appToken: ${SLACK_APP_TOKEN} + features: + cron: true + heartbeat: + enabled: true + intervalMin: 30 + + - name: personal-assistant + model: claude-sonnet-4 + channels: + signal: + phone: "+1234567890" + selfChat: true + whatsapp: + enabled: true + selfChat: true + features: + heartbeat: + enabled: true + intervalMin: 60 +``` + +### Per-Agent Options + +Each entry in `agents:` accepts: + +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `name` | string | Yes | Agent name (used for display, creation, and state isolation) | +| `id` | string | No | Use existing agent ID (skips creation) | +| `model` | string | No | Model for agent creation | +| `channels` | object | No | Channel configs (same schema as top-level `channels:`). At least one agent must have channels. | +| `features` | object | No | Per-agent features (cron, heartbeat, maxToolCalls) | +| `polling` | object | No | Per-agent polling config (Gmail, etc.) | +| `integrations` | object | No | Per-agent integrations (Google, etc.) | + +### How it works + +- Each agent is a separate Letta agent with its own conversation history and memory +- Agents have isolated state, channels, and services (see [known limitations](#known-limitations) for exceptions) +- The `LettaGateway` orchestrates startup, shutdown, and message delivery across agents +- Legacy single-agent configs (`agent:` + `channels:`) continue to work unchanged + +### Migrating from single to multi-agent + +Your existing config: + +```yaml +agent: + name: MyBot +channels: + telegram: + token: "..." +features: + cron: true +``` + +Becomes: + +```yaml +agents: + - name: MyBot + channels: + telegram: + token: "..." + features: + cron: true +``` + +The `server:`, `transcription:`, `attachments:`, and `api:` sections remain at the top level (shared across all agents). + +### Known limitations + +- Two agents cannot share the same channel type without ambiguous API routing ([#219](https://github.com/letta-ai/lettabot/issues/219)) +- WhatsApp/Signal session paths are not yet agent-scoped ([#220](https://github.com/letta-ai/lettabot/issues/220)) +- Heartbeat prompt and target are not yet configurable per-agent ([#221](https://github.com/letta-ai/lettabot/issues/221)) + ## Channel Configuration All channels share these common options: From 65cd82bc33c58ef6e5837d055ac1a0cdfccedf98 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 8 Feb 2026 22:42:22 -0800 Subject: [PATCH 15/21] fix: restore env var channel config for container deploys (#224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeAgents() only read YAML config, breaking Railway/Docker deploys where channels are configured via environment variables (TELEGRAM_BOT_TOKEN, etc.). Add env var fallback in the legacy single-agent path. Multi-agent mode still requires YAML configuration. Written by Cameron ◯ Letta Code "The cheapest, fastest, and most reliable components are those that aren't there." -- Gordon Bell --- src/config/normalize.test.ts | 101 ++++++++++++++++++++++++++++++++++- src/config/types.ts | 39 ++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/src/config/normalize.test.ts b/src/config/normalize.test.ts index 24c55e1..b966788 100644 --- a/src/config/normalize.test.ts +++ b/src/config/normalize.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { normalizeAgents, type LettaBotConfig, type AgentConfig } from './types.js'; describe('normalizeAgents', () => { @@ -156,6 +156,105 @@ describe('normalizeAgents', () => { expect(agents[0].id).toBe('agent-123'); }); + describe('env var fallback (container deploys)', () => { + const envVars = [ + 'TELEGRAM_BOT_TOKEN', 'TELEGRAM_DM_POLICY', + 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'SLACK_DM_POLICY', + 'WHATSAPP_ENABLED', 'WHATSAPP_SELF_CHAT_MODE', 'WHATSAPP_DM_POLICY', + 'SIGNAL_PHONE_NUMBER', 'SIGNAL_SELF_CHAT_MODE', 'SIGNAL_DM_POLICY', + 'DISCORD_BOT_TOKEN', 'DISCORD_DM_POLICY', + ]; + const savedEnv: Record = {}; + + beforeEach(() => { + for (const key of envVars) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of envVars) { + if (savedEnv[key] !== undefined) { + process.env[key] = savedEnv[key]; + } else { + delete process.env[key]; + } + } + }); + + it('should pick up channels from env vars when YAML has none', () => { + process.env.TELEGRAM_BOT_TOKEN = 'env-telegram-token'; + process.env.DISCORD_BOT_TOKEN = 'env-discord-token'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].channels.telegram?.token).toBe('env-telegram-token'); + expect(agents[0].channels.discord?.token).toBe('env-discord-token'); + }); + + it('should not override YAML channels with env vars', () => { + process.env.TELEGRAM_BOT_TOKEN = 'env-token'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: { + telegram: { enabled: true, token: 'yaml-token' }, + }, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].channels.telegram?.token).toBe('yaml-token'); + }); + + it('should not apply env vars in multi-agent mode', () => { + process.env.TELEGRAM_BOT_TOKEN = 'env-token'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agents: [{ name: 'Bot1', channels: {} }], + agent: { name: 'Unused', model: 'unused' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].channels.telegram).toBeUndefined(); + }); + + it('should pick up all channel types from env vars', () => { + process.env.TELEGRAM_BOT_TOKEN = 'tg-token'; + process.env.SLACK_BOT_TOKEN = 'slack-bot'; + process.env.SLACK_APP_TOKEN = 'slack-app'; + process.env.WHATSAPP_ENABLED = 'true'; + process.env.SIGNAL_PHONE_NUMBER = '+1234567890'; + process.env.DISCORD_BOT_TOKEN = 'discord-token'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].channels.telegram?.token).toBe('tg-token'); + expect(agents[0].channels.slack?.botToken).toBe('slack-bot'); + expect(agents[0].channels.slack?.appToken).toBe('slack-app'); + expect(agents[0].channels.whatsapp?.enabled).toBe(true); + expect(agents[0].channels.signal?.phone).toBe('+1234567890'); + expect(agents[0].channels.discord?.token).toBe('discord-token'); + }); + }); + it('should preserve features, polling, and integrations', () => { const config: LettaBotConfig = { server: { mode: 'cloud' }, diff --git a/src/config/types.ts b/src/config/types.ts index 3454ad2..9eb6a39 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -256,6 +256,45 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { // Filter out disabled/misconfigured channels const channels = normalizeChannels(config.channels); + // Env var fallback for container deploys without lettabot.yaml (e.g. Railway) + if (!channels.telegram && process.env.TELEGRAM_BOT_TOKEN) { + channels.telegram = { + enabled: true, + token: process.env.TELEGRAM_BOT_TOKEN, + dmPolicy: (process.env.TELEGRAM_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', + }; + } + if (!channels.slack && process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) { + channels.slack = { + enabled: true, + botToken: process.env.SLACK_BOT_TOKEN, + appToken: process.env.SLACK_APP_TOKEN, + dmPolicy: (process.env.SLACK_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', + }; + } + if (!channels.whatsapp && process.env.WHATSAPP_ENABLED === 'true') { + channels.whatsapp = { + enabled: true, + selfChat: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false', + dmPolicy: (process.env.WHATSAPP_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', + }; + } + if (!channels.signal && process.env.SIGNAL_PHONE_NUMBER) { + channels.signal = { + enabled: true, + phone: process.env.SIGNAL_PHONE_NUMBER, + selfChat: process.env.SIGNAL_SELF_CHAT_MODE !== 'false', + dmPolicy: (process.env.SIGNAL_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', + }; + } + if (!channels.discord && process.env.DISCORD_BOT_TOKEN) { + channels.discord = { + enabled: true, + token: process.env.DISCORD_BOT_TOKEN, + dmPolicy: (process.env.DISCORD_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', + }; + } + return [{ name: agentName, id, From abf3307e3d21d74099d09f43c1008e72aec1499d Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 8 Feb 2026 23:07:32 -0800 Subject: [PATCH 16/21] fix: include allowedUsers in env var channel fallback (#226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeAgents() env var fallback (added in #224) only mapped token and dmPolicy, missing allowedUsers for all channels. This caused dmPolicy=allowlist to reject everyone since the allowlist was empty. Parses *_ALLOWED_USERS env vars (comma-separated) for all five channels, matching the existing format used by onboard.ts and the Railway env exporter. Written by Cameron ◯ Letta Code "We can only see a short distance ahead, but we can see plenty there that needs to be done." -- Alan Turing --- src/config/normalize.test.ts | 56 ++++++++++++++++++++++++++++++++---- src/config/types.ts | 9 ++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/config/normalize.test.ts b/src/config/normalize.test.ts index b966788..7880986 100644 --- a/src/config/normalize.test.ts +++ b/src/config/normalize.test.ts @@ -158,11 +158,11 @@ describe('normalizeAgents', () => { describe('env var fallback (container deploys)', () => { const envVars = [ - 'TELEGRAM_BOT_TOKEN', 'TELEGRAM_DM_POLICY', - 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'SLACK_DM_POLICY', - 'WHATSAPP_ENABLED', 'WHATSAPP_SELF_CHAT_MODE', 'WHATSAPP_DM_POLICY', - 'SIGNAL_PHONE_NUMBER', 'SIGNAL_SELF_CHAT_MODE', 'SIGNAL_DM_POLICY', - 'DISCORD_BOT_TOKEN', 'DISCORD_DM_POLICY', + 'TELEGRAM_BOT_TOKEN', 'TELEGRAM_DM_POLICY', 'TELEGRAM_ALLOWED_USERS', + 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'SLACK_DM_POLICY', 'SLACK_ALLOWED_USERS', + 'WHATSAPP_ENABLED', 'WHATSAPP_SELF_CHAT_MODE', 'WHATSAPP_DM_POLICY', 'WHATSAPP_ALLOWED_USERS', + 'SIGNAL_PHONE_NUMBER', 'SIGNAL_SELF_CHAT_MODE', 'SIGNAL_DM_POLICY', 'SIGNAL_ALLOWED_USERS', + 'DISCORD_BOT_TOKEN', 'DISCORD_DM_POLICY', 'DISCORD_ALLOWED_USERS', ]; const savedEnv: Record = {}; @@ -253,6 +253,52 @@ describe('normalizeAgents', () => { expect(agents[0].channels.signal?.phone).toBe('+1234567890'); expect(agents[0].channels.discord?.token).toBe('discord-token'); }); + + it('should pick up allowedUsers from env vars for all channels', () => { + process.env.TELEGRAM_BOT_TOKEN = 'tg-token'; + process.env.TELEGRAM_DM_POLICY = 'allowlist'; + process.env.TELEGRAM_ALLOWED_USERS = '515978553, 123456'; + + process.env.SLACK_BOT_TOKEN = 'slack-bot'; + process.env.SLACK_APP_TOKEN = 'slack-app'; + process.env.SLACK_DM_POLICY = 'allowlist'; + process.env.SLACK_ALLOWED_USERS = 'U123,U456'; + + process.env.DISCORD_BOT_TOKEN = 'discord-token'; + process.env.DISCORD_DM_POLICY = 'allowlist'; + process.env.DISCORD_ALLOWED_USERS = '999888777'; + + process.env.WHATSAPP_ENABLED = 'true'; + process.env.WHATSAPP_DM_POLICY = 'allowlist'; + process.env.WHATSAPP_ALLOWED_USERS = '+1234567890,+0987654321'; + + process.env.SIGNAL_PHONE_NUMBER = '+1555000000'; + process.env.SIGNAL_DM_POLICY = 'allowlist'; + process.env.SIGNAL_ALLOWED_USERS = '+1555111111'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].channels.telegram?.dmPolicy).toBe('allowlist'); + expect(agents[0].channels.telegram?.allowedUsers).toEqual(['515978553', '123456']); + + expect(agents[0].channels.slack?.dmPolicy).toBe('allowlist'); + expect(agents[0].channels.slack?.allowedUsers).toEqual(['U123', 'U456']); + + expect(agents[0].channels.discord?.dmPolicy).toBe('allowlist'); + expect(agents[0].channels.discord?.allowedUsers).toEqual(['999888777']); + + expect(agents[0].channels.whatsapp?.dmPolicy).toBe('allowlist'); + expect(agents[0].channels.whatsapp?.allowedUsers).toEqual(['+1234567890', '+0987654321']); + + expect(agents[0].channels.signal?.dmPolicy).toBe('allowlist'); + expect(agents[0].channels.signal?.allowedUsers).toEqual(['+1555111111']); + }); }); it('should preserve features, polling, and integrations', () => { diff --git a/src/config/types.ts b/src/config/types.ts index 9eb6a39..b14ecad 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -257,11 +257,16 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { const channels = normalizeChannels(config.channels); // Env var fallback for container deploys without lettabot.yaml (e.g. Railway) + // Helper: parse comma-separated env var into string array (or undefined) + const parseList = (envVar?: string): string[] | undefined => + envVar ? envVar.split(',').map(s => s.trim()).filter(Boolean) : undefined; + if (!channels.telegram && process.env.TELEGRAM_BOT_TOKEN) { channels.telegram = { enabled: true, token: process.env.TELEGRAM_BOT_TOKEN, dmPolicy: (process.env.TELEGRAM_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', + allowedUsers: parseList(process.env.TELEGRAM_ALLOWED_USERS), }; } if (!channels.slack && process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) { @@ -270,6 +275,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { botToken: process.env.SLACK_BOT_TOKEN, appToken: process.env.SLACK_APP_TOKEN, dmPolicy: (process.env.SLACK_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', + allowedUsers: parseList(process.env.SLACK_ALLOWED_USERS), }; } if (!channels.whatsapp && process.env.WHATSAPP_ENABLED === 'true') { @@ -277,6 +283,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { enabled: true, selfChat: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false', dmPolicy: (process.env.WHATSAPP_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', + allowedUsers: parseList(process.env.WHATSAPP_ALLOWED_USERS), }; } if (!channels.signal && process.env.SIGNAL_PHONE_NUMBER) { @@ -285,6 +292,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { phone: process.env.SIGNAL_PHONE_NUMBER, selfChat: process.env.SIGNAL_SELF_CHAT_MODE !== 'false', dmPolicy: (process.env.SIGNAL_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', + allowedUsers: parseList(process.env.SIGNAL_ALLOWED_USERS), }; } if (!channels.discord && process.env.DISCORD_BOT_TOKEN) { @@ -292,6 +300,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { enabled: true, token: process.env.DISCORD_BOT_TOKEN, dmPolicy: (process.env.DISCORD_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', + allowedUsers: parseList(process.env.DISCORD_ALLOWED_USERS), }; } From 999ee89cb038b48a7e3aa6cc440af58f2c0179a9 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 9 Feb 2026 09:52:48 -0800 Subject: [PATCH 17/21] feat: prepare package for npm publish (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version 0.2.0 (was 1.0.0 -- too early for stable) - Add files field: only ship dist/, .skills/, patches/ - Add engines: node >= 20 - Add repository, homepage, author metadata - Add prepublishOnly: build + test gate - Move patch-package from postinstall to prepare (don't run for end users) - Add npm publish step to release workflow (requires NPM_TOKEN secret) - Pre-releases publish with --tag next, stable with --tag latest - Update release notes install instructions for npm Closes #174 (once NPM_TOKEN is configured) Written by Cameron ◯ Letta Code "Shipping is a feature." -- Jez Humble --- .github/workflows/release.yml | 27 +++++++++++++++++++++------ package.json | 33 ++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b74fb0a..67c81fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,10 +69,9 @@ jobs: echo "## Install" >> notes.md echo "" >> notes.md echo '```bash' >> notes.md - echo "git clone https://github.com/letta-ai/lettabot.git" >> notes.md - echo "cd lettabot" >> notes.md - echo "git checkout ${CURRENT_TAG}" >> notes.md - echo "npm install && npm run build && npm link" >> notes.md + echo "npx lettabot onboard" >> notes.md + echo "# or" >> notes.md + echo "npm install -g lettabot" >> notes.md echo '```' >> notes.md # Add full changelog link @@ -103,5 +102,21 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # TODO: Ping letta-code agent to write a richer summary once - # letta-code-action supports release events / custom prompts + - name: Publish to npm + if: env.NPM_TOKEN != '' + run: | + CURRENT_TAG=${GITHUB_REF#refs/tags/} + echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc + + # Set version from git tag (strip 'v' prefix) + VERSION=${CURRENT_TAG#v} + npm version "$VERSION" --no-git-tag-version --allow-same-version + + # Determine npm tag (pre-releases get 'next', stable gets 'latest') + if echo "$CURRENT_TAG" | grep -qE '(alpha|beta|rc)'; then + npm publish --tag next --access public + else + npm publish --access public + fi + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/package.json b/package.json index c8ada88..c263b10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lettabot", - "version": "1.0.0", + "version": "0.2.0", "type": "module", "main": "dist/main.js", "bin": { @@ -14,7 +14,8 @@ "setup": "tsx src/setup.ts", "dev": "tsx src/main.ts", "build": "tsc", - "postinstall": "npx patch-package", + "prepare": "npx patch-package || true", + "prepublishOnly": "npm run build && npm run test:run", "start": "node dist/main.js", "test": "vitest", "test:run": "vitest run", @@ -31,15 +32,33 @@ "skills:find": "npx skills find" }, "keywords": [ - "telegram", - "bot", "letta", "ai", - "agent" + "agent", + "chatbot", + "telegram", + "slack", + "whatsapp", + "signal", + "discord", + "multi-agent" ], - "author": "", + "author": "Letta ", "license": "Apache-2.0", - "description": "Multi-channel AI assistant with persistent memory - Telegram, Slack, WhatsApp", + "description": "Multi-channel AI assistant with persistent memory - Telegram, Slack, WhatsApp, Signal, Discord", + "repository": { + "type": "git", + "url": "https://github.com/letta-ai/lettabot.git" + }, + "homepage": "https://github.com/letta-ai/lettabot", + "engines": { + "node": ">=20" + }, + "files": [ + "dist/", + ".skills/", + "patches/" + ], "dependencies": { "@clack/prompts": "^0.11.0", "@hapi/boom": "^10.0.1", From 673f247793c252a27dffa5e49a12f2d054492488 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 9 Feb 2026 10:01:15 -0800 Subject: [PATCH 18/21] feat: custom heartbeat prompt via YAML config or file (#233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: custom heartbeat prompt via YAML config or file Wire up the existing but unused HeartbeatConfig.prompt field so users can customize what the agent sees during heartbeats. Adds three ways to set it: inline YAML (prompt), file-based (promptFile, re-read each tick for live editing), and env var (HEARTBEAT_PROMPT). Also documents the opt-out behavior. Fixes #232 Written by Cameron ◯ Letta Code "The only way to do great work is to love what you do." -- Steve Jobs * test: add coverage for heartbeat prompt resolution Tests buildCustomHeartbeatPrompt and HeartbeatService prompt resolution: - default prompt fallback - inline prompt usage - promptFile loading - inline > promptFile precedence - live reload (file re-read each tick) - graceful fallback on missing file - empty file falls back to default Written by Cameron ◯ Letta Code "The only way to do great work is to love what you do." -- Steve Jobs --- docs/configuration.md | 54 ++++++++++ src/config/types.ts | 4 + src/core/prompts.ts | 26 +++++ src/cron/heartbeat.test.ts | 196 +++++++++++++++++++++++++++++++++++++ src/cron/heartbeat.ts | 23 ++++- src/main.ts | 3 +- 6 files changed, 301 insertions(+), 5 deletions(-) create mode 100644 src/cron/heartbeat.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index 126ea42..1abb9b8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -289,6 +289,43 @@ features: Heartbeats are background tasks where the agent can review pending work. +#### Custom Heartbeat Prompt + +You can customize what the agent is told during heartbeats. The custom text replaces the default body while keeping the silent mode envelope (time, trigger metadata, and messaging instructions). + +Inline in YAML: + +```yaml +features: + heartbeat: + enabled: true + intervalMin: 60 + prompt: "Check your todo list and work on the highest priority item." +``` + +From a file (re-read each tick, so edits take effect without restart): + +```yaml +features: + heartbeat: + enabled: true + intervalMin: 60 + promptFile: ./prompts/heartbeat.md +``` + +Via environment variable: + +```bash +HEARTBEAT_PROMPT="Review recent conversations" npm start +``` + +Precedence: `prompt` (inline YAML) > `HEARTBEAT_PROMPT` (env var) > `promptFile` (file) > built-in default. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `features.heartbeat.prompt` | string | _(none)_ | Custom heartbeat prompt text | +| `features.heartbeat.promptFile` | string | _(none)_ | Path to prompt file (relative to working dir) | + ### Cron Jobs ```yaml @@ -298,6 +335,23 @@ features: Enable scheduled tasks. See [Cron Setup](./cron-setup.md). +### No-Reply (Opt-Out) + +The agent can choose not to respond to a message by sending exactly: + +``` + +``` + +When the bot receives this marker, it suppresses the response and nothing is sent to the channel. This is useful in group chats where the agent shouldn't reply to every message. + +The agent is taught about this behavior in two places: + +- **System prompt**: A "Choosing Not to Reply" section explains when to use it (messages not directed at the agent, simple acknowledgments, conversations between other users, etc.) +- **Message envelope**: Group messages include a hint reminding the agent of the `` option. DMs do not include this hint. + +The bot also handles this gracefully during streaming -- it holds back partial output while the response could still become ``, so users never see a partial match leak through. + ## Polling Configuration Background polling for integrations like Gmail. Runs independently of agent cron jobs. diff --git a/src/config/types.ts b/src/config/types.ts index b14ecad..f9b828f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -31,6 +31,8 @@ export interface AgentConfig { heartbeat?: { enabled: boolean; intervalMin?: number; + prompt?: string; // Custom heartbeat prompt (replaces default body) + promptFile?: string; // Path to prompt file (re-read each tick for live editing) }; maxToolCalls?: number; }; @@ -81,6 +83,8 @@ export interface LettaBotConfig { heartbeat?: { enabled: boolean; intervalMin?: number; + prompt?: string; // Custom heartbeat prompt (replaces default body) + promptFile?: string; // Path to prompt file (re-read each tick for live editing) }; inlineImages?: boolean; // Send images directly to the LLM (default: true). Set false to only send file paths. maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100) diff --git a/src/core/prompts.ts b/src/core/prompts.ts index 21b4db4..922a6f9 100644 --- a/src/core/prompts.ts +++ b/src/core/prompts.ts @@ -53,6 +53,32 @@ If you have nothing to do → just end your turn (no output needed) `.trim(); } +/** + * Custom heartbeat prompt - wraps user-provided text with silent mode envelope + */ +export function buildCustomHeartbeatPrompt( + customPrompt: string, + time: string, + timezone: string, + intervalMinutes: number +): string { + return ` +${SILENT_MODE_PREFIX} + +TRIGGER: Scheduled heartbeat +TIME: ${time} (${timezone}) +NEXT HEARTBEAT: in ${intervalMinutes} minutes + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +YOUR TEXT OUTPUT IS PRIVATE - only you can see it. +To actually contact your human, run: + lettabot-message send --text "Your message here" + +${customPrompt} +`.trim(); +} + /** * Cron job prompt (silent mode) - for background scheduled tasks */ diff --git a/src/cron/heartbeat.test.ts b/src/cron/heartbeat.test.ts new file mode 100644 index 0000000..df93967 --- /dev/null +++ b/src/cron/heartbeat.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { writeFileSync, mkdirSync, unlinkSync, rmSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { HeartbeatService, type HeartbeatConfig } from './heartbeat.js'; +import { buildCustomHeartbeatPrompt, SILENT_MODE_PREFIX } from '../core/prompts.js'; +import type { AgentSession } from '../core/interfaces.js'; + +// ── buildCustomHeartbeatPrompt ────────────────────────────────────────── + +describe('buildCustomHeartbeatPrompt', () => { + it('includes silent mode prefix', () => { + const result = buildCustomHeartbeatPrompt('Do something', '12:00 PM', 'UTC', 60); + expect(result).toContain(SILENT_MODE_PREFIX); + }); + + it('includes time and interval metadata', () => { + const result = buildCustomHeartbeatPrompt('Do something', '3:30 PM', 'America/Los_Angeles', 45); + expect(result).toContain('TIME: 3:30 PM (America/Los_Angeles)'); + expect(result).toContain('NEXT HEARTBEAT: in 45 minutes'); + }); + + it('includes custom prompt text in body', () => { + const result = buildCustomHeartbeatPrompt('Check your todo list.', '12:00 PM', 'UTC', 60); + expect(result).toContain('Check your todo list.'); + }); + + it('includes lettabot-message instructions', () => { + const result = buildCustomHeartbeatPrompt('Custom task', '12:00 PM', 'UTC', 60); + expect(result).toContain('lettabot-message send --text'); + }); + + it('does NOT include default body text', () => { + const result = buildCustomHeartbeatPrompt('Custom task', '12:00 PM', 'UTC', 60); + expect(result).not.toContain('This is your time'); + expect(result).not.toContain('Pursue curiosities'); + }); +}); + +// ── HeartbeatService prompt resolution ────────────────────────────────── + +function createMockBot(): AgentSession { + return { + registerChannel: vi.fn(), + setGroupBatcher: vi.fn(), + processGroupBatch: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + sendToAgent: vi.fn().mockResolvedValue('ok'), + deliverToChannel: vi.fn(), + getStatus: vi.fn().mockReturnValue({ agentId: 'test', channels: [] }), + setAgentId: vi.fn(), + reset: vi.fn(), + getLastMessageTarget: vi.fn().mockReturnValue(null), + getLastUserMessageTime: vi.fn().mockReturnValue(null), + }; +} + +function createConfig(overrides: Partial = {}): HeartbeatConfig { + return { + enabled: true, + intervalMinutes: 30, + workingDir: tmpdir(), + ...overrides, + }; +} + +describe('HeartbeatService prompt resolution', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = resolve(tmpdir(), `heartbeat-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('uses default prompt when no custom prompt is set', async () => { + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ workingDir: tmpDir })); + + await service.trigger(); + + const sentMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + expect(sentMessage).toContain('This is your time'); + expect(sentMessage).toContain(SILENT_MODE_PREFIX); + }); + + it('uses inline prompt when set', async () => { + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ + workingDir: tmpDir, + prompt: 'Check your todo list and work on the top item.', + })); + + await service.trigger(); + + const sentMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + expect(sentMessage).toContain('Check your todo list and work on the top item.'); + expect(sentMessage).not.toContain('This is your time'); + expect(sentMessage).toContain(SILENT_MODE_PREFIX); + }); + + it('uses promptFile when no inline prompt is set', async () => { + const promptPath = resolve(tmpDir, 'heartbeat-prompt.txt'); + writeFileSync(promptPath, 'Research quantum computing papers.'); + + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ + workingDir: tmpDir, + promptFile: 'heartbeat-prompt.txt', + })); + + await service.trigger(); + + const sentMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + expect(sentMessage).toContain('Research quantum computing papers.'); + expect(sentMessage).not.toContain('This is your time'); + }); + + it('inline prompt takes precedence over promptFile', async () => { + const promptPath = resolve(tmpDir, 'heartbeat-prompt.txt'); + writeFileSync(promptPath, 'FROM FILE'); + + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ + workingDir: tmpDir, + prompt: 'FROM INLINE', + promptFile: 'heartbeat-prompt.txt', + })); + + await service.trigger(); + + const sentMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + expect(sentMessage).toContain('FROM INLINE'); + expect(sentMessage).not.toContain('FROM FILE'); + }); + + it('re-reads promptFile on each tick (live reload)', async () => { + const promptPath = resolve(tmpDir, 'heartbeat-prompt.txt'); + writeFileSync(promptPath, 'Version 1'); + + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ + workingDir: tmpDir, + promptFile: 'heartbeat-prompt.txt', + })); + + // First tick + await service.trigger(); + const firstMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + expect(firstMessage).toContain('Version 1'); + + // Update file + writeFileSync(promptPath, 'Version 2'); + + // Second tick + await service.trigger(); + const secondMessage = (bot.sendToAgent as ReturnType).mock.calls[1][0] as string; + expect(secondMessage).toContain('Version 2'); + expect(secondMessage).not.toContain('Version 1'); + }); + + it('falls back to default when promptFile does not exist', async () => { + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ + workingDir: tmpDir, + promptFile: 'nonexistent.txt', + })); + + await service.trigger(); + + const sentMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + // Should fall back to default since file doesn't exist + expect(sentMessage).toContain('This is your time'); + }); + + it('falls back to default when promptFile is empty', async () => { + const promptPath = resolve(tmpDir, 'empty.txt'); + writeFileSync(promptPath, ' \n '); + + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ + workingDir: tmpDir, + promptFile: 'empty.txt', + })); + + await service.trigger(); + + const sentMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + // Empty/whitespace file should fall back to default + expect(sentMessage).toContain('This is your time'); + }); +}); diff --git a/src/cron/heartbeat.ts b/src/cron/heartbeat.ts index b9e6845..7692224 100644 --- a/src/cron/heartbeat.ts +++ b/src/cron/heartbeat.ts @@ -7,11 +7,11 @@ * The agent must use `lettabot-message` CLI via Bash to contact the user. */ -import { appendFileSync, mkdirSync } from 'node:fs'; +import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import type { AgentSession } from '../core/interfaces.js'; import type { TriggerContext } from '../core/types.js'; -import { buildHeartbeatPrompt } from '../core/prompts.js'; +import { buildHeartbeatPrompt, buildCustomHeartbeatPrompt } from '../core/prompts.js'; import { getDataDir } from '../utils/paths.js'; @@ -46,6 +46,9 @@ export interface HeartbeatConfig { // Custom heartbeat prompt (optional) prompt?: string; + // Path to prompt file (re-read each tick for live editing) + promptFile?: string; + // Target for delivery (optional - defaults to last messaged) target?: { channel: string; @@ -168,8 +171,20 @@ export class HeartbeatService { }; try { - // Build the heartbeat message with clear SILENT MODE indication - const message = buildHeartbeatPrompt(formattedTime, timezone, this.config.intervalMinutes); + // Resolve custom prompt: inline config > promptFile (re-read each tick) > default + let customPrompt = this.config.prompt; + if (!customPrompt && this.config.promptFile) { + try { + const promptPath = resolve(this.config.workingDir, this.config.promptFile); + customPrompt = readFileSync(promptPath, 'utf-8').trim(); + } catch (err) { + console.error(`[Heartbeat] Failed to read promptFile "${this.config.promptFile}":`, err); + } + } + + const message = customPrompt + ? buildCustomHeartbeatPrompt(customPrompt, formattedTime, timezone, this.config.intervalMinutes) + : buildHeartbeatPrompt(formattedTime, timezone, this.config.intervalMinutes); console.log(`[Heartbeat] Sending prompt (SILENT MODE):\n${'─'.repeat(50)}\n${message}\n${'─'.repeat(50)}\n`); diff --git a/src/main.ts b/src/main.ts index 5933231..6e40090 100644 --- a/src/main.ts +++ b/src/main.ts @@ -545,7 +545,8 @@ async function main() { const heartbeatService = new HeartbeatService(bot, { enabled: heartbeatConfig?.enabled ?? false, intervalMinutes: heartbeatConfig?.intervalMin ?? 30, - prompt: process.env.HEARTBEAT_PROMPT, + prompt: heartbeatConfig?.prompt || process.env.HEARTBEAT_PROMPT, + promptFile: heartbeatConfig?.promptFile, workingDir: globalConfig.workingDir, target: parseHeartbeatTarget(process.env.HEARTBEAT_TARGET), }); From 16b5e5b7b73a9e5b3b0154101ba0cbae14ef02b8 Mon Sep 17 00:00:00 2001 From: Jason Carreira Date: Mon, 9 Feb 2026 13:16:10 -0500 Subject: [PATCH 19/21] Add lettabot-history CLI (#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add lettabot-history CLI * Document and test lettabot-history * Validate lettabot-history limit * fix: address review feedback on history CLI - Extract shared loadLastTarget into cli/shared.ts (was duplicated in message.ts, react.ts, history-core.ts) - Clamp --limit to platform maximums (Discord: 100, Slack: 1000) - Fix Discord author formatting: use globalName/username instead of deprecated discriminator - Add Slack fetch test Written by Cameron ◯ Letta Code "You miss 100% of the shots you don't take." -- Wayne Gretzky -- Michael Scott --------- Co-authored-by: Jason Carreira Co-authored-by: Cameron --- README.md | 12 +++ docs/README.md | 1 + docs/cli-tools.md | 36 +++++++++ package.json | 1 + src/cli/history-core.test.ts | 141 +++++++++++++++++++++++++++++++++ src/cli/history-core.ts | 146 +++++++++++++++++++++++++++++++++++ src/cli/history.ts | 100 ++++++++++++++++++++++++ src/cli/message.ts | 29 +------ src/cli/react.ts | 29 +------ src/cli/shared.ts | 28 +++++++ 10 files changed, 467 insertions(+), 56 deletions(-) create mode 100644 docs/cli-tools.md create mode 100644 src/cli/history-core.test.ts create mode 100644 src/cli/history-core.ts create mode 100644 src/cli/history.ts create mode 100644 src/cli/shared.ts diff --git a/README.md b/README.md index f224009..3390dfc 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,18 @@ The agent sees a clear `[SILENT MODE]` banner when triggered by heartbeats/cron, If your agent isn't sending messages during heartbeats, check the [ADE](https://app.letta.com) to see what the agent is doing and whether it's attempting to use `lettabot-message`. +## Agent CLI Tools + +LettaBot includes small CLIs the agent can invoke via Bash (or you can run directly): + +```bash +lettabot-message send --text "Hello from a background task" +lettabot-react add --emoji :eyes: --channel discord --chat 123 --message 456 +lettabot-history fetch --limit 25 --channel discord --chat 123456789 +``` + +See [CLI Tools](docs/cli-tools.md) for details and limitations. + ## Connect to Letta Code Any LettaBot agent can also be directly chatted with through [Letta Code](https://github.com/letta-ai/letta-code). Use the `/status` command to find your `agent_id`, and run: ```sh diff --git a/docs/README.md b/docs/README.md index 0a532cf..f7a262b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ LettaBot is a multi-channel AI assistant powered by [Letta](https://letta.com) t - [Self-Hosted Setup](./selfhosted-setup.md) - Run with your own Letta server - [Configuration Reference](./configuration.md) - All config options - [Commands Reference](./commands.md) - Bot commands reference +- [CLI Tools](./cli-tools.md) - Agent/operator CLI tools - [Scheduling Tasks](./cron-setup.md) - Cron jobs and heartbeats - [Gmail Pub/Sub](./gmail-pubsub.md) - Email notifications integration - [Railway Deployment](./railway-deploy.md) - Deploy to Railway diff --git a/docs/cli-tools.md b/docs/cli-tools.md new file mode 100644 index 0000000..5b06196 --- /dev/null +++ b/docs/cli-tools.md @@ -0,0 +1,36 @@ +# CLI Tools + +LettaBot ships with a few small CLIs that the agent can invoke via Bash, or you can run manually. +They use the same config/credentials as the bot server. + +## lettabot-message + +Send a message to the most recent chat, or target a specific channel/chat. + +```bash +lettabot-message send --text "Hello from a background task" +lettabot-message send --text "Hello" --channel slack --chat C123456 +``` + +## lettabot-react + +Add a reaction to a message (emoji can be unicode or :alias:). + +```bash +lettabot-react add --emoji :eyes: --channel discord --chat 123 --message 456 +lettabot-react add --emoji "👍" +``` + +## lettabot-history + +Fetch recent messages from supported channels (Discord, Slack). + +```bash +lettabot-history fetch --limit 25 --channel discord --chat 123456789 +lettabot-history fetch --limit 10 --channel slack --chat C123456 --before 1712345678.000100 +``` + +Notes: +- History fetch is not supported by the Telegram Bot API, Signal, or WhatsApp. +- If you omit `--channel` or `--chat`, the CLI falls back to the last message target stored in `lettabot-agent.json`. +- You need the channel-specific bot token set (`DISCORD_BOT_TOKEN` or `SLACK_BOT_TOKEN`). diff --git a/package.json b/package.json index c263b10..5538981 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "lettabot-schedule": "./dist/cron/cli.js", "lettabot-message": "./dist/cli/message.js", "lettabot-react": "./dist/cli/react.js", + "lettabot-history": "./dist/cli/history.js", "lettabot-channels": "./dist/cli/channels.js" }, "scripts": { diff --git a/src/cli/history-core.test.ts b/src/cli/history-core.test.ts new file mode 100644 index 0000000..8040cc8 --- /dev/null +++ b/src/cli/history-core.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fetchDiscordHistory, fetchHistory, fetchSlackHistory, isValidLimit, parseFetchArgs } from './history-core.js'; +import { loadLastTarget } from './shared.js'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const ORIGINAL_ENV = { ...process.env }; + +afterEach(() => { + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + Object.assign(process.env, ORIGINAL_ENV); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('parseFetchArgs', () => { + it('parses fetch args with flags', () => { + const parsed = parseFetchArgs([ + '--limit', '25', + '--channel', 'discord', + '--chat', '123', + '--before', '456', + ]); + + expect(parsed).toEqual({ + channel: 'discord', + chatId: '123', + before: '456', + limit: 25, + }); + }); +}); + +describe('isValidLimit', () => { + it('accepts positive integers only', () => { + expect(isValidLimit(1)).toBe(true); + expect(isValidLimit(50)).toBe(true); + expect(isValidLimit(0)).toBe(false); + expect(isValidLimit(-1)).toBe(false); + expect(isValidLimit(1.5)).toBe(false); + expect(isValidLimit(Number.NaN)).toBe(false); + }); +}); + +describe('loadLastTarget', () => { + it('loads the last message target from the store path', () => { + const dir = mkdtempSync(join(tmpdir(), 'lettabot-history-')); + const storePath = join(dir, 'lettabot-agent.json'); + writeFileSync( + storePath, + JSON.stringify({ lastMessageTarget: { channel: 'slack', chatId: 'C123' } }), + 'utf-8' + ); + + const target = loadLastTarget(storePath); + expect(target).toEqual({ channel: 'slack', chatId: 'C123' }); + + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe('fetchDiscordHistory', () => { + it('formats Discord history responses', async () => { + process.env.DISCORD_BOT_TOKEN = 'test-token'; + + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ([ + { + id: '111', + content: 'Hello', + author: { username: 'alice', globalName: 'Alice' }, + timestamp: '2026-01-01T00:00:00Z', + }, + ]), + }); + vi.stubGlobal('fetch', fetchSpy); + + const output = await fetchDiscordHistory('999', 10, '888'); + const parsed = JSON.parse(output) as { + count: number; + messages: Array<{ messageId: string; author: string; content: string; timestamp?: string }>; + }; + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://discord.com/api/v10/channels/999/messages?limit=10&before=888', + expect.objectContaining({ method: 'GET' }) + ); + expect(parsed.count).toBe(1); + expect(parsed.messages[0]).toEqual({ + messageId: '111', + author: 'Alice', + content: 'Hello', + timestamp: '2026-01-01T00:00:00Z', + }); + }); +}); + +describe('fetchSlackHistory', () => { + it('formats Slack history responses', async () => { + process.env.SLACK_BOT_TOKEN = 'test-token'; + + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + messages: [ + { + ts: '1704067200.000100', + text: 'Hello from Slack', + user: 'U123456', + }, + ], + }), + }); + vi.stubGlobal('fetch', fetchSpy); + + const output = await fetchSlackHistory('C999', 10); + const parsed = JSON.parse(output); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://slack.com/api/conversations.history', + expect.objectContaining({ method: 'POST' }) + ); + expect(parsed.count).toBe(1); + expect(parsed.messages[0].author).toBe('U123456'); + expect(parsed.messages[0].content).toBe('Hello from Slack'); + expect(parsed.messages[0].messageId).toBe('1704067200.000100'); + // Verify ts -> ISO conversion + expect(parsed.messages[0].timestamp).toBeDefined(); + }); +}); + +describe('fetchHistory', () => { + it('rejects unsupported channels', async () => { + await expect(fetchHistory('unknown', '1', 1)).rejects.toThrow('Unknown channel'); + }); +}); diff --git a/src/cli/history-core.ts b/src/cli/history-core.ts new file mode 100644 index 0000000..671ee69 --- /dev/null +++ b/src/cli/history-core.ts @@ -0,0 +1,146 @@ +import type { LastTarget } from './shared.js'; + +export const DEFAULT_LIMIT = 50; + +export function isValidLimit(limit: number): boolean { + return Number.isInteger(limit) && limit > 0; +} + +export function parseFetchArgs(args: string[]): { + channel?: string; + chatId?: string; + before?: string; + limit: number; +} { + let channel = ''; + let chatId = ''; + let before = ''; + let limit = DEFAULT_LIMIT; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const next = args[i + 1]; + + if ((arg === '--limit' || arg === '-l') && next) { + limit = Number(next); + i++; + } else if ((arg === '--channel' || arg === '-c') && next) { + channel = next; + i++; + } else if ((arg === '--chat' || arg === '--to') && next) { + chatId = next; + i++; + } else if ((arg === '--before' || arg === '-b') && next) { + before = next; + i++; + } + } + + return { + channel: channel || undefined, + chatId: chatId || undefined, + before: before || undefined, + limit, + }; +} + +export async function fetchDiscordHistory(chatId: string, limit: number, before?: string): Promise { + limit = Math.min(limit, 100); + const token = process.env.DISCORD_BOT_TOKEN; + if (!token) { + throw new Error('DISCORD_BOT_TOKEN not set'); + } + + const params = new URLSearchParams({ limit: String(limit) }); + if (before) params.set('before', before); + + const response = await fetch(`https://discord.com/api/v10/channels/${chatId}/messages?${params.toString()}`, { + method: 'GET', + headers: { + 'Authorization': `Bot ${token}`, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Discord API error: ${error}`); + } + + const messages = await response.json() as Array<{ + id: string; + content: string; + author?: { username?: string; globalName?: string }; + timestamp?: string; + }>; + + const output = { + count: messages.length, + messages: messages.map((msg) => ({ + messageId: msg.id, + author: msg.author?.globalName || msg.author?.username || 'unknown', + content: msg.content || '', + timestamp: msg.timestamp, + })), + }; + + return JSON.stringify(output, null, 2); +} + +export async function fetchSlackHistory(chatId: string, limit: number, before?: string): Promise { + limit = Math.min(limit, 1000); + const token = process.env.SLACK_BOT_TOKEN; + if (!token) { + throw new Error('SLACK_BOT_TOKEN not set'); + } + + const response = await fetch('https://slack.com/api/conversations.history', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + channel: chatId, + limit, + ...(before ? { latest: before, inclusive: false } : {}), + }), + }); + + const result = await response.json() as { + ok: boolean; + error?: string; + messages?: Array<{ ts?: string; text?: string; user?: string; bot_id?: string }>; + }; + if (!result.ok) { + throw new Error(`Slack API error: ${result.error || 'unknown error'}`); + } + + const output = { + count: result.messages?.length || 0, + messages: (result.messages || []).map((msg) => ({ + messageId: msg.ts, + author: msg.user || msg.bot_id || 'unknown', + content: msg.text || '', + timestamp: msg.ts ? new Date(Number(msg.ts) * 1000).toISOString() : undefined, + })), + }; + + return JSON.stringify(output, null, 2); +} + +export async function fetchHistory(channel: string, chatId: string, limit: number, before?: string): Promise { + switch (channel.toLowerCase()) { + case 'discord': + return fetchDiscordHistory(chatId, limit, before); + case 'slack': + return fetchSlackHistory(chatId, limit, before); + case 'telegram': + throw new Error('Telegram history fetch is not supported by the Bot API'); + case 'signal': + throw new Error('Signal history fetch is not supported'); + case 'whatsapp': + throw new Error('WhatsApp history fetch is not supported'); + default: + throw new Error(`Unknown channel: ${channel}. Supported: discord, slack`); + } +} diff --git a/src/cli/history.ts b/src/cli/history.ts new file mode 100644 index 0000000..4d81ae8 --- /dev/null +++ b/src/cli/history.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * lettabot-history - Fetch message history from channels + * + * Usage: + * lettabot-history fetch --limit 50 [--channel discord] [--chat 123456] [--before 789] + */ + +// Config loaded from lettabot.yaml +import { loadConfig, applyConfigToEnv } from '../config/index.js'; +const config = loadConfig(); +applyConfigToEnv(config); +import { fetchHistory, isValidLimit, parseFetchArgs } from './history-core.js'; +import { loadLastTarget, STORE_PATH } from './shared.js'; + +async function fetchCommand(args: string[]): Promise { + const parsed = parseFetchArgs(args); + let channel = parsed.channel || ''; + let chatId = parsed.chatId || ''; + const before = parsed.before || ''; + const limit = parsed.limit; + + if (!isValidLimit(limit)) { + console.error('Error: --limit must be a positive integer'); + console.error('Usage: lettabot-history fetch --limit 50 [--channel discord] [--chat 123456] [--before 789]'); + process.exit(1); + } + + if (!channel || !chatId) { + const lastTarget = loadLastTarget(STORE_PATH); + if (lastTarget) { + channel = channel || lastTarget.channel; + chatId = chatId || lastTarget.chatId; + } + } + + if (!channel) { + console.error('Error: --channel is required (no default available)'); + console.error('Specify: --channel discord|slack'); + process.exit(1); + } + + if (!chatId) { + console.error('Error: --chat is required (no default available)'); + console.error('Specify: --chat '); + process.exit(1); + } + + try { + const output = await fetchHistory(channel, chatId, limit, before || undefined); + console.log(output); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} + +function showHelp(): void { + console.log(` +lettabot-history - Fetch message history from channels + +Commands: + fetch [options] Fetch recent messages + +Fetch options: + --limit, -l Max messages (default: 50) + --channel, -c Channel: discord, slack + --chat, --to Chat/conversation ID (default: last messaged) + --before, -b Fetch messages before this message ID + +Examples: + lettabot-history fetch --limit 50 + lettabot-history fetch --limit 50 --channel discord --chat 123456789 + lettabot-history fetch --limit 50 --before 987654321 +`); +} + +async function main(): Promise { + const args = process.argv.slice(2); + const command = args[0]; + + if (!command || command === 'help' || command === '--help' || command === '-h') { + showHelp(); + return; + } + + if (command === 'fetch') { + await fetchCommand(args.slice(1)); + return; + } + + console.error(`Unknown command: ${command}`); + showHelp(); + process.exit(1); +} + +main().catch((err) => { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/src/cli/message.ts b/src/cli/message.ts index 9d39636..cf55ed4 100644 --- a/src/cli/message.ts +++ b/src/cli/message.ts @@ -14,35 +14,8 @@ import { loadConfig, applyConfigToEnv } from '../config/index.js'; const config = loadConfig(); applyConfigToEnv(config); -import { resolve } from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; -import { getDataDir } from '../utils/paths.js'; - -// Types -interface LastTarget { - channel: string; - chatId: string; -} - -interface AgentStore { - agentId?: string; - lastMessageTarget?: LastTarget; // Note: field is "lastMessageTarget" not "lastTarget" -} - -// Store path (same location as bot uses) -const STORE_PATH = resolve(getDataDir(), 'lettabot-agent.json'); - -function loadLastTarget(): LastTarget | null { - try { - if (existsSync(STORE_PATH)) { - const store: AgentStore = JSON.parse(readFileSync(STORE_PATH, 'utf-8')); - return store.lastMessageTarget || null; - } - } catch { - // Ignore - } - return null; -} +import { loadLastTarget, STORE_PATH } from './shared.js'; // Channel senders async function sendTelegram(chatId: string, text: string): Promise { diff --git a/src/cli/react.ts b/src/cli/react.ts index 30015a8..a5e94e9 100644 --- a/src/cli/react.ts +++ b/src/cli/react.ts @@ -13,34 +13,7 @@ import { loadConfig, applyConfigToEnv } from '../config/index.js'; const config = loadConfig(); applyConfigToEnv(config); -import { resolve } from 'node:path'; -import { existsSync, readFileSync } from 'node:fs'; -import { getDataDir } from '../utils/paths.js'; - -interface LastTarget { - channel: string; - chatId: string; - messageId?: string; -} - -interface AgentStore { - agentId?: string; - lastMessageTarget?: LastTarget; -} - -const STORE_PATH = resolve(getDataDir(), 'lettabot-agent.json'); - -function loadLastTarget(): LastTarget | null { - try { - if (existsSync(STORE_PATH)) { - const store: AgentStore = JSON.parse(readFileSync(STORE_PATH, 'utf-8')); - return store.lastMessageTarget || null; - } - } catch { - // Ignore - } - return null; -} +import { loadLastTarget, STORE_PATH } from './shared.js'; const EMOJI_ALIAS_TO_UNICODE: Record = { eyes: '👀', diff --git a/src/cli/shared.ts b/src/cli/shared.ts new file mode 100644 index 0000000..87cc8af --- /dev/null +++ b/src/cli/shared.ts @@ -0,0 +1,28 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { getDataDir } from '../utils/paths.js'; + +export interface LastTarget { + channel: string; + chatId: string; + messageId?: string; +} + +interface AgentStore { + agentId?: string; + lastMessageTarget?: LastTarget; +} + +export const STORE_PATH = resolve(getDataDir(), 'lettabot-agent.json'); + +export function loadLastTarget(storePath: string = STORE_PATH): LastTarget | null { + try { + if (existsSync(storePath)) { + const store: AgentStore = JSON.parse(readFileSync(storePath, 'utf-8')); + return store.lastMessageTarget || null; + } + } catch { + // Ignore + } + return null; +} From b8a248b0fb16ed4833d0a295124ae936c635cee8 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 9 Feb 2026 10:32:51 -0800 Subject: [PATCH 20/21] fix: CLI tools use Store class for v2 format compatibility (#235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shared.ts was parsing lettabot-agent.json as v1 format directly, returning null for v2 stores. Now uses the Store class which handles v1/v2 transparently. Affects lettabot-message, lettabot-react, and lettabot-history. Written by Cameron ◯ Letta Code "Simplicity is the ultimate sophistication." -- Leonardo da Vinci --- src/cli/history-core.test.ts | 22 ++-------------------- src/cli/history.ts | 4 ++-- src/cli/message.ts | 2 +- src/cli/react.ts | 2 +- src/cli/shared.ts | 28 ++++++++-------------------- 5 files changed, 14 insertions(+), 44 deletions(-) diff --git a/src/cli/history-core.test.ts b/src/cli/history-core.test.ts index 8040cc8..7fb842d 100644 --- a/src/cli/history-core.test.ts +++ b/src/cli/history-core.test.ts @@ -1,9 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { fetchDiscordHistory, fetchHistory, fetchSlackHistory, isValidLimit, parseFetchArgs } from './history-core.js'; -import { loadLastTarget } from './shared.js'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; const ORIGINAL_ENV = { ...process.env }; @@ -45,22 +41,8 @@ describe('isValidLimit', () => { }); }); -describe('loadLastTarget', () => { - it('loads the last message target from the store path', () => { - const dir = mkdtempSync(join(tmpdir(), 'lettabot-history-')); - const storePath = join(dir, 'lettabot-agent.json'); - writeFileSync( - storePath, - JSON.stringify({ lastMessageTarget: { channel: 'slack', chatId: 'C123' } }), - 'utf-8' - ); - - const target = loadLastTarget(storePath); - expect(target).toEqual({ channel: 'slack', chatId: 'C123' }); - - rmSync(dir, { recursive: true, force: true }); - }); -}); +// loadLastTarget is now backed by the Store class (handles v1/v2 transparently). +// Store-level tests in src/core/store.test.ts cover lastMessageTarget persistence. describe('fetchDiscordHistory', () => { it('formats Discord history responses', async () => { diff --git a/src/cli/history.ts b/src/cli/history.ts index 4d81ae8..98a1f57 100644 --- a/src/cli/history.ts +++ b/src/cli/history.ts @@ -11,7 +11,7 @@ import { loadConfig, applyConfigToEnv } from '../config/index.js'; const config = loadConfig(); applyConfigToEnv(config); import { fetchHistory, isValidLimit, parseFetchArgs } from './history-core.js'; -import { loadLastTarget, STORE_PATH } from './shared.js'; +import { loadLastTarget } from './shared.js'; async function fetchCommand(args: string[]): Promise { const parsed = parseFetchArgs(args); @@ -27,7 +27,7 @@ async function fetchCommand(args: string[]): Promise { } if (!channel || !chatId) { - const lastTarget = loadLastTarget(STORE_PATH); + const lastTarget = loadLastTarget(); if (lastTarget) { channel = channel || lastTarget.channel; chatId = chatId || lastTarget.chatId; diff --git a/src/cli/message.ts b/src/cli/message.ts index cf55ed4..2cf6faf 100644 --- a/src/cli/message.ts +++ b/src/cli/message.ts @@ -15,7 +15,7 @@ import { loadConfig, applyConfigToEnv } from '../config/index.js'; const config = loadConfig(); applyConfigToEnv(config); import { existsSync, readFileSync } from 'node:fs'; -import { loadLastTarget, STORE_PATH } from './shared.js'; +import { loadLastTarget } from './shared.js'; // Channel senders async function sendTelegram(chatId: string, text: string): Promise { diff --git a/src/cli/react.ts b/src/cli/react.ts index a5e94e9..b42882d 100644 --- a/src/cli/react.ts +++ b/src/cli/react.ts @@ -13,7 +13,7 @@ import { loadConfig, applyConfigToEnv } from '../config/index.js'; const config = loadConfig(); applyConfigToEnv(config); -import { loadLastTarget, STORE_PATH } from './shared.js'; +import { loadLastTarget } from './shared.js'; const EMOJI_ALIAS_TO_UNICODE: Record = { eyes: '👀', diff --git a/src/cli/shared.ts b/src/cli/shared.ts index 87cc8af..e3393b6 100644 --- a/src/cli/shared.ts +++ b/src/cli/shared.ts @@ -1,6 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { getDataDir } from '../utils/paths.js'; +import { Store } from '../core/store.js'; export interface LastTarget { channel: string; @@ -8,21 +6,11 @@ export interface LastTarget { messageId?: string; } -interface AgentStore { - agentId?: string; - lastMessageTarget?: LastTarget; -} - -export const STORE_PATH = resolve(getDataDir(), 'lettabot-agent.json'); - -export function loadLastTarget(storePath: string = STORE_PATH): LastTarget | null { - try { - if (existsSync(storePath)) { - const store: AgentStore = JSON.parse(readFileSync(storePath, 'utf-8')); - return store.lastMessageTarget || null; - } - } catch { - // Ignore - } - return null; +/** + * Load the last message target from the agent store. + * Uses Store class which handles both v1 and v2 formats transparently. + */ +export function loadLastTarget(): LastTarget | null { + const store = new Store('lettabot-agent.json'); + return store.lastMessageTarget || null; } From 1a381757bb7c9618f52268276fc37b20e3ace277 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 9 Feb 2026 10:47:54 -0800 Subject: [PATCH 21/21] fix: Telegram messages truncated when MarkdownV2 edit fails (#236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit editMessage() had no fallback for MarkdownV2 failures (unlike sendMessage which already falls back to plain text). When the agent generates markdown tables or other complex formatting, the MarkdownV2 conversion can fail mid-stream, silently leaving the user with whatever the last successful streaming edit was -- a truncated message. Three fixes: - editMessage() now mirrors sendMessage's try/catch with plain-text fallback - Final send retry no longer guarded by !messageId, so failed edits fall back to sending a new complete message - Streaming edit errors are logged instead of silently swallowed Written by Cameron ◯ Letta Code "If you want to go fast, go alone. If you want to go far, go together." - African Proverb --- src/channels/telegram.ts | 10 ++++++++-- src/core/bot.ts | 24 +++++++++++++----------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index a676fac..2500829 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -501,8 +501,14 @@ export class TelegramAdapter implements ChannelAdapter { async editMessage(chatId: string, messageId: string, text: string): Promise { const { markdownToTelegramV2 } = await import('./telegram-format.js'); - const formatted = await markdownToTelegramV2(text); - await this.bot.api.editMessageText(chatId, Number(messageId), formatted, { parse_mode: 'MarkdownV2' }); + try { + const formatted = await markdownToTelegramV2(text); + await this.bot.api.editMessageText(chatId, Number(messageId), formatted, { parse_mode: 'MarkdownV2' }); + } catch (e) { + // If MarkdownV2 fails, fall back to plain text (mirrors sendMessage fallback) + console.warn('[Telegram] MarkdownV2 edit failed, falling back to raw text:', e); + await this.bot.api.editMessageText(chatId, Number(messageId), text); + } } async addReaction(chatId: string, messageId: string, emoji: string): Promise { diff --git a/src/core/bot.ts b/src/core/bot.ts index 216e674..2f102e7 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -633,8 +633,10 @@ export class LettaBot implements AgentSession { messageId = result.messageId; sentAnyMessage = true; } - } catch { - // Ignore edit errors (e.g. rate limits) + } catch (editErr) { + // Log but don't fail - streaming edits are best-effort + // (e.g. rate limits, MarkdownV2 formatting issues mid-stream) + console.warn('[Bot] Streaming edit failed:', editErr instanceof Error ? editErr.message : editErr); } lastUpdate = Date.now(); } @@ -729,15 +731,15 @@ export class LettaBot implements AgentSession { console.log(`[Bot] Sent: "${preview}"`); } catch (sendError) { console.error('[Bot] Error sending response:', sendError); - if (!messageId) { - try { - await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId }); - sentAnyMessage = true; - // Reset recovery counter on successful response - this.store.resetRecoveryAttempts(); - } catch (retryError) { - console.error('[Bot] Retry send also failed:', retryError); - } + // If edit failed (messageId exists), send the complete response as a new message + // so the user isn't left with a truncated streaming edit + try { + await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId }); + sentAnyMessage = true; + // Reset recovery counter on successful response + this.store.resetRecoveryAttempts(); + } catch (retryError) { + console.error('[Bot] Retry send also failed:', retryError); } } }