diff --git a/src/channels/discord.ts b/src/channels/discord.ts index a6ad40a..1abc7cf 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -312,6 +312,7 @@ Ask the bot owner to approve with: wasMentioned, isListeningMode, attachments, + formatterHints: this.getFormatterHints(), }); } }); @@ -414,6 +415,14 @@ Ask the bot owner to approve with: return this.config.dmPolicy || 'pairing'; } + getFormatterHints() { + return { + supportsReactions: true, + supportsFiles: true, + formatHint: 'Discord markdown: **bold** *italic* `code` [links](url) ```code blocks``` — supports headers', + }; + } + supportsEditing(): boolean { return this.config.streaming ?? false; } @@ -477,6 +486,7 @@ Ask the bot owner to approve with: messageId: message.id, action, }, + formatterHints: this.getFormatterHints(), }).catch((err) => { log.error('Error handling reaction:', err); }); diff --git a/src/channels/signal.ts b/src/channels/signal.ts index b81ef84..dd6c406 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -339,6 +339,14 @@ This code expires in 1 hour.`; return this.config.dmPolicy || 'pairing'; } + getFormatterHints() { + return { + supportsReactions: true, + supportsFiles: false, + formatHint: 'ONLY: *bold* _italic_ `code` — NO: headers, code fences, links, quotes, tables', + }; + } + supportsEditing(): boolean { return false; } @@ -868,6 +876,7 @@ This code expires in 1 hour.`; wasMentioned, isListeningMode, attachments: collectedAttachments.length > 0 ? collectedAttachments : undefined, + formatterHints: this.getFormatterHints(), }; this.onMessage?.(msg).catch((err) => { diff --git a/src/channels/slack.ts b/src/channels/slack.ts index 4ab77d6..ddabca6 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -169,6 +169,7 @@ export class SlackAdapter implements ChannelAdapter { wasMentioned: false, // Regular messages; app_mention handles mentions isListeningMode: mode === 'listen', attachments, + formatterHints: this.getFormatterHints(), }); } }); @@ -264,6 +265,7 @@ export class SlackAdapter implements ChannelAdapter { groupName: isGroup ? channelId : undefined, wasMentioned: true, // app_mention is always a mention attachments, + formatterHints: this.getFormatterHints(), }); } }); @@ -359,6 +361,14 @@ export class SlackAdapter implements ChannelAdapter { return this.config.dmPolicy || 'pairing'; } + getFormatterHints() { + return { + supportsReactions: true, + supportsFiles: true, + formatHint: 'Slack mrkdwn: *bold* _italic_ `code` — NO standard markdown headers', + }; + } + /** Check if a channel is allowed by the groups config allowlist */ private isChannelAllowed(channelId: string): boolean { return isGroupAllowed(this.config.groups, [channelId]); @@ -413,6 +423,7 @@ export class SlackAdapter implements ChannelAdapter { messageId, action, }, + formatterHints: this.getFormatterHints(), }); } diff --git a/src/channels/telegram-mtproto.ts b/src/channels/telegram-mtproto.ts index 44497ea..dc72cec 100644 --- a/src/channels/telegram-mtproto.ts +++ b/src/channels/telegram-mtproto.ts @@ -496,6 +496,7 @@ Reply **approve** or **deny** to this message.`; text, messageId, timestamp: new Date(message.date * 1000), + formatterHints: this.getFormatterHints(), }; // Call handler @@ -749,6 +750,14 @@ Reply **approve** or **deny** to this message.`; return false; } + getFormatterHints() { + return { + supportsReactions: false, + supportsFiles: false, + formatHint: 'MarkdownV2: *bold* _italic_ `code` [link](url) — NO: headers, tables', + }; + } + async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> { if (!this.client) { throw new Error('Client not initialized'); diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 218526e..c7e8729 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -304,6 +304,7 @@ export class TelegramAdapter implements ChannelAdapter { groupName, wasMentioned, isListeningMode, + formatterHints: this.getFormatterHints(), }); } }); @@ -349,6 +350,7 @@ export class TelegramAdapter implements ChannelAdapter { messageId: String(messageId), action, }, + formatterHints: this.getFormatterHints(), }); } }); @@ -409,6 +411,7 @@ export class TelegramAdapter implements ChannelAdapter { groupName, wasMentioned, isListeningMode, + formatterHints: this.getFormatterHints(), }); } } catch (error) { @@ -427,6 +430,7 @@ export class TelegramAdapter implements ChannelAdapter { groupName, wasMentioned, isListeningMode, + formatterHints: this.getFormatterHints(), }); } } @@ -461,6 +465,7 @@ export class TelegramAdapter implements ChannelAdapter { wasMentioned, isListeningMode, attachments, + formatterHints: this.getFormatterHints(), }); } }); @@ -633,6 +638,14 @@ export class TelegramAdapter implements ChannelAdapter { return this.config.dmPolicy || 'pairing'; } + getFormatterHints() { + return { + supportsReactions: true, + supportsFiles: true, + formatHint: 'MarkdownV2: *bold* _italic_ `code` [link](url) — NO: headers, tables', + }; + } + 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 d7d82d4..69cc8e3 100644 --- a/src/channels/types.ts +++ b/src/channels/types.ts @@ -4,7 +4,7 @@ * Each channel (Telegram, Slack, Discord, WhatsApp, Signal) implements this interface. */ -import type { ChannelId, InboundMessage, OutboundMessage, OutboundFile } from '../core/types.js'; +import type { ChannelId, InboundMessage, OutboundMessage, OutboundFile, FormatterHints } from '../core/types.js'; /** * Channel adapter - implement this for each messaging platform @@ -29,6 +29,7 @@ export interface ChannelAdapter { sendFile?(file: OutboundFile): Promise<{ messageId: string }>; addReaction?(chatId: string, messageId: string, emoji: string): Promise; getDmPolicy?(): string; + getFormatterHints(): FormatterHints; // 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 39c5e28..ce08d42 100644 --- a/src/channels/whatsapp/index.ts +++ b/src/channels/whatsapp/index.ts @@ -836,6 +836,7 @@ export class WhatsAppAdapter implements ChannelAdapter { isListeningMode, replyToUser: extracted.replyContext?.senderE164, attachments: extracted.attachments, + formatterHints: this.getFormatterHints(), }); } } @@ -1002,6 +1003,14 @@ export class WhatsAppAdapter implements ChannelAdapter { return this.config.dmPolicy || 'pairing'; } + getFormatterHints() { + return { + supportsReactions: false, + supportsFiles: true, + formatHint: 'WhatsApp: *bold* _italic_ `code` — NO: headers, code fences, links, tables', + }; + } + supportsEditing(): boolean { return false; } diff --git a/src/core/formatter.test.ts b/src/core/formatter.test.ts index ba85d69..be08cae 100644 --- a/src/core/formatter.test.ts +++ b/src/core/formatter.test.ts @@ -181,38 +181,67 @@ describe('formatMessageEnvelope', () => { expect(result).toContain('**Mentioned**: yes'); }); - it('does not include per-message directive hints (covered by system prompt)', () => { - const groupMsg = createMessage({ isGroup: true }); - const dmMsg = createMessage({ isGroup: false }); - expect(formatMessageEnvelope(groupMsg)).not.toContain('Response Directives'); - expect(formatMessageEnvelope(dmMsg)).not.toContain('Response Directives'); + it('includes directives when reactions are supported', () => { + const msg = createMessage({ + isGroup: false, + formatterHints: { supportsReactions: true }, + }); + const result = formatMessageEnvelope(msg); + expect(result).toContain('Response Directives'); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('omits directives when reactions are not supported', () => { + const msg = createMessage({ isGroup: false }); + const result = formatMessageEnvelope(msg); + expect(result).toContain('Response Directives'); + expect(result).toContain(''); + expect(result).not.toContain(' { + const msg = createMessage({ + formatterHints: { supportsFiles: true }, + }); + const result = formatMessageEnvelope(msg); + expect(result).toContain(' { + const msg = createMessage({ + formatterHints: { supportsFiles: false }, + }); + const result = formatMessageEnvelope(msg); + expect(result).not.toContain(' { + const msg = createMessage({ + isGroup: true, + isListeningMode: true, + formatterHints: { supportsReactions: true }, + }); + const result = formatMessageEnvelope(msg); + expect(result).toContain(''); + expect(result).toContain('react to show you saw this'); + expect(result).not.toContain('react and reply'); }); }); describe('format hints', () => { - it('includes Slack format hint', () => { - const msg = createMessage({ channel: 'slack' }); + it('includes format hint when provided via formatterHints', () => { + const msg = createMessage({ + formatterHints: { formatHint: 'MarkdownV2: *bold* _italic_' }, + }); const result = formatMessageEnvelope(msg); - expect(result).toContain('**Format support**: Markdown (auto-converted to Slack mrkdwn):'); + expect(result).toContain('**Format support**: MarkdownV2: *bold* _italic_'); }); - it('includes Telegram format hint', () => { - const msg = createMessage({ channel: 'telegram' }); + it('omits Format support line when no formatHint is set', () => { + const msg = createMessage({}); const result = formatMessageEnvelope(msg); - expect(result).toContain('**Format support**: MarkdownV2:'); - }); - - it('includes WhatsApp format hint', () => { - const msg = createMessage({ channel: 'whatsapp' }); - const result = formatMessageEnvelope(msg); - expect(result).toContain('**Format support**:'); - expect(result).toContain('NO: headers'); - }); - - it('includes Signal format hint', () => { - const msg = createMessage({ channel: 'signal' }); - const result = formatMessageEnvelope(msg); - expect(result).toContain('**Format support**: ONLY:'); + expect(result).not.toContain('**Format support**'); }); }); diff --git a/src/core/formatter.ts b/src/core/formatter.ts index e450054..b52797d 100644 --- a/src/core/formatter.ts +++ b/src/core/formatter.ts @@ -13,17 +13,7 @@ export const SYSTEM_REMINDER_TAG = 'system-reminder'; export const SYSTEM_REMINDER_OPEN = `<${SYSTEM_REMINDER_TAG}>`; export const SYSTEM_REMINDER_CLOSE = ``; -/** - * Channel format hints - tells the agent what formatting syntax to use - * Each channel has different markdown support - hints help agent format appropriately. - */ -const CHANNEL_FORMATS: Record = { - slack: 'Markdown (auto-converted to Slack mrkdwn): **bold** _italic_ `code` [links](url) ```code blocks``` - NO: headers, tables', - discord: '**bold** *italic* `code` [links](url) ```code blocks``` - NO: headers, tables', - telegram: 'MarkdownV2: *bold* _italic_ `code` [links](url) - NO: headers, tables', - whatsapp: '*bold* _italic_ `code` - NO: headers, code fences, links, tables', - signal: 'ONLY: *bold* _italic_ `code` - NO: headers, code fences, links, quotes, tables', -}; +// Channel format hints are now provided per-message via formatterHints on InboundMessage. export interface EnvelopeOptions { timezone?: 'local' | 'utc' | string; // IANA timezone or 'local'/'utc' @@ -229,7 +219,7 @@ function buildMetadataLines(msg: InboundMessage, options: EnvelopeOptions): stri lines.push(`- **Timestamp**: ${formatTimestamp(msg.timestamp, options)}`); // Format support hint - const formatHint = CHANNEL_FORMATS[msg.channel]; + const formatHint = msg.formatterHints?.formatHint; if (formatHint) { lines.push(`- **Format support**: ${formatHint}`); } @@ -256,7 +246,13 @@ function buildChatContextLines(msg: InboundMessage, options: EnvelopeOptions): s if (msg.wasMentioned) { lines.push(`- **Mentioned**: yes`); } - lines.push(`- **Hint**: Use \`\` to skip replying, \`\` for reactions/voice`); + if (msg.isListeningMode) { + lines.push(`- **Mode**: Listen only — observe and update memories, do not send text replies`); + } else if (msg.formatterHints?.supportsReactions) { + lines.push(`- **Hint**: See Response Directives below for \`\` and \`\``); + } else { + lines.push(`- **Hint**: See Response Directives below for \`\``); + } } else { lines.push(`- **Type**: Direct message`); } @@ -301,6 +297,56 @@ export function buildSessionContext(options: SessionContextOptions): string[] { return lines; } +/** + * Build context-aware Response Directives based on channel capabilities and chat type. + * In listening mode, shows minimal directives. In normal mode, shows the full set + * filtered by what the channel actually supports. + */ +function buildResponseDirectives(msg: InboundMessage): string[] { + const lines: string[] = []; + const supportsReactions = msg.formatterHints?.supportsReactions ?? false; + const supportsFiles = msg.formatterHints?.supportsFiles ?? false; + const isGroup = !!msg.isGroup; + const isListeningMode = msg.isListeningMode ?? false; + + // Listening mode: minimal directives only + if (isListeningMode) { + lines.push(`- \`\` — acknowledge without replying (recommended)`); + if (supportsReactions) { + lines.push(`- \`\` — react to show you saw this`); + } + return lines; + } + + // no-reply + if (isGroup) { + lines.push(`- \`\` — skip replying when the message isn't directed at you`); + } else { + lines.push(`- \`\` — skip replying when the message doesn't need a response`); + } + + // actions/react (only if channel supports it) + if (supportsReactions) { + lines.push(`- \`\` — react without sending text (executes silently)`); + lines.push(`- \`Your text here\` — react and reply`); + if (isGroup) { + lines.push(`- \`\` — react to a specific message`); + } + lines.push(`- Emoji names: eyes, thumbsup, heart, fire, tada, clap — or unicode`); + lines.push(`- Prefer directives over tool calls for reactions (faster and cheaper)`); + } + + // voice memo (always available -- TTS config is server-side) + lines.push(`- \`Your message here\` — send a voice memo via TTS`); + + // file sending (only if channel supports it) + if (supportsFiles) { + lines.push(`- \`\` — send a file (restricted to configured directory)`); + } + + return lines; +} + /** * Format a message with XML system-reminder envelope. * @@ -351,6 +397,9 @@ export function formatMessageEnvelope( sections.push(`## Chat Context\n${contextLines.join('\n')}`); } + // Channel-aware response directives + const directiveLines = buildResponseDirectives(msg); + sections.push(`## Response Directives\n${directiveLines.join('\n')}`); // Build the full system-reminder block const reminderContent = sections.join('\n\n'); const reminder = `${SYSTEM_REMINDER_OPEN}\n${reminderContent}\n${SYSTEM_REMINDER_CLOSE}`; @@ -418,8 +467,15 @@ export function formatGroupBatchEnvelope( }); // Format hint - const formatHint = CHANNEL_FORMATS[first.channel]; + const formatHint = first.formatterHints?.formatHint; const hint = formatHint ? `\n(Format: ${formatHint})` : ''; - return `${header}\n${lines.join('\n')}${hint}`; + // Compact directives for batch + const supportsReactions = first.formatterHints?.supportsReactions ?? false; + const directiveParts = isListeningMode + ? [`\`\` to acknowledge`, ...(supportsReactions ? [`\`\` to react`] : [])] + : [`\`\` to skip replying`, ...(supportsReactions ? [`\`\` to react`] : [])]; + const directives = `\n(Directives: ${directiveParts.join(', ')})`; + + return `${header}\n${lines.join('\n')}${hint}${directives}`; } diff --git a/src/core/group-batcher.ts b/src/core/group-batcher.ts index eb3ce1c..f347bd1 100644 --- a/src/core/group-batcher.ts +++ b/src/core/group-batcher.ts @@ -95,6 +95,7 @@ export class GroupBatcher { isListeningMode: messages.every((m) => m.isListeningMode === true) ? true : undefined, isBatch: true, batchedMessages: messages, + formatterHints: last.formatterHints, }; this.onFlush(batchMsg, adapter); diff --git a/src/core/redaction-channel.test.ts b/src/core/redaction-channel.test.ts index 8e4d539..1923961 100644 --- a/src/core/redaction-channel.test.ts +++ b/src/core/redaction-channel.test.ts @@ -35,6 +35,7 @@ describe('channel redaction wrapping', () => { sendMessage: sendSpy, editMessage: vi.fn(async () => {}), sendTypingIndicator: vi.fn(async () => {}), + getFormatterHints: () => ({ supportsReactions: false, supportsFiles: false }), }; bot.registerChannel(adapter); diff --git a/src/core/system-prompt.ts b/src/core/system-prompt.ts index 8c9fa1a..f613445 100644 --- a/src/core/system-prompt.ts +++ b/src/core/system-prompt.ts @@ -6,7 +6,18 @@ export const SYSTEM_PROMPT = `You are a self-improving AI agent with advanced me You are connected to a multi-channel messaging system (LettaBot) that allows you to communicate with users across Telegram, Slack, Discord, WhatsApp, and Signal. You run on a remote server and can execute tools, manage files, and interact with various services. -Not every message requires a response. Before replying, consider whether your response adds value. In group chats especially, avoid replying to messages not directed at you, simple acknowledgments, or conversations between other users. Quality over quantity — only reply when you have something meaningful to contribute. +Not every message requires a response. Before replying, consider whether your response adds value. When in doubt, prefer \`\` over a low-value response. + +## Choosing Not to Reply + +Use \`\` when the message: +- Is a simple acknowledgment ("ok", "thanks", "got it") that doesn't need a follow-up +- Is a conversation between other users that you're not part of +- Is a notification or status update with no question or request +- Has already been addressed in a previous turn +- Is in a group chat and not directed at you + +Channel-specific response options (reactions, file sending) are listed in the **Response Directives** section of each incoming message. # Communication System @@ -74,54 +85,6 @@ During heartbeats and background tasks: You don't need to notify the user about everything. Use judgment about what's worth interrupting them for. -## Choosing Not to Reply - -Not all messages warrant a response. If a message doesn't need a reply, respond with exactly: - -\`\` - -This suppresses the message so nothing is sent to the user. Use this for: -- Messages in a group not directed at you -- Simple acknowledgments (e.g., "ok", "thanks", thumbs up) -- Conversations between other users you don't need to join -- Notifications or updates that don't require a response -- Messages you've already addressed - -When in doubt, prefer \`\` over a low-value response. Users appreciate an agent that knows when to stay quiet. - -## Response Directives - -You can include an \`\` block at the **start** of your response to perform actions alongside your reply. The entire block is stripped before your message is sent. - -\`\`\` - - - -Great idea! -\`\`\` - -This sends "Great idea!" and reacts with thumbsup. - -### Available directives - -- \`\` -- react to the message you are responding to. Use the actual emoji character (👀, 👍, ❤️, 🔥, 🎉, 👏). -- \`\` -- react to a specific message by ID. -- \`\` -- send a file or image to the same channel/chat. File paths are restricted to the configured send-file directory (default: \`data/outbound/\` in the working directory). Paths outside this directory are blocked. -- \`\` -- send a voice note. Audio files (.ogg, .mp3, etc.) are sent as native voice memos on Telegram and WhatsApp. Use \`cleanup="true"\` to delete the file after sending. -- \`Your message here\` -- generate and send a voice memo. The text is converted to speech via TTS and sent as a native voice note. No tool calls needed. Use for short conversational replies, responding to voice messages, or when the user asks for audio. - -### Actions-only response - -An \`\` block with no text after it executes silently (nothing sent to the user), like \`\`: - -\`\`\` - - - -\`\`\` - -Prefer directives over tool calls for simple actions like reactions. They are faster and cheaper. - ## Available Channels - **telegram** - Telegram messenger diff --git a/src/core/types.ts b/src/core/types.ts index 8a17a92..1ebfa6f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -84,6 +84,16 @@ export interface InboundMessage { isBatch?: boolean; // Is this a batched group message? batchedMessages?: InboundMessage[]; // Original individual messages (for batch formatting) isListeningMode?: boolean; // Listening mode: agent processes for memory but response is suppressed + formatterHints?: FormatterHints; // Channel capabilities for directive rendering +} + +/** + * Channel capability hints for per-message directive rendering + */ +export interface FormatterHints { + supportsReactions?: boolean; + supportsFiles?: boolean; + formatHint?: string; } /** diff --git a/src/test/mock-channel.ts b/src/test/mock-channel.ts index 045953a..a93d445 100644 --- a/src/test/mock-channel.ts +++ b/src/test/mock-channel.ts @@ -55,6 +55,10 @@ export class MockChannelAdapter implements ChannelAdapter { supportsEditing(): boolean { return false; // Disable streaming edits for simpler testing } + + getFormatterHints() { + return { supportsReactions: false, supportsFiles: false }; + } /** * Simulate an inbound message and wait for response