/** * committee.ts β€” Multi-model debate UI for squeezr-code. * * Runs the same prompt against 2–3 providers in parallel (whichever are * authenticated). Each model's response is buffered, then displayed as it * completes β€” so the fastest model appears first. After all models finish, * an optional "judge" (claude-sonnet) synthesises the best answer. * * Unlike the classic REPL version (repl.ts), this version: * - Streams results live into the TUI output buffer * - Shows individual timing per model * - Runs a judge synthesis pass * - Works with the custom ANSI renderer (no console.log) */ import { SqAgent } from '../agent/agent.js' import { AuthManager } from '../auth/manager.js' import type { SqConfig } from '../config.js' import type { OutputLine } from './tui-repl.js' import { resolveModelAlias } from './model-picker.js' type AddLines = (...lines: OutputLine[]) => void type MkLine = (kind: OutputLine['kind'], text: string) => OutputLine const MODELS: Array<{ provider: 'anthropic' | 'openai' | 'google'; model: string; label: string }> = [ { provider: 'anthropic', model: 'claude-opus-4-20250514', label: 'Claude Opus' }, { provider: 'openai', model: 'gpt-5.4', label: 'GPT-5.4' }, { provider: 'google', model: 'gemini-2.5-pro', label: 'Gemini 2.5' }, ] const JUDGE_MODEL = 'claude-sonnet-4-20250514' export interface CommitteeResult { label: string text: string elapsedMs: number error?: string } /** * Run committee mode. Calls `addLines` live as each model finishes. * Returns the collected results so tui-repl can optionally run a judge pass. */ export async function runCommitteeTui(opts: { prompt: string authStatus: { anthropic: boolean; openai: boolean; google: boolean } config: SqConfig cwd: string addLines: AddLines mkLine: MkLine scheduleRedraw: () => void /** If true, after all models respond, run claude-sonnet as judge to synthesise. */ withJudge?: boolean }): Promise { const { prompt, authStatus, config, cwd, addLines, mkLine, scheduleRedraw, withJudge = true } = opts // Select authenticated members const members = MODELS.filter(m => authStatus[m.provider]) if (members.length < 2) { addLines(mkLine('error', `Committee needs β‰₯ 2 authenticated providers (you have ${members.length}).`)) addLines(mkLine('info', ' Run /login anthropic / /login openai / /login google to add more.')) return [] } // Header addLines( mkLine('info', ''), mkLine('info', ` πŸ› Committee β€” ${members.map(m => m.label).join(' Β· ')}`), mkLine('info', ' Running all models in parallel…'), mkLine('info', ''), ) scheduleRedraw() const start = Date.now() const results: CommitteeResult[] = [] // Run all members in parallel; display each as it finishes (race order) const promises = members.map(async (member) => { const t0 = Date.now() let text = '' let ag: InstanceType | null = null try { const auth = new AuthManager() await auth.init() ag = new SqAgent(auth, { defaultModel: resolveModelAlias(member.model) || member.model, permissions: 'bypass', transplant: { warnThreshold: 95, autoThreshold: 100 }, }) for await (const event of ag.send(prompt, { cwd })) { if (event.type === 'text' && event.text) text += event.text if (event.type === 'done') break } } catch (err) { return { label: member.label, text: '', elapsedMs: Date.now() - t0, error: err instanceof Error ? err.message : String(err), } } finally { try { ag?.shutdown() } catch { /* ignore */ } } return { label: member.label, text, elapsedMs: Date.now() - t0 } }) // As each promise settles, render immediately for (const p of promises.map(p => p.then(r => r))) { const result = await p results.push(result) const elapsed = (result.elapsedMs / 1000).toFixed(1) if (result.error) { addLines( mkLine('info', ` β–Έ ${result.label} ${elapsed}s`), mkLine('error', ` ${result.error}`), mkLine('turn_end', ''), ) } else { addLines(mkLine('agent_header', '')) // shows "Squeezr" header β€” reuse for visual break addLines(mkLine('info', ` β–Έ ${result.label} ${elapsed}s`)) for (const line of result.text.split('\n')) { addLines(mkLine('agent_body', line)) } addLines(mkLine('turn_end', '')) } scheduleRedraw() } const totalElapsed = ((Date.now() - start) / 1000).toFixed(1) addLines( mkLine('info', ''), mkLine('info', ` ═ All ${members.length} models responded in ${totalElapsed}s ═`), ) // ── Judge synthesis ────────────────────────────────────────────────── if (withJudge && results.filter(r => !r.error).length >= 2) { const successResponses = results.filter(r => !r.error) addLines( mkLine('info', ''), mkLine('info', ` βš– Judge (${JUDGE_MODEL.split('-').slice(0,2).join('-')}) synthesising…`), ) scheduleRedraw() const judgePrompt = [ 'You are a synthesis judge. Multiple AI models answered the following user question:', '', `ORIGINAL QUESTION: ${prompt}`, '', ...successResponses.map(r => `--- ${r.label} ---\n${r.text}\n`), '--- END ---', '', 'Synthesise the best combined answer. Take the strongest points from each response.', 'Be concise. If the responses agree, say so briefly. If they disagree, explain why and give your verdict.', ].join('\n') let judgeText = '' let judgeAg: InstanceType | null = null try { const auth = new AuthManager() await auth.init() judgeAg = new SqAgent(auth, { defaultModel: resolveModelAlias(JUDGE_MODEL) || JUDGE_MODEL, permissions: 'bypass', transplant: { warnThreshold: 95, autoThreshold: 100 }, }) for await (const event of judgeAg.send(judgePrompt, { cwd })) { if (event.type === 'text' && event.text) judgeText += event.text if (event.type === 'done') break } addLines(mkLine('agent_header', '')) addLines(mkLine('info', ' βš– Synthesis')) for (const line of judgeText.split('\n')) addLines(mkLine('agent_body', line)) addLines(mkLine('turn_end', '')) } catch (e) { addLines(mkLine('error', ` Judge failed: ${e instanceof Error ? e.message : String(e)}`)) } finally { try { judgeAg?.shutdown() } catch { /* ignore */ } } scheduleRedraw() } addLines( mkLine('info', ''), mkLine('info', ' Ask a follow-up to any model: @opus / @gpt / @gemini '), mkLine('info', ''), ) scheduleRedraw() return results }