@goodandready/dsh-moa 0.2.7 → 0.2.9

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,84 +1,58 @@
1
- import { estimateTokenCost as calculateTokenCost, resolveModelRates, refreshCatalogInBackground, DIRECT_VENDOR_RATES, FALLBACK_RATES } from './pricing.js'
2
1
  /**
3
- * Mixture of Agents (MoA) execution engine with Interactive Questioning,
4
- * Isolated Multi-Candidate File Execution, Native Cost Tracking,
5
- * Refinement Mode, and Multi-Candidate Scalability.
2
+ * Mixture of Agents (MoA) Orchestrator Engine (#1 - #16)
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
6
13
  */
7
14
 
8
15
  import {
9
16
  extractFileBlocks,
17
+ collectProjectContext,
18
+ isRefinementTask,
10
19
  writeCandidateWorkspace,
11
20
  promoteCandidateWorkspace,
12
21
  cleanMoaWorkspaces,
13
- collectProjectContext,
14
- isRefinementTask,
15
22
  } from './file-workspace.js'
16
23
 
24
+ import { estimateTokenCost } from './pricing.js'
17
25
  import { recordMoaRun } from './history.js'
18
26
 
19
- export {
20
- extractFileBlocks,
21
- writeCandidateWorkspace,
22
- promoteCandidateWorkspace,
23
- cleanMoaWorkspaces,
24
- collectProjectContext,
25
- isRefinementTask,
26
- }
27
+ export { estimateTokenCost } from './pricing.js'
27
28
 
28
- export const REFERENCE_SYSTEM_PROMPT = `You are an expert candidate engineer model in a Mixture of Agents (MoA) architecture.
29
- You are directly implementing the solution for the user's task.
30
-
31
- RULES:
32
- 1. Do NOT emit internal planning or pretend tool calls (no xml, no <invoke>, no brainstorming delays).
33
- 2. Write complete, production-ready, functional code.
34
- 3. Every file you produce MUST be formatted in markdown code blocks with clear file path annotation, e.g.:
35
- \`\`\`html file="index.html"
36
- ...
37
- \`\`\`
38
- or
39
- \`\`\`javascript file="src/app.js"
40
- ...
41
- \`\`\`
42
- 4. If the user request is broad, make opinionated, high-quality technical decisions and deliver a complete working project.
43
- 5. Never leave placeholders like "// TODO" or "... rest of code". Deliver exhaustive, working code.`
44
-
45
- export const REFINEMENT_SYSTEM_PROMPT = `You are an expert software engineer performing an iterative modification / refinement on an existing project.
46
- You are provided with the current codebase files and the user's delta request.
47
-
48
- RULES:
49
- 1. Modify the existing files or create new files to fulfill the user's change request.
50
- 2. Deliver complete, production-ready updated code for every modified file.
51
- 3. Format each updated file in markdown code blocks with explicit file attribute:
52
- \`\`\`javascript file="src/app.js"
53
- ...
54
- \`\`\`
55
- 4. Keep the existing architecture, styling conventions and dependencies consistent.`
56
-
57
- export const ADVISOR_QUESTION_PROMPT = `You are a senior technical advisor in a Mixture of Agents (MoA) architecture.
58
- The user has provided a prompt that may have multiple design choices, architectural paths, or underspecified requirements.
59
-
60
- Review the user prompt and identify 1 to 3 critical, high-impact clarifying questions or architectural options that would define the implementation (e.g. framework/vanilla, features, design style, target environment).
61
- Keep questions very clear, structured, and actionable. Avoid trivial questions. Respond directly in Russian.`
62
-
63
- export { resolveModelRates, refreshCatalogInBackground, DIRECT_VENDOR_RATES, FALLBACK_RATES }
64
-
65
- export function estimateTokenCost(slot, usage = {}, customPrices = {}) {
66
- return calculateTokenCost(slot, usage, customPrices)
67
- }
29
+ export const DEFAULT_PROMPT = 'Please propose an optimal, well-structured, production-ready solution with full code and explanations.'
30
+
31
+ export const REFERENCE_SYSTEM_PROMPT = `You are an expert AI software architect and senior engineer acting as a candidate proposer in a Mixture of Agents (MoA) ensemble.
32
+ Your task is to provide the highest-quality, robust, complete, production-ready solution to the user request.
33
+ Write clean, modern, fully functional code without placeholders or shortcuts.
34
+ When generating files for a project, explicitly specify file paths using fenced code blocks with file annotations, e.g.:
35
+ \`\`\`html file="index.html"
36
+ \`\`\`
37
+ \`\`\`javascript file="script.js"
38
+ \`\`\`
39
+ \`\`\`css file="style.css"
40
+ \`\`\``
68
41
 
