From ac553cb6b48e984856015b68dd3d9ebb052ffcb8 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 16:37:50 -0400 Subject: [PATCH] feat: task decomposition view during execution (#5) At the start of job execution, a Haiku call decomposes the user's request into 2-4 named sub-steps. Steps are broadcast via WebSocket as `job_steps` and `job_step_update` events. The Workshop renders a live checklist panel near Gamma that checks off steps as streaming progresses using a character-count heuristic, then collapses to "Done" on completion. Steps are stored with the job record. - agent.ts: add `decomposeRequest` (Haiku, stub-safe) - event-bus.ts: add `DecompositionEvent` types (job:steps, job:step_update) - jobs.ts: call decompose before streaming; advance steps heuristically - events.ts: translate new bus events to WS messages - websocket.js: handle job_steps / job_step_update / collapse on complete - ui.js: showJobSteps / updateJobStep / collapseJobSteps panel - jobs schema: decomposition_steps text column - migration 0010: add decomposition_steps column Fixes #5 Co-Authored-By: Claude Sonnet 4.6 --- artifacts/api-server/src/lib/agent.ts | 41 ++++++ artifacts/api-server/src/lib/event-bus.ts | 6 +- artifacts/api-server/src/routes/events.ts | 15 +++ artifacts/api-server/src/routes/jobs.ts | 60 +++++++++ lib/db/migrations/0010_task_decomposition.sql | 4 + lib/db/src/schema/jobs.ts | 4 + the-matrix/js/ui.js | 126 ++++++++++++++++++ the-matrix/js/websocket.js | 17 ++- 8 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 lib/db/migrations/0010_task_decomposition.sql diff --git a/artifacts/api-server/src/lib/agent.ts b/artifacts/api-server/src/lib/agent.ts index 13939bb..c8e2709 100644 --- a/artifacts/api-server/src/lib/agent.ts +++ b/artifacts/api-server/src/lib/agent.ts @@ -53,6 +53,13 @@ const STUB_RESULT = "Stub response: Timmy is running in stub mode (no Anthropic API key). " + "Configure AI_INTEGRATIONS_ANTHROPIC_API_KEY to enable real AI responses."; +const STUB_DECOMPOSITION_STEPS = [ + "Understand the request", + "Research and gather information", + "Draft a comprehensive response", + "Review and finalise output", +]; + const STUB_CHAT_REPLIES = [ "Ah, a visitor! *adjusts hat* The crystal ball sensed your presence. What do you seek?", "By the ancient runes! In stub mode I cannot reach the stars, but my wisdom remains. Ask away!", @@ -248,6 +255,40 @@ If the user asks how to run their own Timmy or self-host this service, enthusias return { result: fullText, inputTokens, outputTokens }; } + /** + * Decompose a request into 2-4 named sub-steps (#5). + * Uses the cheap eval model (Haiku) so this adds minimal latency at job start. + * In stub mode, returns canned steps so the full checklist UI is exercised. + */ + async decomposeRequest(requestText: string): Promise { + if (STUB_MODE) { + await new Promise((r) => setTimeout(r, 150)); + return [...STUB_DECOMPOSITION_STEPS]; + } + + const client = await getClient(); + const message = await client.messages.create({ + model: this.evalModel, + max_tokens: 256, + system: `You are a task decomposition assistant. Break the user's request into 2-4 short, specific sub-step labels that describe how you will fulfil it. Each label should be 3-8 words. Respond ONLY with a valid JSON array of strings, e.g. ["Step one label", "Step two label"].`, + messages: [{ role: "user", content: `Decompose this request into sub-steps: ${requestText}` }], + }); + + const block = message.content[0]; + if (block.type !== "text") return [...STUB_DECOMPOSITION_STEPS]; + + try { + const raw = block.text!.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim(); + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed) && parsed.length >= 2 && parsed.length <= 4) { + return (parsed as unknown[]).map(String).slice(0, 4); + } + } catch { + logger.warn("decomposeRequest parse failed, using stubs", { text: block.text }); + } + return [...STUB_DECOMPOSITION_STEPS]; + } + /** * Quick free chat reply — called for visitor messages in the Workshop. * Uses the cheaper eval model with a wizard persona and a 150-token limit diff --git a/artifacts/api-server/src/lib/event-bus.ts b/artifacts/api-server/src/lib/event-bus.ts index 79f8edb..955fd33 100644 --- a/artifacts/api-server/src/lib/event-bus.ts +++ b/artifacts/api-server/src/lib/event-bus.ts @@ -18,7 +18,11 @@ export type DebateEvent = export type CostEvent = | { type: "cost:update"; jobId: string; sats: number; phase: "eval" | "work" | "session"; isFinal: boolean }; -export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent; +export type DecompositionEvent = + | { type: "job:steps"; jobId: string; steps: string[] } + | { type: "job:step_update"; jobId: string; activeStep: number; completedSteps: number[] }; + +export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | DecompositionEvent; class EventBus extends EventEmitter { emit(event: "bus", data: BusEvent): boolean; diff --git a/artifacts/api-server/src/routes/events.ts b/artifacts/api-server/src/routes/events.ts index 1ef6511..d3695c3 100644 --- a/artifacts/api-server/src/routes/events.ts +++ b/artifacts/api-server/src/routes/events.ts @@ -257,6 +257,21 @@ function translateEvent(ev: BusEvent): object | null { isFinal: ev.isFinal, }; + // ── Task decomposition (#5) ─────────────────────────────────────────────── + case "job:steps": + return { + type: "job_steps", + jobId: ev.jobId, + steps: ev.steps, + }; + case "job:step_update": + return { + type: "job_step_update", + jobId: ev.jobId, + activeStep: ev.activeStep, + completedSteps: ev.completedSteps, + }; + default: return null; } diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 341ba68..5f8527a 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -254,10 +254,70 @@ async function runWorkInBackground( try { eventBus.publish({ type: "job:state", jobId, state: "executing" }); + // ── Task decomposition (#5) ───────────────────────────────────────────── + // Decompose the request into 2-4 named sub-steps via Haiku, then broadcast + // the step list so the Workshop checklist panel can appear immediately. + let decompositionSteps: string[] = []; + try { + decompositionSteps = await agentService.decomposeRequest(request); + // Persist steps in job record (non-fatal if it fails) + await db + .update(jobs) + .set({ decompositionSteps: JSON.stringify(decompositionSteps), updatedAt: new Date() }) + .where(eq(jobs.id, jobId)); + eventBus.publish({ type: "job:steps", jobId, steps: decompositionSteps }); + // Start with step 0 active + eventBus.publish({ type: "job:step_update", jobId, activeStep: 0, completedSteps: [] }); + } catch (decompErr) { + logger.warn("decomposeRequest failed, continuing without steps", { jobId, err: String(decompErr) }); + } + + // ── Step advancement heuristic ────────────────────────────────────────── + // Divide the expected output into equal chunks; advance the active step + // each time that fraction of streamed characters has been received. + let charCount = 0; + let currentStep = 0; + const numSteps = decompositionSteps.length; + const completedStepsSoFar: number[] = []; + + // Rough expected output length — anything over ~200 chars triggers first step. + // Steps are advanced at equal intervals: 25% / 50% / 75% of 600 estimated chars. + const ESTIMATED_TOTAL_CHARS = 600; + const stepThreshold = numSteps > 0 ? ESTIMATED_TOTAL_CHARS / numSteps : Infinity; + const workResult = await agentService.executeWorkStreaming(request, (delta) => { streamRegistry.write(jobId, delta); + + if (numSteps > 0) { + charCount += delta.length; + const expectedStep = Math.min( + Math.floor(charCount / stepThreshold), + numSteps - 1, + ); + if (expectedStep > currentStep) { + completedStepsSoFar.push(currentStep); + currentStep = expectedStep; + eventBus.publish({ + type: "job:step_update", + jobId, + activeStep: currentStep, + completedSteps: [...completedStepsSoFar], + }); + } + } }); + // Mark all steps complete when streaming finishes + if (numSteps > 0) { + const allCompleted = Array.from({ length: numSteps }, (_, i) => i); + eventBus.publish({ + type: "job:step_update", + jobId, + activeStep: -1, // -1 = all done, no active step + completedSteps: allCompleted, + }); + } + streamRegistry.end(jobId); latencyHistogram.record("work_phase", Date.now() - workStart); diff --git a/lib/db/migrations/0010_task_decomposition.sql b/lib/db/migrations/0010_task_decomposition.sql new file mode 100644 index 0000000..cccfecc --- /dev/null +++ b/lib/db/migrations/0010_task_decomposition.sql @@ -0,0 +1,4 @@ +-- Task decomposition view (#5) +-- Stores the Haiku-generated step labels for a job execution as a JSON array. + +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS decomposition_steps TEXT; diff --git a/lib/db/src/schema/jobs.ts b/lib/db/src/schema/jobs.ts index b5538c0..7192ac0 100644 --- a/lib/db/src/schema/jobs.ts +++ b/lib/db/src/schema/jobs.ts @@ -52,6 +52,10 @@ export const jobs = pgTable("jobs", { refundState: text("refund_state").$type<"not_applicable" | "pending" | "paid">(), refundPaymentHash: text("refund_payment_hash"), + // ── Task decomposition (#5) ────────────────────────────────────────────── + // JSON array of 2-4 step labels generated by Haiku at execution start. + decompositionSteps: text("decomposition_steps"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); diff --git a/the-matrix/js/ui.js b/the-matrix/js/ui.js index 70f293e..ece64d8 100644 --- a/the-matrix/js/ui.js +++ b/the-matrix/js/ui.js @@ -305,3 +305,129 @@ export function appendDebateMessage(agent, argument, isVerdict, accepted) { export function loadChatHistory() { return []; } export function saveChatHistory() {} + +// ── Task decomposition checklist panel (#5) ─────────────────────────────────── +// Appears near the active Gamma agent during job execution. +// Steps are checked off as streaming progresses, then collapses to "Done". + +let $stepsPanel = null; +let _stepElements = []; +let _collapseTimer = null; + +function _ensureStepsPanel() { + if ($stepsPanel) return $stepsPanel; + $stepsPanel = document.getElementById('timmy-steps-panel'); + if (!$stepsPanel) { + $stepsPanel = document.createElement('div'); + $stepsPanel.id = 'timmy-steps-panel'; + $stepsPanel.style.cssText = [ + 'position:fixed;bottom:80px;right:16px', + 'background:rgba(0,20,40,0.88);border:1px solid #224466', + 'border-radius:6px;padding:8px 12px', + 'font-size:11px;font-family:"Courier New",monospace', + 'color:#88bbdd;min-width:200px;max-width:280px', + 'pointer-events:none;z-index:10', + 'transition:opacity .4s;opacity:0', + ].join(';'); + document.body.appendChild($stepsPanel); + } + return $stepsPanel; +} + +/** + * Show the checklist panel with the given step labels. + * Called on `job_steps` WebSocket message. + */ +export function showJobSteps(steps) { + clearTimeout(_collapseTimer); + const panel = _ensureStepsPanel(); + + // Clear previous content + panel.innerHTML = ''; + _stepElements = []; + + const header = document.createElement('div'); + header.textContent = '⚙ Working…'; + header.style.cssText = 'color:#aaccee;font-weight:bold;margin-bottom:5px;font-size:10px;letter-spacing:1px;'; + panel.appendChild(header); + + steps.forEach((label, i) => { + const row = document.createElement('div'); + row.style.cssText = 'display:flex;align-items:center;gap:5px;margin:2px 0;transition:color .3s;'; + + const check = document.createElement('span'); + check.textContent = '○'; + check.style.cssText = 'min-width:12px;color:#336655;'; + + const text = document.createElement('span'); + text.textContent = label; + + row.appendChild(check); + row.appendChild(text); + panel.appendChild(row); + _stepElements.push({ row, check, text, index: i }); + }); + + panel.style.opacity = '1'; +} + +/** + * Advance the visual state of steps. + * activeStep = currently running step index (-1 = all done). + * completedSteps = array of step indices that are finished. + * Called on `job_step_update` WebSocket message. + */ +export function updateJobStep(activeStep, completedSteps) { + if (!$stepsPanel || _stepElements.length === 0) return; + + const completedSet = new Set(completedSteps); + + _stepElements.forEach(({ row, check, text, index }) => { + if (completedSet.has(index)) { + // Completed: green checkmark + check.textContent = '✓'; + check.style.color = '#44cc88'; + text.style.color = '#667788'; + row.style.opacity = '0.7'; + } else if (index === activeStep) { + // Active: pulsing indicator + check.textContent = '▶'; + check.style.color = '#ffcc44'; + text.style.color = '#eeddaa'; + row.style.opacity = '1'; + // Pulse animation via inline keyframe trick + row.style.animation = 'none'; + void row.offsetHeight; // reflow + check.style.transition = 'opacity 0.5s'; + } else { + // Pending + check.textContent = '○'; + check.style.color = '#336655'; + text.style.color = '#88bbdd'; + row.style.opacity = '1'; + } + }); +} + +/** + * Collapse the checklist panel to a single "Done" line, then fade out. + * Called on `job_completed` WebSocket message. + */ +export function collapseJobSteps() { + if (!$stepsPanel) return; + clearTimeout(_collapseTimer); + + // Replace content with a Done summary + $stepsPanel.innerHTML = ''; + const done = document.createElement('div'); + done.textContent = '✓ Done'; + done.style.cssText = 'color:#44cc88;font-weight:bold;letter-spacing:1px;'; + $stepsPanel.appendChild(done); + $stepsPanel.style.opacity = '1'; + + // Fade out after 2.5 s + _collapseTimer = setTimeout(() => { + if ($stepsPanel) $stepsPanel.style.opacity = '0'; + _stepElements = []; + }, 2500); +} diff --git a/the-matrix/js/websocket.js b/the-matrix/js/websocket.js index ad1d412..d710407 100644 --- a/the-matrix/js/websocket.js +++ b/the-matrix/js/websocket.js @@ -1,5 +1,5 @@ import { setAgentState, setSpeechBubble, applyAgentStates, setMood } from './agents.js'; -import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js'; +import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker, showJobSteps, updateJobStep, collapseJobSteps } from './ui.js'; import { sentiment } from './edge-worker-client.js'; import { setLabelState } from './hud-labels.js'; @@ -105,6 +105,7 @@ function handleMessage(msg) { setLabelState(msg.agentId, 'idle'); } appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} complete`); + collapseJobSteps(); break; } @@ -140,6 +141,20 @@ function handleMessage(msg) { break; } + case 'job_steps': { + // Task decomposition (#5): render checklist panel with step labels + if (Array.isArray(msg.steps) && msg.steps.length > 0) { + showJobSteps(msg.steps); + } + break; + } + + case 'job_step_update': { + // Task decomposition (#5): advance active step and mark completed ones + updateJobStep(msg.activeStep, msg.completedSteps || []); + break; + } + case 'cost_update': { // Real-time cost ticker (#68): show estimated cost when job starts, // update to final charged amount when job completes. -- 2.43.0