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:
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getPendingMessages, markProcessing, markCompleted, touchProcessing } from './db/messages-in.js';
|
||||
import { getPendingMessages, markProcessing, markCompleted, touchProcessing, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { formatMessages, extractRouting, type RoutingContext } from './formatter.js';
|
||||
import { formatMessages, extractRouting, categorizeMessage, type RoutingContext } from './formatter.js';
|
||||
import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent } from './providers/types.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
@@ -50,9 +50,69 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
markProcessing(ids);
|
||||
|
||||
const routing = extractRouting(messages);
|
||||
const prompt = formatMessages(messages);
|
||||
|
||||
log(`Processing ${messages.length} message(s), kinds: ${[...new Set(messages.map((m) => m.kind))].join(',')}`);
|
||||
// Handle commands: categorize chat messages
|
||||
const adminUserId = config.env.NANOCLAW_ADMIN_USER_ID;
|
||||
const normalMessages = [];
|
||||
const commandIds: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.kind !== 'chat' && msg.kind !== 'chat-sdk') {
|
||||
normalMessages.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cmdInfo = categorizeMessage(msg);
|
||||
|
||||
if (cmdInfo.category === 'filtered') {
|
||||
// Silently drop — mark completed, don't process
|
||||
log(`Filtered command: ${cmdInfo.command} (msg: ${msg.id})`);
|
||||
commandIds.push(msg.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cmdInfo.category === 'admin') {
|
||||
if (!adminUserId || cmdInfo.senderId !== adminUserId) {
|
||||
// Not admin — send error, mark completed
|
||||
log(`Admin command denied: ${cmdInfo.command} from ${cmdInfo.senderId} (msg: ${msg.id})`);
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platformId,
|
||||
channel_type: routing.channelType,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: `Permission denied: ${cmdInfo.command} requires admin access.` }),
|
||||
});
|
||||
commandIds.push(msg.id);
|
||||
continue;
|
||||
}
|
||||
// Admin user — format as system command
|
||||
normalMessages.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// passthrough or none
|
||||
normalMessages.push(msg);
|
||||
}
|
||||
|
||||
// Mark filtered/denied command messages as completed immediately
|
||||
if (commandIds.length > 0) {
|
||||
markCompleted(commandIds);
|
||||
}
|
||||
|
||||
// If all messages were filtered commands, skip processing
|
||||
if (normalMessages.length === 0) {
|
||||
// Mark remaining processing IDs as completed
|
||||
const remainingIds = ids.filter((id) => !commandIds.includes(id));
|
||||
if (remainingIds.length > 0) markCompleted(remainingIds);
|
||||
log(`All ${messages.length} message(s) were commands, skipping query`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Format messages: passthrough commands get raw text, others get XML
|
||||
const prompt = formatMessagesWithCommands(normalMessages);
|
||||
|
||||
log(`Processing ${normalMessages.length} message(s), kinds: ${[...new Set(normalMessages.map((m) => m.kind))].join(',')}`);
|
||||
|
||||
// Set routing context as env vars for MCP tools
|
||||
setRoutingEnv(routing, config.env);
|
||||
@@ -69,8 +129,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
});
|
||||
|
||||
// Process the query while concurrently polling for new messages
|
||||
const processingIds = ids.filter((id) => !commandIds.includes(id));
|
||||
try {
|
||||
const result = await processQuery(query, routing, config, ids);
|
||||
const result = await processQuery(query, routing, config, processingIds);
|
||||
if (result.sessionId) sessionId = result.sessionId;
|
||||
if (result.resumeAt) resumeAt = result.resumeAt;
|
||||
} catch (err) {
|
||||
@@ -86,11 +147,55 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
markCompleted(ids);
|
||||
markCompleted(processingIds);
|
||||
log(`Completed ${ids.length} message(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format messages, handling passthrough commands differently.
|
||||
* Passthrough commands (e.g., /foo) are sent raw (no XML wrapping).
|
||||
* Admin commands from authorized users are formatted as system commands.
|
||||
* Normal messages get standard XML formatting.
|
||||
*/
|
||||
function formatMessagesWithCommands(messages: MessageInRow[]): string {
|
||||
// Check if any message is a passthrough command
|
||||
const parts: string[] = [];
|
||||
const normalBatch: MessageInRow[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.kind === 'chat' || msg.kind === 'chat-sdk') {
|
||||
const cmdInfo = categorizeMessage(msg);
|
||||
if (cmdInfo.category === 'passthrough') {
|
||||
// Flush normal batch first
|
||||
if (normalBatch.length > 0) {
|
||||
parts.push(formatMessages(normalBatch));
|
||||
normalBatch.length = 0;
|
||||
}
|
||||
// Pass raw command text (no XML wrapping)
|
||||
parts.push(cmdInfo.text);
|
||||
continue;
|
||||
}
|
||||
if (cmdInfo.category === 'admin') {
|
||||
// Format admin command as a system command block
|
||||
if (normalBatch.length > 0) {
|
||||
parts.push(formatMessages(normalBatch));
|
||||
normalBatch.length = 0;
|
||||
}
|
||||
parts.push(`[SYSTEM COMMAND: ${cmdInfo.command}]\n${cmdInfo.text}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
normalBatch.push(msg);
|
||||
}
|
||||
|
||||
if (normalBatch.length > 0) {
|
||||
parts.push(formatMessages(normalBatch));
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
interface QueryResult {
|
||||
sessionId?: string;
|
||||
resumeAt?: string;
|
||||
|
||||
Reference in New Issue
Block a user