- Replace in-memory Chat SDK state with SqliteStateAdapter — thread subscriptions now persist across restarts - Add migration 002 for chat_sdk_kv, subscriptions, locks, lists tables - Handle /clear in agent-runner (reset sessionId) — SDK has supportsNonInteractive:false for this command - Pass /compact, /context, /cost, /files through to SDK as admin commands - Skip admin commands in follow-up poll so they start fresh queries - Emit compact_boundary events as user-visible feedback messages - Pass NANOCLAW_ADMIN_USER_ID and NANOCLAW_ASSISTANT_NAME to containers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
189 lines
6.5 KiB
TypeScript
189 lines
6.5 KiB
TypeScript
import type { MessageInRow } from './db/messages-in.js';
|
|
|
|
/**
|
|
* Command categories for messages starting with '/'.
|
|
* - admin: requires NANOCLAW_ADMIN_USER_ID check
|
|
* - filtered: silently drop (mark completed without processing)
|
|
* - passthrough: pass raw to the agent (no XML wrapping)
|
|
* - none: not a command — format normally
|
|
*/
|
|
export type CommandCategory = 'admin' | 'filtered' | 'passthrough' | 'none';
|
|
|
|
const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files']);
|
|
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config']);
|
|
|
|
export interface CommandInfo {
|
|
category: CommandCategory;
|
|
command: string; // the command name (e.g., '/clear')
|
|
text: string; // full original text
|
|
senderId: string | null;
|
|
}
|
|
|
|
/**
|
|
* Categorize a message as a command or not.
|
|
* Only applies to chat/chat-sdk messages.
|
|
*/
|
|
export function categorizeMessage(msg: MessageInRow): CommandInfo {
|
|
const content = parseContent(msg.content);
|
|
const text = (content.text || '').trim();
|
|
const senderId = content.senderId || content.author?.userId || null;
|
|
|
|
if (!text.startsWith('/')) {
|
|
return { category: 'none', command: '', text, senderId };
|
|
}
|
|
|
|
// Extract the command name (e.g., '/clear' from '/clear some args')
|
|
const command = text.split(/\s/)[0].toLowerCase();
|
|
|
|
if (ADMIN_COMMANDS.has(command)) {
|
|
return { category: 'admin', command, text, senderId };
|
|
}
|
|
|
|
if (FILTERED_COMMANDS.has(command)) {
|
|
return { category: 'filtered', command, text, senderId };
|
|
}
|
|
|
|
return { category: 'passthrough', command, text, senderId };
|
|
}
|
|
|
|
/**
|
|
* Routing context extracted from messages_in rows.
|
|
* Copied to messages_out by default so responses go back to the sender.
|
|
*/
|
|
export interface RoutingContext {
|
|
platformId: string | null;
|
|
channelType: string | null;
|
|
threadId: string | null;
|
|
inReplyTo: string | null;
|
|
}
|
|
|
|
/**
|
|
* Extract routing context from a batch of messages.
|
|
* Uses the first message's routing fields.
|
|
*/
|
|
export function extractRouting(messages: MessageInRow[]): RoutingContext {
|
|
const first = messages[0];
|
|
return {
|
|
platformId: first?.platform_id ?? null,
|
|
channelType: first?.channel_type ?? null,
|
|
threadId: first?.thread_id ?? null,
|
|
inReplyTo: first?.id ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Format a batch of messages_in rows into a prompt string.
|
|
* Strips routing fields — the agent never sees platform_id, channel_type, thread_id.
|
|
*/
|
|
export function formatMessages(messages: MessageInRow[]): string {
|
|
if (messages.length === 0) return '';
|
|
|
|
// Group by kind
|
|
const chatMessages = messages.filter((m) => m.kind === 'chat' || m.kind === 'chat-sdk');
|
|
const taskMessages = messages.filter((m) => m.kind === 'task');
|
|
const webhookMessages = messages.filter((m) => m.kind === 'webhook');
|
|
const systemMessages = messages.filter((m) => m.kind === 'system');
|
|
|
|
const parts: string[] = [];
|
|
|
|
if (chatMessages.length > 0) {
|
|
parts.push(formatChatMessages(chatMessages));
|
|
}
|
|
if (taskMessages.length > 0) {
|
|
parts.push(...taskMessages.map(formatTaskMessage));
|
|
}
|
|
if (webhookMessages.length > 0) {
|
|
parts.push(...webhookMessages.map(formatWebhookMessage));
|
|
}
|
|
if (systemMessages.length > 0) {
|
|
parts.push(...systemMessages.map(formatSystemMessage));
|
|
}
|
|
|
|
return parts.join('\n\n');
|
|
}
|
|
|
|
function formatChatMessages(messages: MessageInRow[]): string {
|
|
if (messages.length === 1) {
|
|
return formatSingleChat(messages[0]);
|
|
}
|
|
|
|
const lines = ['<messages>'];
|
|
for (const msg of messages) {
|
|
const content = parseContent(msg.content);
|
|
const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown';
|
|
const time = formatTime(msg.timestamp);
|
|
const text = content.text || '';
|
|
const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
|
|
const attachmentsSuffix = formatAttachments(content.attachments);
|
|
lines.push(`<message${idAttr} sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}${attachmentsSuffix}</message>`);
|
|
}
|
|
lines.push('</messages>');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function formatSingleChat(msg: MessageInRow): string {
|
|
const content = parseContent(msg.content);
|
|
const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown';
|
|
const time = formatTime(msg.timestamp);
|
|
const text = content.text || '';
|
|
const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
|
|
const attachmentsSuffix = formatAttachments(content.attachments);
|
|
return `<message${idAttr} sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}${attachmentsSuffix}</message>`;
|
|
}
|
|
|
|
function formatTaskMessage(msg: MessageInRow): string {
|
|
const content = parseContent(msg.content);
|
|
const parts = ['[SCHEDULED TASK]'];
|
|
if (content.scriptOutput) {
|
|
parts.push('', 'Script output:', JSON.stringify(content.scriptOutput, null, 2));
|
|
}
|
|
parts.push('', 'Instructions:', content.prompt || '');
|
|
return parts.join('\n');
|
|
}
|
|
|
|
function formatWebhookMessage(msg: MessageInRow): string {
|
|
const content = parseContent(msg.content);
|
|
const source = content.source || 'unknown';
|
|
const event = content.event || 'unknown';
|
|
return `[WEBHOOK: ${source}/${event}]\n\n${JSON.stringify(content.payload || content, null, 2)}`;
|
|
}
|
|
|
|
function formatSystemMessage(msg: MessageInRow): string {
|
|
const content = parseContent(msg.content);
|
|
return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function formatAttachments(attachments: any[] | undefined): string {
|
|
if (!Array.isArray(attachments) || attachments.length === 0) return '';
|
|
const parts = attachments.map((a) => {
|
|
const name = a.name || a.filename || 'attachment';
|
|
const type = a.type || 'file';
|
|
const url = a.url || '';
|
|
return url ? `[${type}: ${escapeXml(name)} (${escapeXml(url)})]` : `[${type}: ${escapeXml(name)}]`;
|
|
});
|
|
return '\n' + parts.join('\n');
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function parseContent(json: string): any {
|
|
try {
|
|
return JSON.parse(json);
|
|
} catch {
|
|
return { text: json };
|
|
}
|
|
}
|
|
|
|
function formatTime(timestamp: string): string {
|
|
try {
|
|
const d = new Date(timestamp);
|
|
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
|
} catch {
|
|
return timestamp;
|
|
}
|
|
}
|
|
|
|
function escapeXml(str: string): string {
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|