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
+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') {