69
42
  export function slotLabel(slot) {
70
- if (!slot || typeof slot !== 'object') return 'unknown'
71
- const prov = String(slot.provider || '').trim()
72
- const model = String(slot.model || '').trim()
73
- if (prov && model) return `${prov}:${model}`
74
- return prov || model || 'unknown'
43
+ if (!slot) return 'unknown'
44
+ if (typeof slot === 'string') return slot
45
+ if (slot.provider && slot.model) return `${slot.provider}:${slot.model}`
46
+ return slot.model || slot.provider || 'unknown'
75
47
  }
76
48
 
77
49
  /**
78
- * Strips tool results, system prompts, and tool calls from history
79
- * to give reference advisors a clean, advisory-safe context.
50
+ * Extracts and sanitizes conversation history for candidate models.
51
+ * Robust against complex DSH message content types (strings, part arrays, objects, tool calls).
80
52
  */
81
- export function cleanAdvisoryMessages(messages = [], maxCharBudget = 4000) {
53
+ export function cleanAdvisoryMessages(messages = [], maxCharBudget = 24000) {
54
+ if (!Array.isArray(messages)) return []
55
+
82
56
  const trimmed = []
83
57
  for (const msg of messages) {
84
58
  if (!msg || typeof msg !== 'object') continue
@@ -90,12 +64,21 @@ export function cleanAdvisoryMessages(messages = [], maxCharBudget = 4000) {
90
64
  text = msg.content
91
65
  } else if (Array.isArray(msg.content)) {
92
66
  text = msg.content
93
- .filter((part) => part && part.type === 'text' && typeof part.text === 'string')
67
+ .filter((part) => part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string')
94
68
  .map((part) => part.text)
95
69
  .join('\n')
70
+ } else if (msg.content && typeof msg.content === 'object') {
71
+ if (typeof msg.content.text === 'string') {
72
+ text = msg.content.text
73
+ } else if (typeof msg.content.content === 'string') {
74
+ text = msg.content.content
75
+ }
76
+ } else if (typeof msg.text === 'string') {
77
+ text = msg.text
96
78
  }
97
79
 
98
- if (!text.trim()) continue
80
+ text = text.trim()
81
+ if (!text) continue
99
82
 
100
83
  if (text.length > maxCharBudget) {
101
84
  const head = text.slice(0, Math.floor(maxCharBudget * 0.7))
@@ -118,7 +101,7 @@ export function isBroadPromptRequiringQuestions(userPrompt = '', messages = [])
118
101
  }
119
102
 
120
103
  const hasRecentQuestion = messages.some((m) => {
121
- const text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '')
104
+ const text = typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content || '')
122
105
  return text.includes('Уточнение требований') || text.includes('опросник') || text.includes('Вариант 1')
123
106
  })
124
107
  if (hasRecentQuestion) {
@@ -281,56 +264,53 @@ export async function runReferencesParallel(references, messages, options = {},
281
264
  provider: slot.provider,
282
265
  model: slot.model,
283
266
  messages: fullMessages,
284
- temperature: options.referenceTemperature ?? 0.6,
267
+ temperature: options.temperature ?? 0.6,
285
268
  maxTokens: options.maxTokens ?? 4096,
286
269
  })
287
270
 
288
- const timeoutPromise = new Promise((_, reject) =>
289
- setTimeout(() => reject(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s waiting for ${label}`)), timeoutMs).unref()
290
- )
271
+ const timeoutPromise = new Promise((_, reject) => {
272
+ const timer = setTimeout(() => reject(new Error(`Timeout after ${timeoutMs / 1000}s`)), timeoutMs)
273
+ timer.unref?.()
274
+ })
291
275
 
292
276
  const res = await Promise.race([callPromise, timeoutPromise])
293
- const text = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
294
- const files = extractFileBlocks(text)
277
+ const text = typeof res === 'string' ? res : (res?.content || res?.text || '')
295
278
 
296
- // Estimate tokens & cost
297
- const rawUsage = res?.usage || {
298
- inputTokens: Math.round(JSON.stringify(fullMessages).length / 4),
299
- outputTokens: Math.round(text.length / 4),
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)),
300
282
  }
301
- const costInfo = estimateTokenCost(slot, rawUsage, options.prices)
283
+ const costInfo = estimateTokenCost(slot, fallbackUsage)
302
284
 
303
285
  if (typeof onProgress === 'function') {
304
- const fileMsg = files.length > 0 ? ` (создано файлов: ${files.length})` : ''
305
- onProgress(`✅ *Кандидат ${i + 1} (${label}) завершил ответ${fileMsg}.*\n`)
286
+ const costStr = costInfo.costUsd > 0 ? ` (~\$${costInfo.costUsd.toFixed(4)})` : ''
287
+ onProgress(`✅ *Кандидат ${i + 1} (${label}) завершил ответ${costStr}*\n`)
306
288
  }
307
289
 
308
290
  return {
309
291
  index: i + 1,
292
+ slot,
310
293
  label,
311
- provider: slot.provider,
312
- model: slot.model,
313
- text: text || '(empty response)',
314
- files,
294
+ text,
315
295
  usage: costInfo,
316
296
  costUsd: costInfo.costUsd,
317
297
  ok: true,
318
298
  }
319
299
  } catch (err) {
320
- console.warn(`[dsh-moa] Reference ${label} failed:`, err)
300
+ const errMsg = err?.message || String(err)
301
+ console.warn(`[dsh-moa] Reference ${label} failed:`, errMsg)
321
302
  if (typeof onProgress === 'function') {
322
- onProgress(`⚠️ *Кандидат ${i + 1} (${label}) завершился с ошибкой: ${err?.message || String(err)}*\n`)
303
+ onProgress(`⚠️ *Кандидат ${i + 1} (${label}) ошибка: ${errMsg}*\n`)
323
304
  }
324
305
  return {
325
306
  index: i + 1,
307
+ slot,
326
308
  label,
327
- provider: slot.provider,
328
- model: slot.model,
329
- text: `[failed: ${err?.message || String(err)}]`,
330
- files: [],
309
+ text: `[Ошибка модели ${label}: ${errMsg}]`,
331
310
  usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
332
311
  costUsd: 0,
333
312
  ok: false,
313
+ error: errMsg,
334
314
  }
335
315
  }
336
316
  })
@@ -339,217 +319,220 @@ export async function runReferencesParallel(references, messages, options = {},
339
319
  }
340
320
 
341
321
  /**
342
- * Runs the full Mixture of Agents pipeline:
343
- * 1. Checks if questions or refinement needed
344
- * 2. Parallel candidate proposers write to .moa/candidate-X/
345
- * 3. Aggregator judges and picks winner (or bypassed in Fast Mode)
346
- * 4. Promotes winner files, records history and calculates costs
322
+ * Main MoA pipeline runner.
347
323
  */
348
324
  export async function runMoAPipeline({
349
325
  userPrompt,
350
326
  messages = [],
351
327
  preset,
352
328
  callLlm,
353
- cwd,
329
+ cwd = process.cwd(),
354
330
  onProgress,
355
- skipQuestions = false,
356
- prices = {},
357
331
  }) {
358
- if (typeof callLlm !== 'function') {
359
- throw new Error('callLlm function is required for runMoAPipeline')
332
+ const startTime = Date.now()
333
+ const referenceModels = preset?.reference_models || [
334
+ { provider: 'opencode-go', model: 'deepseek-v4-flash' },
335
+ { provider: 'grok', model: 'grok-build-0.1' },
336
+ ]
337
+ const aggregator = preset?.aggregator || { provider: 'codex', model: 'gpt-5.6-sol' }
338
+ const aggLabel = slotLabel(aggregator)
339
+ const refTemp = preset?.reference_temperature ?? 0.6
340
+ const aggTemp = preset?.aggregator_temperature ?? 0.4
341
+ const maxTokens = preset?.max_tokens ?? 4096
342
+ const judgeCriteria = preset?.judge_criteria ?? ''
343
+ const isFastMode = referenceModels.length === 1 && !preset?.force_aggregator
344
+
345
+ // 1. Collect current project workspace context (#6)
346
+ if (typeof onProgress === 'function') {
347
+ onProgress('🔍 *Сканирование контекста проекта...*\n')
360
348
  }
349
+ const projectContext = await collectProjectContext(cwd)
350
+ const isRefinement = isRefinementTask(userPrompt, projectContext.files)
361
351
 
362
- const startTime = Date.now()
363
- const referenceModels = preset?.reference_models || preset?.referenceModels || []
364
- const aggregator = preset?.aggregator || { provider: 'default', model: 'default' }
365
- const refTemp = preset?.reference_temperature ?? preset?.referenceTemperature ?? 0.6
366
- const aggTemp = preset?.aggregator_temperature ?? preset?.aggregatorTemperature ?? 0.4
367
- const maxTokens = preset?.max_tokens ?? preset?.maxTokens ?? 4096
368
- const judgeCriteria = preset?.judge_criteria || preset?.judgeCriteria || ''
369
- const workDir = cwd || process.cwd()
370
-
371
- // Collect project context (#6) and check refinement task (#4)
372
- const projectCtx = await collectProjectContext(workDir, 16000)
373
- const isRefinement = isRefinementTask(userPrompt, projectCtx.files)
374
-
375
- // System prompt selection
352
+ // 2. Check if broad prompt requires user questionnaire (#7)
353
+ const askQuestions = preset?.ask_clarifying_questions !== false
354
+ const needsQuestions = !isFastMode && askQuestions && !isRefinement && isBroadPromptRequiringQuestions(userPrompt, messages)
355
+
356
+ // 3. Build enriched prompt for candidates
376
357
  let candidateSystemPrompt = REFERENCE_SYSTEM_PROMPT
377
- if (isRefinement && projectCtx.files.length > 0) {
378
- const fileList = projectCtx.files.map((f) => `### File: ${f.relativePath}\n\`\`\`\n${f.content}\n\`\`\``).join('\n\n')
379
- candidateSystemPrompt = `${REFINEMENT_SYSTEM_PROMPT}\n\n## Existing Project Files:\n${fileList}`
380
- if (typeof onProgress === 'function') {
381
- onProgress(`🔄 *[Refinement Mode]: Обнаружен существующий проект (${projectCtx.files.length} файлов). Кандидаты вносят точечные изменения...*\n\n`)
382
- }
358
+ if (isRefinement && projectContext.files.length > 0) {
359
+ const fileList = projectContext.files.map((f) => `- \`${f.relativePath}\` (${f.content.length} chars)`).join('\n')
360
+ const fileContents = projectContext.files.map((f) => `### File: ${f.relativePath}\n\`\`\`\n${f.content}\n\`\`\``).join('\n\n')
361
+ candidateSystemPrompt += `\n\nExisting project structure:\n${fileList}\n\nProject files:\n${fileContents}\n\nYou are modifying an existing project. Output modified or new files with explicit file paths.`
383
362
  }
384
363
 
385
- // Phase 1: Check if clarifying questions should be asked
386
- const needsQuestions = !skipQuestions && !isRefinement && isBroadPromptRequiringQuestions(userPrompt, messages)
364
+ let promptForCandidates = userPrompt
387
365
  if (needsQuestions) {
388
- if (typeof onProgress === 'function') {
389
- onProgress('🔍 *Задача общего характера. Советники формируют ключевые развилки...*\n\n')
366
+ promptForCandidates += '\n(Примечание: задача широкая. Предложи ключевые архитектурные и функциональные развилки для уточнения требований пользователя).'
367
+ }
368
+
369
+ const enrichedMessages = [...messages, { role: 'user', content: promptForCandidates }]
370
+
371
+ // 4. Parallel fan-out to candidate models
372
+ if (typeof onProgress === 'function') {
373
+ onProgress(`🚀 *Запуск ${referenceModels.length} моделей-кандидатов параллельно...*\n`)
374
+ }
375
+
376
+ const referenceOutputs = await runReferencesParallel(
377
+ referenceModels,
378
+ enrichedMessages,
379
+ { systemPrompt: candidateSystemPrompt, temperature: refTemp, maxTokens },
380
+ callLlm,
381
+ onProgress
382
+ )
383
+
384
+ // Fail fast if all candidates failed (#12)
385
+ const successfulRefs = referenceOutputs.filter((r) => r.ok)
386
+ if (successfulRefs.length === 0) {
387
+ const reasons = referenceOutputs.map((r) => `${r.label}: ${r.error || 'unknown error'}`).join('; ')
388
+ return {
389
+ kind: 'failure',
390
+ content: `⚠️ Все модели-советники (${referenceOutputs.length}) завершились с ошибкой: ${reasons}`,
391
+ aggregator: aggLabel,
392
+ references: referenceOutputs,
393
+ presetName: preset?.name || 'default',
394
+ isRefinement,
395
+ isFastMode,
396
+ winningIndex: 0,
397
+ winnerModel: 'none',
398
+ promotedFiles: [],
399
+ usage: {
400
+ totalTokens: 0,
401
+ totalCostUsd: 0,
402
+ candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
403
+ aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
404
+ },
405
+ durationMs: Date.now() - startTime,
390
406
  }
407
+ }
391
408
 
392
- const questionOutputs = await runReferencesParallel(
393
- referenceModels,
394
- [...messages, { role: 'user', content: userPrompt }],
395
- { systemPrompt: ADVISOR_QUESTION_PROMPT, referenceTemperature: 0.5, maxTokens: 1024 },
396
- callLlm,
397
- onProgress,
398
- )
409
+ // 5. Extract file blocks & write candidate workspaces (#1, #2)
410
+ for (let i = 0; i < referenceOutputs.length; i++) {
411
+ const ref = referenceOutputs[i]
412
+ if (!ref.ok) continue
413
+ const files = extractFileBlocks(ref.text)
414
+ ref.files = files
415
+ if (files.length > 0) {
416
+ await writeCandidateWorkspace(cwd, i + 1, files)
417
+ }
418
+ }
399
419
 
420
+ // 6. Questionnaire synthesis branch (#7)
421
+ if (needsQuestions) {
400
422
  if (typeof onProgress === 'function') {
401
- onProgress(`\n⚖️ *Судья (${slotLabel(aggregator)}) синтезирует единый опросник для вас...*\n\n`)
423
+ onProgress('📋 *Синтез опросника судьей...*\n')
402
424
  }
403
-
404
- const qSynthPrompt = buildQuestionSynthesisPrompt(userPrompt, questionOutputs)
405
- let questionsText = ''
425
+ const questionPrompt = buildQuestionSynthesisPrompt(userPrompt, referenceOutputs)
426
+ let questionsContent = ''
406
427
  try {
407
- const qPromise = callLlm({
428
+ const qRes = await callLlm({
408
429
  provider: aggregator.provider,
409
430
  model: aggregator.model,
410
- messages: [{ role: 'user', content: qSynthPrompt }],
431
+ messages: [{ role: 'user', content: questionPrompt }],
411
432
  temperature: 0.3,
412
- maxTokens: 1500,
433
+ maxTokens: 2048,
413
434
  })
414
- const qTimeout = new Promise((_, reject) =>
415
- setTimeout(() => reject(new Error('Timeout waiting for judge questions synthesis')), 60000).unref()
416
- )
417
- const res = await Promise.race([qPromise, qTimeout])
418
- questionsText = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
419
- } catch (err) {
420
- questionsText = `Не удалось сформировать вопросы судьи: ${err?.message || String(err)}`
435
+ questionsContent = typeof qRes === 'string' ? qRes : (qRes?.content || qRes?.text || '')
436
+ } catch {
437
+ questionsContent = '### Уточнение требований к проекту\nПожалуйста, уточните детали реализации и желаемый стек.'
421
438
  }
422
439
 
423
440
  return {
424
441
  kind: 'questions',
425
- content: questionsText,
426
- aggregator: slotLabel(aggregator),
427
- references: questionOutputs,
442
+ content: questionsContent,
443
+ references: referenceOutputs,
428
444
  presetName: preset?.name || 'default',
445
+ isRefinement: false,
429
446
  }
430
447
  }
431
448
 
432
- // FAST MODE: single candidate model bypasses aggregator (#14)
433
- const isFastMode = referenceModels.length === 1
449
+ // 7. Fast mode bypass for single candidate
450
+ if (isFastMode) {
451
+ const single = referenceOutputs[0]
452
+ let promotedFiles = []
453
+ if (single.files && single.files.length > 0) {
454
+ promotedFiles = await promoteCandidateWorkspace(cwd, 1)
455
+ } else {
456
+ await cleanMoaWorkspaces(cwd)
457
+ }
434
458
 
435
- // Phase 2: Parallel execution & file generation
436
- if (typeof onProgress === 'function') {
437
- const modeLabel = isFastMode ? '⚡ Fast Mode' : `советниками (${referenceModels.length})`
438
- onProgress(`🚀 *Запускаю реализацию ${modeLabel}...*\n\n`)
439
- }
459
+ const totalTokens = single.usage?.totalTokens || 0
460
+ const totalCostUsd = single.costUsd || 0
461
+ const durationMs = Date.now() - startTime
440
462
 
441
- const referenceOutputs = await runReferencesParallel(
442
- referenceModels,
443
- [...messages, { role: 'user', content: userPrompt }],
444
- { systemPrompt: candidateSystemPrompt, referenceTemperature: refTemp, maxTokens },
445
- callLlm,
446
- onProgress,
447
- )
448
-
449
- // Write files for each candidate into .moa/candidate-X/
450
- for (let i = 0; i < referenceOutputs.length; i++) {
451
- const ref = referenceOutputs[i]
452
- if (ref.ok && ref.files && ref.files.length > 0) {
453
- try {
454
- await writeCandidateWorkspace(workDir, i + 1, ref.files)
455
- if (typeof onProgress === 'function') {
456
- onProgress(`📁 *Кандидат ${i + 1} (${ref.label}) сохранил ${ref.files.length} файл(а) в .moa/candidate-${i + 1}/*\n`)
457
- }
458
- } catch (writeErr) {
459
- console.warn(`[dsh-moa] Failed to write candidate ${i + 1} workspace:`, writeErr)
460
- }
463
+ return {
464
+ kind: 'synthesis',
465
+ content: single.text,
466
+ aggregator: 'Fast Mode (Direct)',
467
+ references: referenceOutputs,
468
+ presetName: preset?.name || 'default',
469
+ isRefinement,
470
+ isFastMode: true,
471
+ winningIndex: 1,
472
+ winnerModel: single.label,
473
+ promotedFiles,
474
+ usage: {
475
+ totalTokens,
476
+ totalCostUsd,
477
+ candidates: [{ label: single.label, usage: single.usage, costUsd: single.costUsd }],
478
+ aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
479
+ },
480
+ durationMs,
461
481
  }
462
482
  }
463
483
 
484
+ // 8. Aggregator / Judge synthesis (#5, #8)
485
+ if (typeof onProgress === 'function') {
486
+ onProgress(`⚖️ *Ведущая модель (${aggLabel}) оценивает варианты и синтезирует решение...*\n`)
487
+ }
488
+
489
+ const synthesisPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria)
464
490
  let synthesizedText = ''
465
- let winningIndex = 1
466
491
  let aggUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
467
- const aggLabel = slotLabel(aggregator)
468
-
469
- if (isFastMode) {
470
- // Fast mode: promote candidate 1 directly without judge
471
- synthesizedText = referenceOutputs[0]?.text || '(empty fast response)'
472
- winningIndex = 1
473
- } else {
474
- // Phase 3: Aggregator evaluation
475
- if (typeof onProgress === 'function') {
476
- onProgress(`\n⚖️ *Все кандидаты завершили генерацию. Судья (${aggLabel}) оценивает код и файлы...*\n\n`)
477
- }
478
492
 
479
- const synthPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria)
480
-
481
- try {
482
- const aggPromise = callLlm({
483
- provider: aggregator.provider,
484
- model: aggregator.model,
485
- messages: [{ role: 'user', content: synthPrompt }],
486
- temperature: aggTemp,
487
- maxTokens,
488
- })
489
- const aggTimeout = new Promise((_, reject) =>
490
- setTimeout(() => reject(new Error(`Timeout after 90s waiting for aggregator ${aggLabel}`)), 90000).unref()
491
- )
492
- const res = await Promise.race([aggPromise, aggTimeout])
493
- synthesizedText = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
494
-
495
- const rawAggUsage = res?.usage || {
496
- inputTokens: Math.round(synthPrompt.length / 4),
497
- outputTokens: Math.round(synthesizedText.length / 4),
498
- }
499
- aggUsage = estimateTokenCost(aggregator, rawAggUsage, prices)
500
- } catch (err) {
501
- synthesizedText = `[Aggregator error: ${err?.message || String(err)}]\n\nFallback candidate outputs:\n\n` +
502
- referenceOutputs.map((r, i) => `### ${r.label}\n${r.text}`).join('\n\n')
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)),
503
505
  }
504
-
505
- winningIndex = parseWinnerIndex(synthesizedText, 1)
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')
506
511
  }
507
512
 
508
- // Phase 4: Promote winner files and cleanup
513
+ // 9. Evaluate winner & promote files (#1, #2)
514
+ const winningIndex = parseWinnerIndex(synthesizedText, 1)
509
515
  let promotedFiles = []
510
- try {
511
- promotedFiles = await promoteCandidateWorkspace(workDir, winningIndex)
512
- if (typeof onProgress === 'function' && promotedFiles.length > 0) {
513
- onProgress(`\n✅ *Файлы победителя (Кандидат ${winningIndex}) перенесены в проект: ${promotedFiles.join(', ')}*\n\n`)
514
- }
515
- } catch (promoteErr) {
516
- console.warn('[dsh-moa] Error promoting candidate files:', promoteErr)
516
+ if (referenceOutputs[winningIndex - 1]?.files?.length > 0) {
517
+ promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex)
518
+ } else if (successfulRefs[0]?.files?.length > 0) {
519
+ const fallbackIdx = successfulRefs[0].index
520
+ promotedFiles = await promoteCandidateWorkspace(cwd, fallbackIdx)
521
+ } else {
522
+ await cleanMoaWorkspaces(cwd)
517
523
  }
518
524
 
519
- // Phase 5: Live Canvas preview auto-registration
525
+ // 10. Live Canvas preview (#16)
520
526
  let liveCanvas = null
521
- if (promotedFiles && promotedFiles.length > 0) {
522
- const previewCandidate = promotedFiles.find(f => /\.(html|htm|jsx|tsx|vue|svelte)$/i.test(f)) || promotedFiles[0]
523
- if (previewCandidate) {
524
- try {
525
- const port = process.env.PORT || 3080
526
- const lcRes = await fetch(`http://127.0.0.1:${port}/dsh-live-canvas/api/open-file`, {
527
- method: 'POST',
528
- headers: { 'Content-Type': 'application/json' },
529
- body: JSON.stringify({ filePath: previewCandidate }),
530
- signal: AbortSignal.timeout(500),
531
- })
532
- if (lcRes.ok) {
533
- const lcData = await lcRes.json()
534
- if (lcData?.canvasId) {
535
- liveCanvas = {
536
- canvasId: lcData.canvasId,
537
- title: lcData.title || previewCandidate,
538
- filePath: previewCandidate,
539
- previewUrl: lcData.previewUrl || `/dsh-live-canvas/sandbox/${lcData.canvasId}`
540
- }
541
- if (typeof onProgress === 'function') {
542
- onProgress(`\n🎨 *[Live Canvas]: Файл ${previewCandidate} открыт для предпросмотра!*\n\n`)
543
- }
544
- }
545
- }
546
- } catch (err) {
547
- // live-canvas plugin might not be installed or reachable, non-fatal
548
- }
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',
549
533
  }
550
534
  }
551
535
 
552
- // Calculate totals
553
536
  const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + aggUsage.totalTokens
554
537
  const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + aggUsage.costUsd).toFixed(5))
555
538
  const durationMs = Date.now() - startTime
@@ -627,6 +610,12 @@ export function formatMoAResponse({ moaResult, presetName }) {
627
610
  return parts.join('\n')
628
611
  }
629
612
 
613
+ if (moaResult?.kind === 'failure') {
614
+ parts.push('## ⚠️ Mixture of Agents — Сбой выполнения')
615
+ parts.push(moaResult.content)
616
+ return parts.join('\n')
617
+ }
618
+
630
619
  const modeBadge = moaResult?.isFastMode ? '⚡ Fast Mode' : `Судья: ${judge}`
631
620
  parts.push(`## 🧠 Mixture of Agents (Пресет: ${pName} | ${modeBadge})`)
632
621
  parts.push('')
@@ -691,73 +680,94 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
691
680
  yield { type: 'block-start', index: 0, blockType: 'text' }
692
681
  yield { type: 'text-delta', index: 0, text: '🧠 *Mixture of Agents запущен...*\n\n' }
693
682
 
694
- // Queue for streaming progress updates from within runMoAPipeline
695
- const progressChunks = []
696
- const onProgress = (text) => {
697
- progressChunks.push(text)
683
+ // Async push-queue for zero-latency live delta streaming
684
+ const queue = []
685
+ let notify = null
686
+
687
+ const pushUpdate = (text) => {
688
+ queue.push({ type: 'text-delta', index: 0, text })
689
+ if (notify) {
690
+ notify()
691
+ notify = null
692
+ }
698
693
  }
699
694
 
700
- try {
701
- const pipelinePromise = runMoAPipeline({
702
- userPrompt,
703
- messages,
704
- preset: targetPreset,
705
- callLlm,
706
- cwd,
707
- onProgress,
695
+ let done = false
696
+ const pipelinePromise = runMoAPipeline({
697
+ userPrompt,
698
+ messages,
699
+ preset: targetPreset,
700
+ callLlm,
701
+ cwd,
702
+ onProgress: pushUpdate,
703
+ })
704
+ .catch((err) => ({ error: err }))
705
+ .finally(() => {
706
+ done = true
707
+ if (notify) {
708
+ notify()
709
+ notify = null
710
+ }
708
711
  })
709
712
 
710
- const startTime = Date.now()
711
- let lastYieldTime = Date.now()
713
+ const startTime = Date.now()
714
+ let lastYieldTime = Date.now()
712
715
 
713
- // Yield progressive updates while pipeline runs
714
- while (true) {
715
- if (signal?.aborted) return
716
+ try {
717
+ while (!done || queue.length > 0) {
718
+ if (signal?.aborted) {
719
+ await cleanMoaWorkspaces(cwd)
720
+ return
721
+ }
716
722
 
717
- let emittedAny = false
718
- while (progressChunks.length > 0) {
719
- const chunk = progressChunks.shift()
720
- yield { type: 'text-delta', index: 0, text: chunk }
721
- emittedAny = true
723
+ while (queue.length > 0) {
724
+ const item = queue.shift()
725
+ yield item
722
726
  lastYieldTime = Date.now()
723
727
  }
724
728
 
725
- const done = await Promise.race([
726
- pipelinePromise.then(() => true),
727
- new Promise((r) => { const t = setTimeout(() => r(false), 500); t.unref?.(); }),
728
- ])
729
729
  if (done) break
730
730
 
731
+ // Wait for next push item or max 2.5s heartbeat
732
+ await Promise.race([
733
+ new Promise((resolve) => { notify = resolve }),
734
+ new Promise((resolve) => { const t = setTimeout(resolve, 2500); t.unref?.(); }),
735
+ ])
736
+
731
737
  const elapsedSec = Math.floor((Date.now() - startTime) / 1000)
732
- // If 3 seconds passed without any progress message, send a live tick
733
- if (!emittedAny && Date.now() - lastYieldTime >= 3000) {
738
+ if (!done && Date.now() - lastYieldTime >= 3000) {
734
739
  yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}с] Идёт обработка...*\n` }
735
740
  lastYieldTime = Date.now()
736
741
  }
737
742
  }
738
743
 
739
- while (progressChunks.length > 0) {
740
- const chunk = progressChunks.shift()
741
- yield { type: 'text-delta', index: 0, text: chunk }
744
+ const result = await pipelinePromise
745
+ if (signal?.aborted) {
746
+ await cleanMoaWorkspaces(cwd)
747
+ return
742
748
  }
743
749
 
744
- const moaResult = await pipelinePromise
745
- if (signal?.aborted) return
750
+ if (result?.error) {
751
+ const errText = '\n\n⚠️ **Ошибка Mixture of Agents**: ' + (result.error?.message || String(result.error))
752
+ yield { type: 'text-delta', index: 0, text: errText }
753
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
754
+ yield { type: 'finish', reason: { kind: 'stop' } }
755
+ return
756
+ }
746
757
 
747
758
  const formatted = formatMoAResponse({
748
- moaResult,
759
+ moaResult: result,
749
760
  presetName: targetPreset?.name || 'default',
750
761
  })
751
762
 
752
763
  yield { type: 'text-delta', index: 0, text: '\n---\n\n' + formatted }
753
764
  yield { type: 'block-end', index: 0, block: { type: 'text', text: formatted } }
754
- yield { type: 'usage', usage: { inputTokens: moaResult?.usage?.totalTokens || 100, outputTokens: formatted.length } }
755
- yield { type: 'finish', reason: { kind: 'stop' } }
756
- } catch (err) {
757
- if (signal?.aborted) return
758
- const errText = '\n\n⚠️ **Ошибка Mixture of Agents**: ' + (err?.message || String(err))
759
- yield { type: 'text-delta', index: 0, text: errText }
760
- yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
765
+ yield { type: 'usage', usage: { inputTokens: result?.usage?.totalTokens || 100, outputTokens: formatted.length } }
761
766
  yield { type: 'finish', reason: { kind: 'stop' } }
767
+ } finally {
768
+ if (signal?.aborted) {
769
+ await cleanMoaWorkspaces(cwd)
770
+ }
762
771
  }
763
772
  }
773
+