v2 phase 5: scheduling fixes, media handling, command processing

- Host sweep: fix DELETE journal mode, busy_timeout, seq in recurrence INSERT
- Outbound files: delivery reads from outbox dir, passes buffers to adapter,
  cleans up after delivery. Chat SDK bridge sends files via postMessage.
- Inbound attachments: formatter includes attachment info in prompts
- Commands: categorize /commands as admin, filtered, or passthrough.
  Admin commands check sender against NANOCLAW_ADMIN_USER_ID.
  Filtered commands silently dropped. Passthrough sent raw to agent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-09 02:59:33 +03:00
parent afbc20a6c4
commit c348fabf22
7 changed files with 266 additions and 43 deletions

View File

@@ -1,5 +1,51 @@
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']);
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.
@@ -68,7 +114,8 @@ function formatChatMessages(messages: MessageInRow[]): string {
const time = formatTime(msg.timestamp);
const text = content.text || '';
const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
lines.push(`<message${idAttr} sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}</message>`);
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');
@@ -80,7 +127,8 @@ function formatSingleChat(msg: MessageInRow): string {
const time = formatTime(msg.timestamp);
const text = content.text || '';
const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
return `<message${idAttr} sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}</message>`;
const attachmentsSuffix = formatAttachments(content.attachments);
return `<message${idAttr} sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}${attachmentsSuffix}</message>`;
}
function formatTaskMessage(msg: MessageInRow): string {
@@ -105,6 +153,18 @@ function formatSystemMessage(msg: MessageInRow): string {
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 {