feat: add P2 features — retry policy, prompt templating, usage tracking, tech debt cleanup
- Extract shared splitMessage() into channels/utils.ts (dedup 4 adapters) - Add Slack user name resolution with caching (users.info API) - Add withRetry() with exponential backoff + jitter, isRetryable() filter - Wire retry config into ModelRouter.chat() (non-streaming only) - Add assembleSystemPrompt() multi-file template system (SOUL/AGENTS/IDENTITY/USER/TOOLS.md) - Add usage tracking accumulators in NativeAgent + AgentOrchestrator - Add estimateCost() with per-model pricing table - Add /usage TUI command with full usage report formatting - Add retrySchema and promptSchema to config schema Tests: 569 passing, typecheck clean
This commit is contained in:
@@ -15,6 +15,7 @@ import type {
|
||||
ChannelAdapter,
|
||||
ChannelStatus,
|
||||
} from '../types.js';
|
||||
import { splitMessage } from '../utils.js';
|
||||
|
||||
/** Configuration for the Discord channel adapter. */
|
||||
export interface DiscordAdapterConfig {
|
||||
@@ -27,36 +28,6 @@ export interface DiscordAdapterConfig {
|
||||
requireMention?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a long message into chunks that respect Discord's 2000 char limit.
|
||||
* Prefers splitting at newlines, then spaces, then hard-cuts.
|
||||
*/
|
||||
function splitMessage(text: string, maxLength: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLength) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to split at a newline within the allowed window
|
||||
let splitIndex = remaining.lastIndexOf('\n', maxLength);
|
||||
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
||||
splitIndex = remaining.lastIndexOf(' ', maxLength);
|
||||
}
|
||||
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
||||
splitIndex = maxLength;
|
||||
}
|
||||
|
||||
chunks.push(remaining.slice(0, splitIndex));
|
||||
remaining = remaining.slice(splitIndex).trimStart();
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord channel adapter backed by discord.js.
|
||||
*
|
||||
|
||||
@@ -7,6 +7,7 @@ export type {
|
||||
MessageHandler,
|
||||
} from './types.js';
|
||||
export { ChannelRegistry } from './registry.js';
|
||||
export { splitMessage } from './utils.js';
|
||||
export { TelegramAdapter, type TelegramAdapterConfig } from './telegram/index.js';
|
||||
export { WebChatAdapter, type WebChatAdapterConfig } from './webchat/index.js';
|
||||
export { DiscordAdapter, type DiscordAdapterConfig } from './discord/index.js';
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ChannelAdapter,
|
||||
ChannelStatus,
|
||||
} from '../types.js';
|
||||
import { splitMessage } from '../utils.js';
|
||||
|
||||
/** Configuration for the Slack channel adapter. */
|
||||
export interface SlackAdapterConfig {
|
||||
@@ -34,36 +35,6 @@ interface SlackMessageEvent {
|
||||
subtype?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a long message into chunks that respect Slack's readability limit.
|
||||
* Prefers splitting at newlines, then spaces, then hard-cuts.
|
||||
*/
|
||||
function splitMessage(text: string, maxLength: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLength) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to split at a newline within the allowed window
|
||||
let splitIndex = remaining.lastIndexOf('\n', maxLength);
|
||||
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
||||
splitIndex = remaining.lastIndexOf(' ', maxLength);
|
||||
}
|
||||
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
||||
splitIndex = maxLength;
|
||||
}
|
||||
|
||||
chunks.push(remaining.slice(0, splitIndex));
|
||||
remaining = remaining.slice(splitIndex).trimStart();
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack channel adapter backed by @slack/bolt.
|
||||
*
|
||||
@@ -77,6 +48,7 @@ export class SlackAdapter implements ChannelAdapter {
|
||||
private app: App | null = null;
|
||||
private messageHandler?: (msg: InboundMessage) => void;
|
||||
private config: SlackAdapterConfig;
|
||||
private userNameCache: Map<string, string> = new Map();
|
||||
|
||||
get status(): ChannelStatus {
|
||||
return this._status;
|
||||
@@ -105,7 +77,7 @@ export class SlackAdapter implements ChannelAdapter {
|
||||
|
||||
// Register message event handler
|
||||
this.app.message(async ({ message }) => {
|
||||
this.handleMessage(message as unknown as SlackMessageEvent);
|
||||
await this.handleMessage(message as unknown as SlackMessageEvent);
|
||||
});
|
||||
|
||||
await this.app.start();
|
||||
@@ -161,8 +133,23 @@ export class SlackAdapter implements ChannelAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a Slack user ID to a display name, with caching. */
|
||||
private async resolveUserName(userId: string): Promise<string> {
|
||||
const cached = this.userNameCache.get(userId);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const result = await this.app!.client.users.info({ user: userId });
|
||||
const name = result.user?.real_name || result.user?.name || userId;
|
||||
this.userNameCache.set(userId, name);
|
||||
return name;
|
||||
} catch {
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal: process an inbound Slack message event. */
|
||||
private handleMessage(message: SlackMessageEvent): void {
|
||||
private async handleMessage(message: SlackMessageEvent): Promise<void> {
|
||||
if (!this.messageHandler) return;
|
||||
|
||||
// Ignore bot messages
|
||||
@@ -187,9 +174,10 @@ export class SlackAdapter implements ChannelAdapter {
|
||||
// Strip bot mentions: <@U\w+> pattern
|
||||
let text = (message.text ?? '').replace(/<@U\w+>/g, '').trim();
|
||||
|
||||
// TODO: message.user is a Slack user ID (e.g. U0123ABC), not a display name.
|
||||
// To resolve display names, use this.app.client.users.info() with caching.
|
||||
const senderName = message.user;
|
||||
// Resolve display name from Slack user ID
|
||||
const senderName = message.user
|
||||
? await this.resolveUserName(message.user)
|
||||
: undefined;
|
||||
|
||||
// Detect reset command
|
||||
if (text === '!reset' || text === 'reset') {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from '../types.js';
|
||||
import { isAllowedChat } from '../../frontends/telegram/handlers.js';
|
||||
import { parseConfirmationCallback } from '../../frontends/telegram/confirmations.js';
|
||||
import { splitMessage } from '../utils.js';
|
||||
|
||||
/** Configuration for the Telegram channel adapter. */
|
||||
export interface TelegramAdapterConfig {
|
||||
@@ -17,36 +18,6 @@ export interface TelegramAdapterConfig {
|
||||
hookEngine?: HookEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a long message into chunks that respect Telegram's 4096 char limit.
|
||||
* Prefers splitting at newlines, then spaces, then hard-cuts.
|
||||
*/
|
||||
function splitMessage(text: string, maxLength: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLength) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to split at a newline within the allowed window
|
||||
let splitIndex = remaining.lastIndexOf('\n', maxLength);
|
||||
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
||||
splitIndex = remaining.lastIndexOf(' ', maxLength);
|
||||
}
|
||||
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
||||
splitIndex = maxLength;
|
||||
}
|
||||
|
||||
chunks.push(remaining.slice(0, splitIndex));
|
||||
remaining = remaining.slice(splitIndex).trimStart();
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram channel adapter backed by grammy.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { splitMessage } from './utils.js';
|
||||
|
||||
describe('splitMessage', () => {
|
||||
it('returns single chunk for empty string', () => {
|
||||
const result = splitMessage('', 100);
|
||||
// empty string never enters the while loop → returns empty array
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns single chunk when text is under maxLength', () => {
|
||||
const result = splitMessage('hello world', 100);
|
||||
expect(result).toEqual(['hello world']);
|
||||
});
|
||||
|
||||
it('returns single chunk when text equals maxLength', () => {
|
||||
const text = 'a'.repeat(50);
|
||||
const result = splitMessage(text, 50);
|
||||
expect(result).toEqual([text]);
|
||||
});
|
||||
|
||||
it('splits at newline when possible', () => {
|
||||
const text = 'line one\nline two\nline three';
|
||||
// maxLength 18 → "line one\nline two\n" is 18 chars, lastIndexOf('\n', 18) = 17
|
||||
const result = splitMessage(text, 18);
|
||||
expect(result).toEqual(['line one\nline two', 'line three']);
|
||||
});
|
||||
|
||||
it('splits at space when no newline available', () => {
|
||||
const text = 'word1 word2 word3 word4';
|
||||
// maxLength 12 → "word1 word2 " lastIndexOf(' ', 12) = 11
|
||||
const result = splitMessage(text, 12);
|
||||
expect(result[0]).toBe('word1 word2');
|
||||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('hard-cuts when no whitespace available', () => {
|
||||
const text = 'abcdefghijklmnop';
|
||||
const result = splitMessage(text, 5);
|
||||
expect(result[0]).toBe('abcde');
|
||||
expect(result[1]).toBe('fghij');
|
||||
expect(result[2]).toBe('klmno');
|
||||
expect(result[3]).toBe('p');
|
||||
});
|
||||
|
||||
it('produces multiple chunks for long text', () => {
|
||||
const text = 'chunk one\nchunk two\nchunk three\nchunk four';
|
||||
const result = splitMessage(text, 20);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
// Every chunk respects the limit
|
||||
for (const chunk of result) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(20);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves all content (joined chunks equal original minus trimmed whitespace)', () => {
|
||||
const text = 'The quick brown fox jumps over the lazy dog. ' +
|
||||
'Pack my box with five dozen liquor jugs. ' +
|
||||
'How vexingly quick daft zebras jump.';
|
||||
const result = splitMessage(text, 30);
|
||||
|
||||
// Reassemble: since trimStart() removes leading whitespace between chunks,
|
||||
// we verify all words are preserved
|
||||
const originalWords = text.split(/\s+/);
|
||||
const resultWords = result.join(' ').split(/\s+/);
|
||||
expect(resultWords).toEqual(originalWords);
|
||||
});
|
||||
|
||||
it('prefers newline split over space split', () => {
|
||||
// Place newline at a good position and space later
|
||||
const text = 'first part\nsecond part of the message';
|
||||
// maxLength 15: lastIndexOf('\n', 15) = 10, which is >= 15/2 = 7.5 → splits at newline
|
||||
const result = splitMessage(text, 15);
|
||||
expect(result[0]).toBe('first part');
|
||||
});
|
||||
|
||||
it('falls back to space when newline is too early', () => {
|
||||
// Newline at position 2, which is < maxLength/2 for maxLength=14
|
||||
const text = 'ab\ncdefghij klmnopqrst';
|
||||
// lastIndexOf('\n', 14) = 2, but 2 < 14/2=7 → falls back to space
|
||||
// lastIndexOf(' ', 14) = 11, which is >= 7 → splits at space
|
||||
const result = splitMessage(text, 14);
|
||||
expect(result[0]).toBe('ab\ncdefghij');
|
||||
expect(result[1]).toBe('klmnopqrst');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Shared utilities for channel adapters.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Split a long message into chunks that respect a platform's character limit.
|
||||
* Prefers splitting at newlines, then spaces, then hard-cuts.
|
||||
*/
|
||||
export function splitMessage(text: string, maxLength: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLength) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to split at a newline within the allowed window
|
||||
let splitIndex = remaining.lastIndexOf('\n', maxLength);
|
||||
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
||||
splitIndex = remaining.lastIndexOf(' ', maxLength);
|
||||
}
|
||||
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
||||
splitIndex = maxLength;
|
||||
}
|
||||
|
||||
chunks.push(remaining.slice(0, splitIndex));
|
||||
remaining = remaining.slice(splitIndex).trimStart();
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ChannelAdapter,
|
||||
ChannelStatus,
|
||||
} from '../types.js';
|
||||
import { splitMessage } from '../utils.js';
|
||||
|
||||
/** Configuration for the WhatsApp channel adapter. */
|
||||
export interface WhatsAppAdapterConfig {
|
||||
@@ -34,36 +35,6 @@ interface WhatsAppMessage {
|
||||
_data?: { notifyName?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a long message into chunks that respect WhatsApp's readability limit.
|
||||
* Prefers splitting at newlines, then spaces, then hard-cuts.
|
||||
*/
|
||||
function splitMessage(text: string, maxLength: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLength) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to split at a newline within the allowed window
|
||||
let splitIndex = remaining.lastIndexOf('\n', maxLength);
|
||||
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
||||
splitIndex = remaining.lastIndexOf(' ', maxLength);
|
||||
}
|
||||
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
||||
splitIndex = maxLength;
|
||||
}
|
||||
|
||||
chunks.push(remaining.slice(0, splitIndex));
|
||||
remaining = remaining.slice(splitIndex).trimStart();
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* WhatsApp channel adapter backed by whatsapp-web.js.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user