@goodandready/dsh-moa 0.2.9 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/moa-runner.js CHANGED
@@ -1,17 +1,16 @@
1
1
  /**
2
- * Mixture of Agents (MoA) Orchestrator Engine (#1 - #16)
2
+ * DeepSeek Harness Mixture of Agents (MoA) — Runner Engine
3
3
  *
4
- * Implements:
5
- * 1. Parallel candidate proposers with isolated workspaces (.moa/candidate-X/)
6
- * 2. Real-time multi-file block extractor and project context collector
7
- * 3. Iterative modification (Refinement) vs greenfield project awareness
8
- * 4. Aggregator / Judge synthesis prompt and candidate winner evaluation
9
- * 5. Automatic promotion of winning candidate files to workspace root
10
- * 6. Native Live Canvas visual preview generation for interactive apps
11
- * 7. Real-time cost tracking, token metrics and history logging
12
- * 8. Zero-latency async push-queue streaming for llm/stream turns
4
+ * Implements the full ensemble pipeline:
5
+ * - Parallel fan-out to reference models with transient retry & quorum straggler mitigation
6
+ * - Prompt caching aligned message structures
7
+ * - Curator synthesis & antipatterns evaluation
8
+ * - Aggregator fallback chain for resilience
9
+ * - Live token streaming for aggregator
10
+ * - File promotion & Live Canvas sandbox preview
13
11
  */
14
12
 
