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:
William Valentin
2026-02-06 15:12:35 -08:00
parent de68deb1b2
commit 4316dbd3be
24 changed files with 902 additions and 143 deletions
+1 -30
View File
@@ -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.
*
+1
View File
@@ -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';
+23 -35
View File
@@ -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') {
+1 -30
View File
@@ -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.
*
+86
View File
@@ -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');
});
});
+33
View File
@@ -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;
}
+1 -30
View File
@@ -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.
*