diff --git a/src/db.ts b/src/db.ts index d1bd5bd..02c77e7 100644 --- a/src/db.ts +++ b/src/db.ts @@ -25,7 +25,9 @@ import { NewMessage, AgentType, PairedProject, + PairedRoomRole, PairedTask, + PairedTurnOutput, PairedWorkspace, RegisteredGroup, ScheduledTask, @@ -276,6 +278,17 @@ function createSchema(database: Database): void { ); CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role ON paired_workspaces(task_id, role); + CREATE TABLE IF NOT EXISTS paired_turn_outputs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + turn_number INTEGER NOT NULL, + role TEXT NOT NULL, + output_text TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(task_id, turn_number, role) + ); + CREATE INDEX IF NOT EXISTS idx_paired_turn_outputs_task + ON paired_turn_outputs(task_id, turn_number); CREATE TABLE IF NOT EXISTS channel_owner ( chat_jid TEXT PRIMARY KEY, owner_service_id TEXT NOT NULL, @@ -2443,3 +2456,55 @@ function migrateJsonState(): void { } } } + +// ── Paired turn outputs ────────────────────────────────────────── + +const MAX_TURN_OUTPUT_CHARS = 50_000; + +export function insertPairedTurnOutput( + taskId: string, + turnNumber: number, + role: PairedRoomRole, + outputText: string, +): void { + if (outputText.length > MAX_TURN_OUTPUT_CHARS) { + logger.warn( + { taskId, turnNumber, role, originalLen: outputText.length, maxLen: MAX_TURN_OUTPUT_CHARS }, + 'Paired turn output truncated — agent output exceeds storage limit', + ); + } + db.prepare( + `INSERT OR REPLACE INTO paired_turn_outputs + (task_id, turn_number, role, output_text, created_at) + VALUES (?, ?, ?, ?, ?)`, + ).run( + taskId, + turnNumber, + role, + outputText.slice(0, MAX_TURN_OUTPUT_CHARS), + new Date().toISOString(), + ); +} + +export function getPairedTurnOutputs( + taskId: string, +): PairedTurnOutput[] { + return db + .prepare( + `SELECT * FROM paired_turn_outputs + WHERE task_id = ? + ORDER BY turn_number ASC`, + ) + .all(taskId) as PairedTurnOutput[]; +} + +export function getLatestTurnNumber(taskId: string): number { + const row = db + .prepare( + `SELECT MAX(turn_number) as max_turn + FROM paired_turn_outputs + WHERE task_id = ?`, + ) + .get(taskId) as { max_turn: number | null } | undefined; + return row?.max_turn ?? 0; +} diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index 28550c3..2db193b 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -15,6 +15,8 @@ import { createServiceHandoff, getAllTasks, getLatestOpenPairedTaskForChat, + getLatestTurnNumber, + insertPairedTurnOutput, } from './db.js'; import { GroupQueue } from './group-queue.js'; import { logger } from './logger.js'; @@ -226,6 +228,7 @@ export async function runAgentForGroup( const effectivePrompt = moaEnrichedPrompt; let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed'; let pairedExecutionSummary: string | null = null; + let pairedFullOutput: string | null = null; let pairedExecutionCompleted = false; let pairedSawOutput = false; @@ -420,6 +423,7 @@ export async function runAgentForGroup( const outputText = getAgentOutputText(output); if (typeof outputText === 'string' && outputText.length > 0) { pairedExecutionSummary = outputText.slice(0, 500); + pairedFullOutput = outputText; } else if ( typeof output.error === 'string' && output.error.length > 0 @@ -1019,6 +1023,25 @@ export async function runAgentForGroup( status: effectiveStatus, summary: pairedExecutionSummary, }); + + // Store full output for direct inter-agent data passing (Discord-independent). + if (pairedFullOutput && effectiveStatus === 'succeeded') { + try { + const turnNumber = + getLatestTurnNumber(pairedExecutionContext.task.id) + 1; + insertPairedTurnOutput( + pairedExecutionContext.task.id, + turnNumber, + completedRole, + pairedFullOutput, + ); + } catch (err) { + logger.warn( + { taskId: pairedExecutionContext.task.id, err }, + 'Failed to store paired turn output', + ); + } + } } // After owner/reviewer completes, enqueue the next turn so diff --git a/src/message-runtime.ts b/src/message-runtime.ts index e55a7c6..059cd60 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -16,6 +16,7 @@ import { getRecentChatMessages, isPairedRoomJid, getLatestOpenPairedTaskForChat, + getPairedTurnOutputs, updatePairedTask, type ServiceHandoff, type WorkItem, @@ -160,6 +161,53 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { }); }; + /** Convert paired turn outputs to NewMessage format for formatMessages(). */ + const turnOutputsToMessages = ( + outputs: import('./types.js').PairedTurnOutput[], + chatJid: string, + ): NewMessage[] => + outputs.map((t) => ({ + id: `turn-${t.task_id}-${t.turn_number}`, + chat_jid: chatJid, + sender: t.role, + sender_name: t.role, + content: t.output_text, + timestamp: t.created_at, + is_bot_message: true as const, + is_from_me: false as const, + })); + + /** + * Build a prompt from paired_turn_outputs (Discord-independent) + human messages. + * Falls back to the legacy labelPairedSenders path when no turn outputs exist. + */ + const buildPairedTurnPrompt = ( + taskId: string, + chatJid: string, + timezone: string, + missedMessages: NewMessage[], + ): string => { + const turnOutputs = getPairedTurnOutputs(taskId); + if (turnOutputs.length === 0) { + // No stored outputs yet — fall back to Discord messages + return formatMessages( + labelPairedSenders(chatJid, missedMessages), + timezone, + ); + } + + // Human messages from the missed messages (exclude bot messages) + const humanMessages = missedMessages.filter((m) => !m.is_bot_message); + const agentMessages = turnOutputsToMessages(turnOutputs, chatJid); + + // Merge human + agent messages chronologically + const allMessages = [...humanMessages, ...agentMessages].sort( + (a, b) => a.timestamp.localeCompare(b.timestamp), + ); + + return formatMessages(allMessages, timezone); + }; + const isBotOnlyPairedRoomTurn = ( chatJid: string, messages: NewMessage[], @@ -655,10 +703,17 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { ); } - const arbiterMessages = labelPairedSenders( - chatJid, - getRecentChatMessages(chatJid, 20), + const arbiterTurnOutputs = getPairedTurnOutputs( + pendingReviewTask.id, ); + const recentMsgs = getRecentChatMessages(chatJid, 20); + const arbiterMessages = + arbiterTurnOutputs.length > 0 + ? [ + ...recentMsgs.filter((m) => !m.is_bot_message), + ...turnOutputsToMessages(arbiterTurnOutputs, chatJid), + ].sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + : labelPairedSenders(chatJid, recentMsgs); const arbiterPrompt = buildArbiterContextPrompt({ chatJid, taskId: pendingReviewTask.id, @@ -820,10 +875,16 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const turnRole = resolveActiveRole(taskStatus); let prompt: string; if (turnRole === 'arbiter' && pendingTaskForChannel) { - const arbiterMsgs = labelPairedSenders( - chatJid, - getRecentChatMessages(chatJid, 20), - ); + // Prefer direct turn outputs; fall back to Discord messages + const turnOutputs = getPairedTurnOutputs(pendingTaskForChannel.id); + const arbiterRecentMsgs = getRecentChatMessages(chatJid, 20); + const arbiterMsgs = + turnOutputs.length > 0 + ? [ + ...arbiterRecentMsgs.filter((m) => !m.is_bot_message), + ...turnOutputsToMessages(turnOutputs, chatJid), + ].sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + : labelPairedSenders(chatJid, arbiterRecentMsgs); prompt = buildArbiterContextPrompt({ chatJid, taskId: pendingTaskForChannel.id, @@ -831,6 +892,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { timezone: deps.timezone, messages: arbiterMsgs, }); + } else if (pendingTaskForChannel) { + prompt = buildPairedTurnPrompt( + pendingTaskForChannel.id, + chatJid, + deps.timezone, + missedMessages, + ); } else { prompt = formatMessages( labelPairedSenders(chatJid, missedMessages), @@ -1042,10 +1110,17 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { pendingMessages.length > 0 ? pendingMessages : processableGroupMessages; - const formatted = formatMessages( - labelPairedSenders(chatJid, messagesToSend), - deps.timezone, - ); + const formatted = loopPendingTask + ? buildPairedTurnPrompt( + loopPendingTask.id, + chatJid, + deps.timezone, + messagesToSend, + ) + : formatMessages( + labelPairedSenders(chatJid, messagesToSend), + deps.timezone, + ); const isBotOnlyPairedFollowUp = isBotOnlyPairedRoomTurn( chatJid, messagesToSend, diff --git a/src/types.ts b/src/types.ts index f8bad91..2347955 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,6 +75,15 @@ export interface PairedTask { updated_at: string; } +export interface PairedTurnOutput { + id: number; + task_id: string; + turn_number: number; + role: PairedRoomRole; + output_text: string; + created_at: string; +} + export interface PairedWorkspace { id: string; task_id: string;