13
+ import path from 'node:path'
15
14
  import {
16
15
  extractFileBlocks,
17
16
  collectProjectContext,
@@ -20,7 +19,6 @@ import {
20
19
  promoteCandidateWorkspace,
21
20
  cleanMoaWorkspaces,
22
21
  } from './file-workspace.js'
23
-
24
22
  import { estimateTokenCost } from './pricing.js'
25
23
  import { recordMoaRun } from './history.js'
26
24
 
@@ -39,6 +37,25 @@ When generating files for a project, explicitly specify file paths using fenced
39
37
  \`\`\`css file="style.css"
40
38
  \`\`\``
41
39
 
40
+ export const ANTIPATTERNS_RUBRIC = `### 🚫 Strict Antipatterns Evaluation Checklist
41
+ Penalize and strictly downgrade candidates exhibiting any of the following flaws:
42
+ 1. 🚫 Lazy Code & Placeholders:
43
+ - Phrases like "// ... rest of code unchanged", "/* TODO: implement */", incomplete functions or stubs returning null/mock without notice.
44
+ 2. 🚫 Blind Mocking:
45
+ - Hardcoded dummy arrays instead of real dynamic logic, user input handling, or real API integration.
46
+ 3. 🚫 Silent Failures & Missing Error Handling:
47
+ - Missing try/catch around async calls, fetch, JSON.parse; lack of user-facing fallback or retry states.
48
+ 4. 🚫 AI Slop UI & Poor Ergonomics:
49
+ - Generic purple/cyan gradients on pure black backgrounds, blurry drop-shadows without borders, lack of typographic hierarchy, low contrast (e.g. light gray text on white).
50
+ 5. 🚫 Missing UI States:
51
+ - Lack of loading state (spinner/skeleton), error display with retry action, or empty state when no data exists.
52
+ 6. 🚫 Broken Layout & Mobile Incompatibility:
53
+ - Fixed pixel widths (e.g. width: 800px) overflowing small viewports; unscrollable modal dialogs.
54
+ 7. 🚫 Monolithic God Objects & Overengineering:
55
+ - Dumping 1000+ lines into a single unmaintainable file, or building 10+ abstraction layers for a 2-function task.
56
+ 8. 🚫 Context Amnesia & Regressions:
57
+ - Dropping or breaking previously functioning project features while adding new code.`
58
+
42
59
  export function slotLabel(slot) {
43
60
  if (!slot) return 'unknown'
44
61
  if (typeof slot === 'string') return slot
@@ -128,28 +145,81 @@ export function isBroadPromptRequiringQuestions(userPrompt = '', messages = [])
128
145
 
129
146
  export function buildQuestionSynthesisPrompt(userPrompt, referenceOutputs = []) {
130
147
  const joined = referenceOutputs
131
- .map((r, i) => `Советник ${i + 1} (${r.label}):\n${r.text}`)
148
+ .map((r, i) => `Advisor ${i + 1} (${r.label}):\n${r.text}`)
132
149
  .join('\n\n')
133
150
 
134
- return `Ты — ведущий архитектор и судья в архитектуре Mixture of Agents (MoA).
135
- Пользователь дал задачу:
151
+ return `You are the lead architect and judge in a Mixture of Agents (MoA) ensemble.
152
+ The user gave the task:
136
153
  "${userPrompt}"
137
154
 
138
- Советники предложили следующие развилки и уточнения:
155
+ The advisors proposed the following decision points and clarifications:
156
+ ${joined}
157
+
158
+ Your task is to synthesize a single, compact, friendly and structured questionnaire (2-4 questions) in the same language as the user's prompt.
159
+ Each question must offer 2-3 concrete recommended answer options (e.g.: 1. Format: single-file HTML/JS or React? 2. Style: minimalism, iOS or neubrutalism?).
160
+ At the end, add a note that the user can answer briefly (e.g.: "1, 2, dark theme") or trust the defaults.`
161
+ }
162
+
163
+ export function buildCuratorSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCriteria = '') {
164
+ const joined = referenceOutputs
165
+ .map((r, i) => {
166
+ const fileSummary = (r.files && r.files.length > 0)
167
+ ? ` [Files created: ${r.files.map((f) => f.relativePath).join(', ')}]`
168
+ : ''
169
+ let textContent = r.text
170
+ if (referenceOutputs.length >= 3 && textContent.length > 3000) {
171
+ textContent = stripOrSummarizeCode(textContent)
172
+ }
173
+ return `Candidate ${i + 1} — ${r.label}:${fileSummary}\n${textContent}`
174
+ })
175
+ .join('\n\n')
176
+
177
+ const criteriaBlock = judgeCriteria && judgeCriteria.trim()
178
+ ? `\n### 🎯 Additional evaluation criteria:\n${judgeCriteria.trim()}\n`
179
+ : ''
180
+
181
+ return `You are the expert Lead Technical Curator and Solution Architect in a Mixture of Agents (MoA) ensemble.
182
+ Your mission is not merely to select one candidate, but to synthesize the optimal solution by extracting the finest components from each candidate's response, identifying potential flaws using the strict antipatterns rubric, and selecting/advising which single agent model is best suited to assemble the final unified deliverable.
183
+
184
+ User Request:
185
+ ${userPrompt}
186
+ ${criteriaBlock}
187
+ Candidate proposals:
139
188
  ${joined}
140
189
 
141
- Твоя задача — синтезировать единый, компактный, дружелюбный и структурированный опросник (2-4 вопроса) для пользователя на русском языке.
142
- Каждый вопрос должен предлагать 2-3 конкретных рекомендуемых варианта ответа (например: 1. Формат: HTML/JS в одном файле или React? 2. Стиль: Минимализм, iOS или Необрутализм?).
143
- В конце добавь примечание, что пользователь может ответить кратко (например: "1, 2, темная тема") или довериться выбору по умолчанию.`
190
+ ${ANTIPATTERNS_RUBRIC}
191
+
192
+ Instructions:
193
+ Your response MUST be structured into three clear parts (respond in the same language as the user's prompt, e.g. Russian):
194
+
195
+ ### 1. 🔍 Curator Analysis & Component Breakdown
196
+ - For EACH candidate, provide:
197
+ - ⭐ **Strongest aspects** (e.g. robust architecture, superior UI/CSS design, clean data validation).
198
+ - ⚠️ **Defects or Antipatterns** found from the checklist above.
199
+ - Highlight which candidate provides the best foundation for each component (e.g. Candidate 1 for core logic, Candidate 2 for visual UI).
200
+
201
+ ### 2. 🧩 Assembly Recipe & Recommended Master Assembler
202
+ - Recommend the best single agent model to assemble and finalize the solution:
203
+ RECOMMENDED_ASSEMBLER: <number from 1 to N> (<provider:model>)
204
+ - State the machine winner index marker for file promotion:
205
+ WINNER_CANDIDATE_INDEX: <number from 1 to N>
206
+ - Provide the exact blueprint / instructions for combining the best pieces into a unified deliverable.
207
+
208
+ ### 3. 🚀 Unified Solution & Execution Guide
209
+ - Present the final synthesized code or complete instructions combining the best candidate features.
210
+ - How to run, verify, and use the deliverable.`
144
211
  }
145
212
 
146
- export function buildSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCriteria = '') {
213
+ export function buildSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCriteria = '', options = {}) {
214
+ if (options.curatorSynthesis) {
215
+ return buildCuratorSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria)
216
+ }
217
+
147
218
  const joined = referenceOutputs
148
219
  .map((r, i) => {
149
220
  const fileSummary = (r.files && r.files.length > 0)
150
- ? ` [Созданные файлы: ${r.files.map((f) => f.relativePath).join(', ')}]`
221
+ ? ` [Files created: ${r.files.map((f) => f.relativePath).join(', ')}]`
151
222
  : ''
152
- // If 3+ candidates, summarize text to prevent judge context overflow (#13)
153
223
  let textContent = r.text
154
224
  if (referenceOutputs.length >= 3 && textContent.length > 3000) {
155
225
  textContent = stripOrSummarizeCode(textContent)
@@ -159,7 +229,7 @@ export function buildSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCri
159
229
  .join('\n\n')
160
230
 
161
231
  const criteriaBlock = judgeCriteria && judgeCriteria.trim()
162
- ? `\n### 🎯 Дополнительные критерии оценки от пользователя:\n${judgeCriteria.trim()}\n`
232
+ ? `\n### 🎯 Additional evaluation criteria from the user:\n${judgeCriteria.trim()}\n`
163
233
  : ''
164
234
 
165
235
  return `You are the expert aggregator/judge in a Mixture of Agents (MoA) process. You evaluate solutions from multiple candidate models, judge which one is best (or how to combine their best parts), and deliver the final authoritative verdict and solution.
@@ -170,37 +240,53 @@ ${criteriaBlock}
170
240
  Reference responses from candidate models:
171
241
  ${joined}
172
242
 
243
+ ${ANTIPATTERNS_RUBRIC}
244
+
173
245
  Instructions:
174
246
  Your response MUST be structured into three clear parts (respond in the same language as the user's prompt, e.g. Russian):
175
247
 
176
- ### 1. ⚖️ Вердикт судьи и анализ вариантов
177
- - **Чей вариант выбран**: Чётко укажи имя модели и номер кандидата (например: "Победитель: Кандидат 1 (opencode-go:deepseek-v4-flash)" или "Выбран вариант модели Reference 1 (label)").
178
- - Обязательно добавь машинный маркер выбора победителя:
179
- WINNER_CANDIDATE_INDEX: <число от 1 до N>
180
- - **Почему сделан этот выбор**: Подробно сравни код, архитектуру, сильные стороны, недочёты и надёжность всех кандидатов.
248
+ ### 1. ⚖️ Judge verdict and comparative analysis
249
+ - **Winner**: clearly name the model and candidate number (e.g. "Winner: Candidate 1 (opencode-go:deepseek-v4-flash)" or "Reference 1 (label) is chosen").
250
+ - Always add the machine winner-selection marker:
251
+ WINNER_CANDIDATE_INDEX: <number from 1 to N>
252
+ - **Why this choice**: compare code, architecture, strengths, weaknesses and reliability of all candidates in detail.
181
253
 
182
- ### 2. 📁 Созданные файлы проекта
183
- - Перечисли файлы победителя, которые были перенесены в корень проекта, и их назначение.
254
+ ### 2. 📁 Project files created
255
+ - List the winner files promoted to the project root and their purpose.
184
256
 
185
- ### 3. 🚀 Инструкция по запуску и использованию
186
- - Опиши, как открыть и запустить созданный проект.`
257
+ ### 3. 🚀 How to run and use
258
+ - Describe how to open and run the created project.`
187
259
  }
188
260
 
189
- export function parseWinnerIndex(judgeText, defaultIndex = 1) {
261
+ export function parseWinnerIndex(judgeText, defaultIndex = 1, candidateCount = Infinity) {
190
262
  if (!judgeText || typeof judgeText !== 'string') return defaultIndex
263
+ const inRange = (idx) => !isNaN(idx) && idx >= 1 && idx <= candidateCount
191
264
  const match = /WINNER_CANDIDATE_INDEX:\s*(\d+)/i.exec(judgeText)
192
265
  if (match) {
193
266
  const idx = parseInt(match[1], 10)
194
- if (!isNaN(idx) && idx >= 1) return idx
267
+ if (inRange(idx)) return idx
195
268
  }
196
- const candMatch = /(?:Кандидат|Reference)\s*(\d+)\b/i.exec(judgeText)
269
+ const candMatch = /(?:Кандидат|Candidate|Reference)\s*(\d+)\b/i.exec(judgeText)
197
270
  if (candMatch) {
198
271
  const idx = parseInt(candMatch[1], 10)
199
- if (!isNaN(idx) && idx >= 1) return idx
272
+ if (inRange(idx)) return idx
200
273
  }
201
274
  return defaultIndex
202
275
  }
203
276
 
277
+ export function parseRecommendedAssembler(judgeText, defaultIndex = 1, candidateCount = Infinity) {
278
+ if (!judgeText || typeof judgeText !== 'string') return { index: defaultIndex, label: '' }
279
+ const inRange = (idx) => !isNaN(idx) && idx >= 1 && idx <= candidateCount
280
+ const match = /RECOMMENDED_ASSEMBLER:\s*(\d+)(?:\s*\(([^)]+)\))?/i.exec(judgeText)
281
+ if (match) {
282
+ const idx = parseInt(match[1], 10)
283
+ if (inRange(idx)) {
284
+ return { index: idx, label: match[2]?.trim() || '' }
285
+ }
286
+ }
287
+ return { index: defaultIndex, label: '' }
288
+ }
289
+
204
290
  export function parseMoACommand(text, presets = []) {
205
291
  if (typeof text !== 'string' || !text.startsWith('/moa')) {
206
292
  return null
@@ -241,7 +327,28 @@ export function parseMoACommand(text, presets = []) {
241
327
  }
242
328
 
243
329
  /**
244
- * Dispatches queries to all reference models in parallel.
330
+ * Invokes LLM call with transient retry for recoverable network/rate-limit errors.
331
+ */
332
+ export async function callWithTransientRetry(callLlmFn, callArgs, maxRetries = 0, retryDelayMs = 1200) {
333
+ let attempt = 0
334
+ while (true) {
335
+ try {
336
+ return await callLlmFn(callArgs)
337
+ } catch (err) {
338
+ attempt++
339
+ const msg = err?.message || String(err)
340
+ const isTransient = /429|rate limit|502|503|504|econnreset|etimedout|socket hang up/i.test(msg)
341
+ if (attempt <= maxRetries && isTransient) {
342
+ await new Promise((r) => setTimeout(r, retryDelayMs))
343
+ continue
344
+ }
345
+ throw err
346
+ }
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Dispatches queries to all reference models in parallel with transient retry and quorum straggler mitigation.
245
352
  */
246
353
  export async function runReferencesParallel(references, messages, options = {}, callLlm, onProgress) {
247
354
  if (!Array.isArray(references) || references.length === 0) {
@@ -250,76 +357,198 @@ export async function runReferencesParallel(references, messages, options = {},
250
357
 
251
358
  const advisoryMessages = cleanAdvisoryMessages(messages)
252
359
  const systemPrompt = options.systemPrompt || REFERENCE_SYSTEM_PROMPT
360
+ // Deterministic prefix for optimal prompt caching hit rate (#2.3)
253
361
  const fullMessages = [{ role: 'system', content: systemPrompt }, ...advisoryMessages]
254
362
  const timeoutMs = options.timeoutMs ?? 75000
363
+ const maxRetries = options.maxRetries ?? 0
364
+ const quorumEnabled = Boolean(options.quorumEnabled)
365
+ const gracePeriodMs = (options.gracePeriodSec ?? 10) * 1000
366
+
367
+ const total = references.length
368
+ let finishedCount = 0
369
+ const results = new Array(total)
370
+ const abortControllers = references.map(() => new AbortController())
371
+
372
+ let onTaskFinished = null
373
+ const notifyFinished = () => {
374
+ if (typeof onTaskFinished === 'function') onTaskFinished()
375
+ }
255
376
 
256
- const tasks = references.map(async (slot, i) => {
377
+ if (typeof onProgress === 'function') {
378
+ onProgress(`⚡ *Launching ${total} candidate models in parallel...*\n`)
379
+ }
380
+
381
+ references.forEach((slot, i) => {
257
382
  const label = slotLabel(slot)
258
- if (typeof onProgress === 'function') {
259
- onProgress(`⚡ *Кандидат ${i + 1} (${label}) начал генерацию...*\n`)
260
- }
261
383
 
262
- try {
263
- const callPromise = callLlm({
264
- provider: slot.provider,
265
- model: slot.model,
266
- messages: fullMessages,
267
- temperature: options.temperature ?? 0.6,
268
- maxTokens: options.maxTokens ?? 4096,
269
- })
270
-
271
- const timeoutPromise = new Promise((_, reject) => {
272
- const timer = setTimeout(() => reject(new Error(`Timeout after ${timeoutMs / 1000}s`)), timeoutMs)
273
- timer.unref?.()
274
- })
275
-
276
- const res = await Promise.race([callPromise, timeoutPromise])
277
- const text = typeof res === 'string' ? res : (res?.content || res?.text || '')
278
-
279
- const fallbackUsage = (typeof res === 'object' && res?.usage) ? res.usage : {
280
- inputTokens: Math.max(1, Math.round(fullMessages.map((m) => m.content).join('').length / 4)),
281
- outputTokens: Math.max(1, Math.round(text.length / 4)),
384
+ const runOne = async () => {
385
+ try {
386
+ const callPromise = callWithTransientRetry(
387
+ callLlm,
388
+ {
389
+ provider: slot.provider,
390
+ model: slot.model,
391
+ messages: fullMessages,
392
+ temperature: options.temperature ?? 0.6,
393
+ maxTokens: options.maxTokens ?? 4096,
394
+ signal: abortControllers[i].signal,
395
+ },
396
+ maxRetries
397
+ )
398
+
399
+ const timeoutPromise = new Promise((_, reject) => {
400
+ const timer = setTimeout(() => reject(new Error(`Timeout after ${timeoutMs / 1000}s`)), timeoutMs)
401
+ timer.unref?.()
402
+ })
403
+
404
+ const res = await Promise.race([callPromise, timeoutPromise])
405
+ const text = typeof res === 'string' ? res : (res?.content || res?.text || '')
406
+
407
+ const fallbackUsage = (typeof res === 'object' && res?.usage) ? res.usage : {
408
+ inputTokens: Math.max(1, Math.round(fullMessages.map((m) => m.content).join('').length / 4)),
409
+ outputTokens: Math.max(1, Math.round(text.length / 4)),
410
+ }
411
+ const costInfo = estimateTokenCost(slot, fallbackUsage, options.prices)
412
+
413
+ finishedCount++
414
+ if (typeof onProgress === 'function') {
415
+ const costStr = costInfo.costUsd > 0 ? ` (~\${costInfo.costUsd.toFixed(4)})` : ''
416
+ onProgress(`✅ *Candidate ${i + 1}/${total} (${label}) finished${costStr}*\n`)
417
+ }
418
+
419
+ results[i] = {
420
+ index: i + 1,
421
+ slot,
422
+ label,
423
+ text,
424
+ usage: costInfo,
425
+ costUsd: costInfo.costUsd,
426
+ ok: true,
427
+ }
428
+ } catch (err) {
429
+ finishedCount++
430
+ const errMsg = err?.message || String(err)
431
+ console.warn(`[dsh-moa] Reference ${label} failed:`, errMsg)
432
+ if (typeof onProgress === 'function') {
433
+ onProgress(`⚠️ *Candidate ${i + 1}/${total} (${label}) error: ${errMsg}*\n`)
434
+ }
435
+ results[i] = {
436
+ index: i + 1,
437
+ slot,
438
+ label,
439
+ text: `[Model ${label} error: ${errMsg}]`,
440
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
441
+ costUsd: 0,
442
+ ok: false,
443
+ error: errMsg,
444
+ }
445
+ } finally {
446
+ notifyFinished()
282
447
  }
283
- const costInfo = estimateTokenCost(slot, fallbackUsage)
448
+ }
284
449
 
285
- if (typeof onProgress === 'function') {
286
- const costStr = costInfo.costUsd > 0 ? ` (~\$${costInfo.costUsd.toFixed(4)})` : ''
287
- onProgress(`✅ *Кандидат ${i + 1} (${label}) завершил ответ${costStr}*\n`)
288
- }
450
+ runOne()
451
+ })
289
452
 
290
- return {
291
- index: i + 1,
292
- slot,
293
- label,
294
- text,
295
- usage: costInfo,
296
- costUsd: costInfo.costUsd,
297
- ok: true,
298
- }
299
- } catch (err) {
300
- const errMsg = err?.message || String(err)
301
- console.warn(`[dsh-moa] Reference ${label} failed:`, errMsg)
302
- if (typeof onProgress === 'function') {
303
- onProgress(`⚠️ *Кандидат ${i + 1} (${label}) ошибка: ${errMsg}*\n`)
453
+ // Quorum straggler mitigation: exit as soon as grace period expires
454
+ if (quorumEnabled && total >= 3) {
455
+ const quorumTarget = Math.max(2, Math.min(total - 1, Math.ceil(total * 0.6)))
456
+ let graceTimer = null
457
+ let quorumTriggered = false
458
+
459
+ await new Promise((resolve) => {
460
+ const checkQuorum = () => {
461
+ if (finishedCount >= total) {
462
+ if (graceTimer) clearTimeout(graceTimer)
463
+ return resolve()
464
+ }
465
+ if (finishedCount >= quorumTarget && !quorumTriggered) {
466
+ quorumTriggered = true
467
+ if (typeof onProgress === 'function') {
468
+ onProgress(`⏳ *Quorum reached (${finishedCount}/${total}). Grace period ${gracePeriodMs / 1000}s for remaining models...*\n`)
469
+ }
470
+ graceTimer = setTimeout(() => {
471
+ if (typeof onProgress === 'function' && finishedCount < total) {
472
+ onProgress(`⏩ *Grace period expired. Proceeding with ${finishedCount}/${total} ready candidates.*\n`)
473
+ }
474
+ for (let k = 0; k < total; k++) {
475
+ if (!results[k]) {
476
+ try { abortControllers[k].abort(new Error('Quorum grace period timed out')) } catch {}
477
+ }
478
+ }
479
+ resolve()
480
+ }, gracePeriodMs)
481
+ // active timer keeps event loop alive
482
+ }
304
483
  }
305
- return {
306
- index: i + 1,
307
- slot,
308
- label,
309
- text: `[Ошибка модели ${label}: ${errMsg}]`,
310
- usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
311
- costUsd: 0,
312
- ok: false,
313
- error: errMsg,
484
+
485
+ onTaskFinished = checkQuorum
486
+ checkQuorum()
487
+ })
488
+
489
+ for (let i = 0; i < total; i++) {
490
+ if (!results[i]) {
491
+ const slot = references[i]
492
+ const label = slotLabel(slot)
493
+ results[i] = {
494
+ index: i + 1,
495
+ slot,
496
+ label,
497
+ text: `[Model ${label} timed out (quorum grace period exceeded)]`,
498
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
499
+ costUsd: 0,
500
+ ok: false,
501
+ error: 'Quorum grace period timed out',
502
+ }
314
503
  }
315
504
  }
505
+ return results
506
+ }
507
+
508
+ // Standard mode: wait for all tasks to complete
509
+ await new Promise((resolve) => {
510
+ const checkAll = () => {
511
+ if (finishedCount >= total) resolve()
512
+ }
513
+ onTaskFinished = checkAll
514
+ checkAll()
316
515
  })
317
516
 
318
- return Promise.all(tasks)
517
+ return results
518
+ }
519
+
520
+ function candidatesForHistory(referenceOutputs) {
521
+ return (referenceOutputs || []).map((r) => ({
522
+ provider: r.slot?.provider || '',
523
+ model: r.slot?.model || '',
524
+ files: (r.files || []).map((f) => f.relativePath),
525
+ usage: r.usage || { inputTokens: 0, outputTokens: 0 },
526
+ costUsd: r.costUsd || 0,
527
+ }))
528
+ }
529
+
530
+ async function createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs) {
531
+ if (!liveCanvas || typeof liveCanvas.createPreviewFromContent !== 'function') return null
532
+ const htmlRel = (promotedFiles || []).find((f) => f.endsWith('.html') || f.endsWith('.htm'))
533
+ if (!htmlRel) return null
534
+ let content = null
535
+ for (const r of referenceOutputs || []) {
536
+ const block = (r?.files || []).find((f) => f.relativePath === htmlRel)
537
+ if (block?.content) {
538
+ content = block.content
539
+ break
540
+ }
541
+ }
542
+ if (!content) return null
543
+ try {
544
+ return await liveCanvas.createPreviewFromContent({ content, title: htmlRel, filePath: path.join(cwd, htmlRel) })
545
+ } catch {
546
+ return null
547
+ }
319
548
  }
320
549
 
321
550
  /**
322
- * Main MoA pipeline runner.
551
+ * Main MoA pipeline runner with Curator Synthesis, Fallback Chains, and Live Token Streaming.
323
552
  */
324
553
  export async function runMoAPipeline({
325
554
  userPrompt,
@@ -328,28 +557,39 @@ export async function runMoAPipeline({
328
557
  callLlm,
329
558
  cwd = process.cwd(),
330
559
  onProgress,
560
+ onStreamDelta,
561
+ prices = {},
562
+ historyFilePath,
563
+ liveCanvas = null,
331
564
  }) {
332
565
  const startTime = Date.now()
333
566
  const referenceModels = preset?.reference_models || [
334
567
  { provider: 'opencode-go', model: 'deepseek-v4-flash' },
335
568
  { provider: 'grok', model: 'grok-build-0.1' },
336
569
  ]
337
- const aggregator = preset?.aggregator || { provider: 'codex', model: 'gpt-5.6-sol' }
338
- const aggLabel = slotLabel(aggregator)
570
+ const primaryJudge = preset?.aggregator || { provider: 'codex', model: 'gpt-5.6-sol' }
571
+ const fallbackJudges = Array.isArray(preset?.aggregator_fallbacks) ? preset.aggregator_fallbacks : []
572
+ const judgesChain = [primaryJudge, ...fallbackJudges]
573
+
339
574
  const refTemp = preset?.reference_temperature ?? 0.6
340
575
  const aggTemp = preset?.aggregator_temperature ?? 0.4
341
576
  const maxTokens = preset?.max_tokens ?? 4096
342
577
  const judgeCriteria = preset?.judge_criteria ?? ''
343
- const isFastMode = referenceModels.length === 1 && !preset?.force_aggregator
344
-
345
- // 1. Collect current project workspace context (#6)
578
+ const isFastMode = referenceModels.length === 1
579
+ const isCuratorSynthesis = Boolean(preset?.curator_synthesis)
580
+ const isStreamAggregator = preset?.stream_aggregator !== false
581
+ const isQuorumEnabled = Boolean(preset?.quorum_enabled)
582
+ const gracePeriodSec = preset?.grace_period_sec ?? 10
583
+ const candidateRetries = preset?.retry_count ?? 1
584
+
585
+ // 1. Collect current project workspace context
346
586
  if (typeof onProgress === 'function') {
347
- onProgress('🔍 *Сканирование контекста проекта...*\n')
587
+ onProgress('🔍 *Scanning project context...*\n')
348
588
  }
349
589
  const projectContext = await collectProjectContext(cwd)
350
590
  const isRefinement = isRefinementTask(userPrompt, projectContext.files)
351
591
 
352
- // 2. Check if broad prompt requires user questionnaire (#7)
592
+ // 2. Check if broad prompt requires user questionnaire
353
593
  const askQuestions = preset?.ask_clarifying_questions !== false
354
594
  const needsQuestions = !isFastMode && askQuestions && !isRefinement && isBroadPromptRequiringQuestions(userPrompt, messages)
355
595
 
@@ -363,32 +603,36 @@ export async function runMoAPipeline({
363
603
 
364
604
  let promptForCandidates = userPrompt
365
605
  if (needsQuestions) {
366
- promptForCandidates += '\n(Примечание: задача широкая. Предложи ключевые архитектурные и функциональные развилки для уточнения требований пользователя).'
606
+ promptForCandidates += "\n(Note: the task is broad. Propose the key architectural and functional decision points to clarify the user's requirements.)"
367
607
  }
368
608
 
369
609
  const enrichedMessages = [...messages, { role: 'user', content: promptForCandidates }]
370
610
 
371
- // 4. Parallel fan-out to candidate models
372
- if (typeof onProgress === 'function') {
373
- onProgress(`🚀 *Запуск ${referenceModels.length} моделей-кандидатов параллельно...*\n`)
374
- }
375
-
611
+ // 4. Parallel fan-out to candidate models with quorum & transient retry
376
612
  const referenceOutputs = await runReferencesParallel(
377
613
  referenceModels,
378
614
  enrichedMessages,
379
- { systemPrompt: candidateSystemPrompt, temperature: refTemp, maxTokens },
615
+ {
616
+ systemPrompt: candidateSystemPrompt,
617
+ temperature: refTemp,
618
+ maxTokens,
619
+ prices,
620
+ quorumEnabled: isQuorumEnabled,
621
+ gracePeriodSec,
622
+ maxRetries: candidateRetries,
623
+ },
380
624
  callLlm,
381
625
  onProgress
382
626
  )
383
627
 
384
- // Fail fast if all candidates failed (#12)
628
+ // Fail fast if all candidates failed
385
629
  const successfulRefs = referenceOutputs.filter((r) => r.ok)
386
630
  if (successfulRefs.length === 0) {
387
631
  const reasons = referenceOutputs.map((r) => `${r.label}: ${r.error || 'unknown error'}`).join('; ')
388
632
  return {
389
633
  kind: 'failure',
390
- content: `⚠️ Все модели-советники (${referenceOutputs.length}) завершились с ошибкой: ${reasons}`,
391
- aggregator: aggLabel,
634
+ content: `⚠️ All advisor models (${referenceOutputs.length}) failed: ${reasons}`,
635
+ aggregator: slotLabel(primaryJudge),
392
636
  references: referenceOutputs,
393
637
  presetName: preset?.name || 'default',
394
638
  isRefinement,
@@ -406,7 +650,7 @@ export async function runMoAPipeline({
406
650
  }
407
651
  }
408
652
 
409
- // 5. Extract file blocks & write candidate workspaces (#1, #2)
653
+ // 5. Extract file blocks & write candidate workspaces
410
654
  for (let i = 0; i < referenceOutputs.length; i++) {
411
655
  const ref = referenceOutputs[i]
412
656
  if (!ref.ok) continue
@@ -417,24 +661,53 @@ export async function runMoAPipeline({
417
661
  }
418
662
  }
419
663
 
420
- // 6. Questionnaire synthesis branch (#7)
664
+ // 6. Questionnaire synthesis branch
421
665
  if (needsQuestions) {
422
666
  if (typeof onProgress === 'function') {
423
- onProgress('📋 *Синтез опросника судьей...*\n')
667
+ onProgress('📋 *Judge synthesizes the clarification questionnaire...*\n')
424
668
  }
425
669
  const questionPrompt = buildQuestionSynthesisPrompt(userPrompt, referenceOutputs)
426
670
  let questionsContent = ''
671
+ let qUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
427
672
  try {
428
- const qRes = await callLlm({
429
- provider: aggregator.provider,
430
- model: aggregator.model,
673
+ const qRes = await callWithTransientRetry(callLlm, {
674
+ provider: primaryJudge.provider,
675
+ model: primaryJudge.model,
431
676
  messages: [{ role: 'user', content: questionPrompt }],
432
677
  temperature: 0.3,
433
678
  maxTokens: 2048,
434
- })
679
+ }, 1)
435
680
  questionsContent = typeof qRes === 'string' ? qRes : (qRes?.content || qRes?.text || '')
681
+ const qFallback = (typeof qRes === 'object' && qRes?.usage) ? qRes.usage : {
682
+ inputTokens: Math.max(1, Math.round(questionPrompt.length / 4)),
683
+ outputTokens: Math.max(1, Math.round(questionsContent.length / 4)),
684
+ }
685
+ qUsage = estimateTokenCost(primaryJudge, qFallback, prices)
436
686
  } catch {
437
- questionsContent = '### Уточнение требований к проекту\nПожалуйста, уточните детали реализации и желаемый стек.'
687
+ questionsContent = '### Project requirements clarification\nPlease specify the implementation details and the desired stack.'
688
+ }
689
+
690
+ await cleanMoaWorkspaces(cwd)
691
+
692
+ const qTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + qUsage.totalTokens
693
+ const qCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + qUsage.costUsd).toFixed(5))
694
+
695
+ try {
696
+ recordMoaRun({
697
+ prompt: userPrompt,
698
+ preset: preset?.name || 'default',
699
+ isRefinement,
700
+ candidates: candidatesForHistory(referenceOutputs),
701
+ aggregator: null,
702
+ winnerIndex: -1,
703
+ winnerModel: '',
704
+ promotedFiles: [],
705
+ totalTokens: qTokens,
706
+ totalCostUsd: qCostUsd,
707
+ durationMs: Date.now() - startTime,
708
+ }, historyFilePath)
709
+ } catch (histErr) {
710
+ console.warn('[dsh-moa] Failed to record questionnaire run in history:', histErr)
438
711
  }
439
712
 
440
713
  return {
@@ -459,6 +732,26 @@ export async function runMoAPipeline({
459
732
  const totalTokens = single.usage?.totalTokens || 0
460
733
  const totalCostUsd = single.costUsd || 0
461
734
  const durationMs = Date.now() - startTime
735
+ const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
736
+
737
+ try {
738
+ recordMoaRun({
739
+ prompt: userPrompt,
740
+ preset: preset?.name || 'default',
741
+ isRefinement,
742
+ isFastMode: true,
743
+ candidates: candidatesForHistory(referenceOutputs),
744
+ aggregator: null,
745
+ winnerIndex: 1,
746
+ winnerModel: single.label,
747
+ promotedFiles,
748
+ totalTokens,
749
+ totalCostUsd,
750
+ durationMs,
751
+ }, historyFilePath)
752
+ } catch (histErr) {
753
+ console.warn('[dsh-moa] Failed to record fast-mode run in history:', histErr)
754
+ }
462
755
 
463
756
  return {
464
757
  kind: 'synthesis',
@@ -471,6 +764,7 @@ export async function runMoAPipeline({
471
764
  winningIndex: 1,
472
765
  winnerModel: single.label,
473
766
  promotedFiles,
767
+ ...(livePreview ? { liveCanvas: livePreview } : {}),
474
768
  usage: {
475
769
  totalTokens,
476
770
  totalCostUsd,
@@ -481,39 +775,84 @@ export async function runMoAPipeline({
481
775
  }
482
776
  }
483
777
 
484
- // 8. Aggregator / Judge synthesis (#5, #8)
485
- if (typeof onProgress === 'function') {
486
- onProgress(`⚖️ *Ведущая модель (${aggLabel}) оценивает варианты и синтезирует решение...*\n`)
487
- }
778
+ // 8. Aggregator / Judge synthesis with fallback chain (#3.3) & streaming (#2.1)
779
+ const synthesisPrompt = buildSynthesisPrompt(
780
+ userPrompt,
781
+ referenceOutputs,
782
+ judgeCriteria,
783
+ { curatorSynthesis: isCuratorSynthesis }
784
+ )
488
785
 
489
- const synthesisPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria)
490
786
  let synthesizedText = ''
491
787
  let aggUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
788
+ let chosenJudge = primaryJudge
789
+ let judgeSuccess = false
790
+ let lastJudgeError = null
492
791
 
493
- try {
494
- const aggRes = await callLlm({
495
- provider: aggregator.provider,
496
- model: aggregator.model,
497
- messages: [{ role: 'user', content: synthesisPrompt }],
498
- temperature: aggTemp,
499
- maxTokens,
500
- })
501
- synthesizedText = typeof aggRes === 'string' ? aggRes : (aggRes?.content || aggRes?.text || '')
502
- const aggFallbackUsage = (typeof aggRes === 'object' && aggRes?.usage) ? aggRes.usage : {
503
- inputTokens: Math.max(1, Math.round(synthesisPrompt.length / 4)),
504
- outputTokens: Math.max(1, Math.round(synthesizedText.length / 4)),
792
+ for (let jIdx = 0; jIdx < judgesChain.length; jIdx++) {
793
+ const currentJudge = judgesChain[jIdx]
794
+ const currentLabel = slotLabel(currentJudge)
795
+
796
+ if (typeof onProgress === 'function') {
797
+ const judgeTitle = isCuratorSynthesis ? 'Lead Curator' : 'Judge'
798
+ const fallbackBadge = jIdx > 0 ? ` (Fallback #${jIdx})` : ''
799
+ onProgress(`⚖️ *${judgeTitle} (${currentLabel})${fallbackBadge} evaluates candidates and synthesizes the solution...*\n`)
800
+ }
801
+
802
+ try {
803
+ const aggRes = await callWithTransientRetry(callLlm, {
804
+ provider: currentJudge.provider,
805
+ model: currentJudge.model,
806
+ messages: [{ role: 'user', content: synthesisPrompt }],
807
+ temperature: aggTemp,
808
+ maxTokens,
809
+ onStreamDelta: (delta) => {
810
+ if (isStreamAggregator && typeof onStreamDelta === 'function') {
811
+ onStreamDelta(delta)
812
+ }
813
+ },
814
+ }, 0)
815
+
816
+ synthesizedText = typeof aggRes === 'string' ? aggRes : (aggRes?.content || aggRes?.text || '')
817
+ const aggFallbackUsage = (typeof aggRes === 'object' && aggRes?.usage) ? aggRes.usage : {
818
+ inputTokens: Math.max(1, Math.round(synthesisPrompt.length / 4)),
819
+ outputTokens: Math.max(1, Math.round(synthesizedText.length / 4)),
820
+ }
821
+ aggUsage = estimateTokenCost(currentJudge, aggFallbackUsage, prices)
822
+ chosenJudge = currentJudge
823
+ judgeSuccess = true
824
+ break
825
+ } catch (err) {
826
+ lastJudgeError = err
827
+ console.warn(`[dsh-moa] Judge ${currentLabel} failed:`, err)
828
+ if (typeof onProgress === 'function') {
829
+ const nextJudge = judgesChain[jIdx + 1]
830
+ const nextHint = nextJudge ? ` Trying fallback ${slotLabel(nextJudge)}...` : ''
831
+ onProgress(`⚠️ *Judge ${currentLabel} failed: ${err?.message || err}.${nextHint}*\n`)
832
+ }
505
833
  }
506
- aggUsage = estimateTokenCost(aggregator, aggFallbackUsage)
507
- } catch (err) {
508
- console.warn(`[dsh-moa] Aggregator ${aggLabel} failed:`, err)
509
- synthesizedText = `⚠️ *[Aggregator error: ${err?.message || err}. Fallback candidate outputs:]*\n\n` +
510
- successfulRefs.map((r, i) => `### Кандидат ${i + 1} (${r.label})\n${r.text}`).join('\n\n')
511
834
  }
512
835
 
513
- // 9. Evaluate winner & promote files (#1, #2)
514
- const winningIndex = parseWinnerIndex(synthesizedText, 1)
836
+ if (!judgeSuccess) {
837
+ const firstErr = lastJudgeError ? (lastJudgeError.message || String(lastJudgeError)) : 'unknown error'
838
+ synthesizedText = `⚠️ *[Aggregator error: ${firstErr}. Fallback candidate outputs:]*\n\n` +
839
+ successfulRefs.map((r, i) => `### Candidate ${i + 1} (${r.label})\n${r.text}`).join('\n\n')
840
+ }
841
+
842
+ // 9. Evaluate winner & promote files
843
+ const winningIndex = parseWinnerIndex(synthesizedText, 1, referenceOutputs.length)
844
+ const recommendedAssembler = isCuratorSynthesis
845
+ ? parseRecommendedAssembler(synthesizedText, winningIndex, referenceOutputs.length)
846
+ : null
847
+
848
+ // Check if aggregator synthesized unified file blocks directly
849
+ const synthesizedFiles = extractFileBlocks(synthesizedText)
515
850
  let promotedFiles = []
516
- if (referenceOutputs[winningIndex - 1]?.files?.length > 0) {
851
+
852
+ if (synthesizedFiles.length > 0) {
853
+ await writeCandidateWorkspace(cwd, 'curator-synthesis', synthesizedFiles)
854
+ promotedFiles = await promoteCandidateWorkspace(cwd, 'curator-synthesis')
855
+ } else if (referenceOutputs[winningIndex - 1]?.files?.length > 0) {
517
856
  promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex)
518
857
  } else if (successfulRefs[0]?.files?.length > 0) {
519
858
  const fallbackIdx = successfulRefs[0].index
@@ -522,16 +861,7 @@ export async function runMoAPipeline({
522
861
  await cleanMoaWorkspaces(cwd)
523
862
  }
524
863
 
525
- // 10. Live Canvas preview (#16)
526
- let liveCanvas = null
527
- const htmlFile = promotedFiles.find((f) => f.endsWith('.html') || f.endsWith('index.html'))
528
- if (htmlFile) {
529
- liveCanvas = {
530
- previewUrl: `http://localhost:3000/preview/${encodeURIComponent(htmlFile)}`,
531
- file: htmlFile,
532
- title: 'Interactive Web Preview',
533
- }
534
- }
864
+ const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
535
865
 
536
866
  const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + aggUsage.totalTokens
537
867
  const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + aggUsage.costUsd).toFixed(5))
@@ -539,22 +869,23 @@ export async function runMoAPipeline({
539
869
 
540
870
  const winningRef = referenceOutputs[winningIndex - 1]
541
871
  const winnerModel = winningRef?.label || slotLabel(referenceModels[0])
872
+ const finalAggLabel = slotLabel(chosenJudge)
542
873
 
543
- // Record run to history (#9, #10, #11)
874
+ // Record run to history
544
875
  try {
545
876
  recordMoaRun({
546
877
  prompt: userPrompt,
547
878
  preset: preset?.name || 'default',
548
879
  isRefinement,
549
- candidates: referenceOutputs,
550
- aggregator: isFastMode ? null : { provider: aggregator.provider, model: aggregator.model, usage: aggUsage, costUsd: aggUsage.costUsd },
880
+ candidates: candidatesForHistory(referenceOutputs),
881
+ aggregator: { provider: chosenJudge.provider, model: chosenJudge.model, usage: aggUsage, costUsd: aggUsage.costUsd },
551
882
  winnerIndex: winningIndex,
552
883
  winnerModel,
553
884
  promotedFiles,
554
885
  totalTokens,
555
886
  totalCostUsd,
556
887
  durationMs,
557
- })
888
+ }, historyFilePath)
558
889
  } catch (histErr) {
559
890
  console.warn('[dsh-moa] Failed to record run in history:', histErr)
560
891
  }
@@ -562,19 +893,21 @@ export async function runMoAPipeline({
562
893
  return {
563
894
  kind: 'synthesis',
564
895
  content: synthesizedText,
565
- aggregator: isFastMode ? 'Fast Mode (Direct)' : aggLabel,
896
+ aggregator: isFastMode ? 'Fast Mode (Direct)' : finalAggLabel,
566
897
  references: referenceOutputs,
567
898
  presetName: preset?.name || 'default',
568
899
  isRefinement,
569
900
  isFastMode,
901
+ isCuratorSynthesis,
902
+ recommendedAssembler,
570
903
  winningIndex,
571
904
  winnerModel,
572
905
  promotedFiles,
573
- liveCanvas,
906
+ ...(livePreview ? { liveCanvas: livePreview } : {}),
574
907
  usage: {
575
908
  totalTokens,
576
909
  totalCostUsd,
577
- candidates: referenceOutputs.map(r => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
910
+ candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
578
911
  aggregator: aggUsage,
579
912
  },
580
913
  durationMs,
@@ -592,8 +925,8 @@ export function stripOrSummarizeCode(text) {
592
925
  return match
593
926
  }
594
927
  const fileHint = fileTag || (code.match(/^\s*(?:\/\/|#|<!--|\/\*)\s*(?:file|filepath|path):\s*([^\s*]+)/im)?.[1])
595
- const label = fileHint ? `файл \`${fileHint}\`` : (lang ? `код \`${lang}\`` : 'код')
596
- return `\n> 📄 *[${label} — ${lines.length} строк сохранены на диск]*\n`
928
+ const label = fileHint ? `file \`${fileHint}\`` : (lang ? `code \`${lang}\`` : 'code')
929
+ return `\n> 📄 *[${label} - ${lines.length} lines saved to disk]*\n`
597
930
  })
598
931
  }
599
932
 
@@ -604,47 +937,53 @@ export function formatMoAResponse({ moaResult, presetName }) {
604
937
  const refs = moaResult?.references || []
605
938
 
606
939
  if (moaResult?.kind === 'questions') {
607
- parts.push('## 🧠 Mixture of Agents — Уточнение требований')
608
- parts.push(`*Судья (${judge}) и советники проанализировали задачу:*\n`)
940
+ parts.push('## 🧠 Mixture of Agents — Requirements Clarification')
941
+ parts.push(`*Judge (${judge}) and the advisors analyzed the task:*\n`)
609
942
  parts.push(moaResult.content)
610
943
  return parts.join('\n')
611
944
  }
612
945
 
613
946
  if (moaResult?.kind === 'failure') {
614
- parts.push('## ⚠️ Mixture of Agents — Сбой выполнения')
947
+ parts.push('## ⚠️ Mixture of Agents — Execution Failed')
615
948
  parts.push(moaResult.content)
616
949
  return parts.join('\n')
617
950
  }
618
951
 
619
- const modeBadge = moaResult?.isFastMode ? '⚡ Fast Mode' : `Судья: ${judge}`
620
- parts.push(`## 🧠 Mixture of Agents (Пресет: ${pName} | ${modeBadge})`)
952
+ const modeBadge = moaResult?.isFastMode
953
+ ? '⚡ Fast Mode'
954
+ : (moaResult?.isCuratorSynthesis ? `🧠 Curator: ${judge}` : `Judge: ${judge}`)
955
+ parts.push(`## 🧠 Mixture of Agents (Preset: ${pName} | ${modeBadge})`)
621
956
  parts.push('')
622
957
 
623
958
  if (moaResult?.isRefinement) {
624
- parts.push('> 🔄 **Режим**: Итеративная доработка проекта (Refinement)')
959
+ parts.push('> 🔄 **Mode**: Iterative project refinement (Refinement)')
625
960
  }
626
961
 
627
962
  const hasPromoted = moaResult?.promotedFiles && moaResult.promotedFiles.length > 0
628
963
  if (hasPromoted) {
629
- parts.push(`> 📦 **Созданы файлы в проекте**: \`${moaResult.promotedFiles.join('`, `')}\``)
964
+ parts.push(`> 📦 **Files created in the project**: \`${moaResult.promotedFiles.join('`, `')}\``)
965
+ }
966
+
967
+ if (moaResult?.recommendedAssembler?.label) {
968
+ parts.push(`> 🎯 **Recommended Master Assembler**: Candidate ${moaResult.recommendedAssembler.index} (\`${moaResult.recommendedAssembler.label}\`)`)
630
969
  }
631
970
 
632
971
  if (moaResult?.liveCanvas?.previewUrl) {
633
972
  parts.push(`> 🎨 **Live Canvas**: [🚀 Открыть ${moaResult.liveCanvas.title || 'превью'} в Live Canvas](${moaResult.liveCanvas.previewUrl}) | [↗ Открыть в новой вкладке](${moaResult.liveCanvas.previewUrl})`)
634
973
  }
635
974
 
636
- // Cost tracking card (#10)
975
+ // Cost tracking card
637
976
  if (moaResult?.usage) {
638
977
  const u = moaResult.usage
639
- const costStr = u.totalCostUsd > 0 ? `~\$${u.totalCostUsd.toFixed(4)}` : 'Бесплатно'
978
+ const costStr = u.totalCostUsd > 0 ? `~\$${u.totalCostUsd.toFixed(4)}` : 'Free'
640
979
  const tokStr = u.totalTokens >= 1000 ? `${(u.totalTokens / 1000).toFixed(1)}k` : `${u.totalTokens}`
641
- parts.push(`> 💰 **Стоимость запуска**: ${costStr} (всего ${tokStr} токенов)`)
980
+ parts.push(`> 💰 **Run cost**: ${costStr} (${tokStr} tokens total)`)
642
981
  }
643
982
 
644
983
  parts.push('')
645
984
 
646
985
  if (!moaResult?.isFastMode) {
647
- parts.push(`### ⚖️ Вердикт судьи и итоговое решение (Синтез: ${judge})`)
986
+ parts.push(`### ⚖️ Judge verdict and final synthesis (Synthesis: ${judge})`)
648
987
  parts.push('')
649
988
  const cleanJudgeContent = hasPromoted
650
989
  ? stripOrSummarizeCode(moaResult?.content || '')
@@ -654,14 +993,14 @@ export function formatMoAResponse({ moaResult, presetName }) {
654
993
  }
655
994
 
656
995
  if (Array.isArray(refs) && refs.length > 0) {
657
- const title = moaResult?.isFastMode ? '### 🚀 Результат генерации кандидата:' : `### 👥 Ответы моделей-советников (${refs.length}):`
996
+ const title = moaResult?.isFastMode ? '### 🚀 Candidate generation result:' : `### 👥 Advisor responses (${refs.length}):`
658
997
  parts.push(title)
659
998
  parts.push('')
660
999
  refs.forEach((ref, i) => {
661
1000
  const statusIcon = ref.ok ? '✅' : '⚠️'
662
1001
  const fileBadge = ref.files?.length ? ` (${ref.files.length} файл(ов))` : ''
663
1002
  const costBadge = ref.costUsd > 0 ? ` [~\$${ref.costUsd.toFixed(4)}]` : ''
664
- parts.push(`#### ${statusIcon} Модель ${i + 1}: ${ref.label}${fileBadge}${costBadge}`)
1003
+ parts.push(`#### ${statusIcon} Model ${i + 1}: ${ref.label}${fileBadge}${costBadge}`)
665
1004
  parts.push('')
666
1005
  parts.push(stripOrSummarizeCode(ref.text))
667
1006
  parts.push('')
@@ -673,12 +1012,12 @@ export function formatMoAResponse({ moaResult, presetName }) {
673
1012
  return parts.join('\n')
674
1013
  }
675
1014
 
676
- export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd }, options = {}) {
1015
+ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd, prices, historyFilePath, liveCanvas }, options = {}) {
677
1016
  const signal = options?.signal
678
1017
  if (signal?.aborted) return
679
1018
 
680
1019
  yield { type: 'block-start', index: 0, blockType: 'text' }
681
- yield { type: 'text-delta', index: 0, text: '🧠 *Mixture of Agents запущен...*\n\n' }
1020
+ yield { type: 'text-delta', index: 0, text: '🧠 *Mixture of Agents started...*\n\n' }
682
1021
 
683
1022
  // Async push-queue for zero-latency live delta streaming
684
1023
  const queue = []
@@ -700,6 +1039,12 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
700
1039
  callLlm,
701
1040
  cwd,
702
1041
  onProgress: pushUpdate,
1042
+ onStreamDelta: (delta) => {
1043
+ pushUpdate(delta)
1044
+ },
1045
+ prices,
1046
+ historyFilePath,
1047
+ liveCanvas,
703
1048
  })
704
1049
  .catch((err) => ({ error: err }))
705
1050
  .finally(() => {
@@ -736,7 +1081,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
736
1081
 
737
1082
  const elapsedSec = Math.floor((Date.now() - startTime) / 1000)
738
1083
  if (!done && Date.now() - lastYieldTime >= 3000) {
739
- yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}с] Идёт обработка...*\n` }
1084
+ yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}s] Still processing...*\n` }
740
1085
  lastYieldTime = Date.now()
741
1086
  }
742
1087
  }
@@ -748,7 +1093,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
748
1093
  }
749
1094
 
750
1095
  if (result?.error) {
751
- const errText = '\n\n⚠️ **Ошибка Mixture of Agents**: ' + (result.error?.message || String(result.error))
1096
+ const errText = '\n\n⚠️ **Mixture of Agents error**: ' + (result.error?.message || String(result.error))
752
1097
  yield { type: 'text-delta', index: 0, text: errText }
753
1098
  yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
754
1099
  yield { type: 'finish', reason: { kind: 'stop' } }
@@ -762,7 +1107,13 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
762
1107
 
763
1108
  yield { type: 'text-delta', index: 0, text: '\n---\n\n' + formatted }
764
1109
  yield { type: 'block-end', index: 0, block: { type: 'text', text: formatted } }
765
- yield { type: 'usage', usage: { inputTokens: result?.usage?.totalTokens || 100, outputTokens: formatted.length } }
1110
+ yield {
1111
+ type: 'usage',
1112
+ usage: {
1113
+ inputTokens: result?.usage?.totalTokens || 0,
1114
+ outputTokens: Math.round((formatted.length || 0) / 4),
1115
+ },
1116
+ }
766
1117
  yield { type: 'finish', reason: { kind: 'stop' } }
767
1118
  } finally {
768
1119
  if (signal?.aborted) {