@goodandready/dsh-moa 0.2.8 → 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,244 +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 allowQuestions = preset?.ask_clarifying_questions !== false && preset?.askClarifyingQuestions !== false
387
- const needsQuestions = allowQuestions && !skipQuestions && !isRefinement && isBroadPromptRequiringQuestions(userPrompt, messages)
364
+ let promptForCandidates = userPrompt
388
365
  if (needsQuestions) {
389
- if (typeof onProgress === 'function') {
390
- onProgress('🔍 *Задача общего характера. Советники формируют ключевые развилки...*\n\n')
391
- }
392
-
393
- const questionOutputs = await runReferencesParallel(
394
- referenceModels,
395
- [...messages, { role: 'user', content: userPrompt }],
396
- { systemPrompt: ADVISOR_QUESTION_PROMPT, referenceTemperature: 0.5, maxTokens: 1024 },
397
- callLlm,
398
- onProgress,
399
- )
400
-
401
- if (typeof onProgress === 'function') {
402
- onProgress(`\n⚖️ *Судья (${slotLabel(aggregator)}) синтезирует единый опросник для вас...*\n\n`)
403
- }
404
-
405
- const qSynthPrompt = buildQuestionSynthesisPrompt(userPrompt, questionOutputs)
406
- let questionsText = ''
407
- try {
408
- const qPromise = callLlm({
409
- provider: aggregator.provider,
410
- model: aggregator.model,
411
- messages: [{ role: 'user', content: qSynthPrompt }],
412
- temperature: 0.3,
413
- maxTokens: 1500,
414
- })
415
- const qTimeout = new Promise((_, reject) =>
416
- setTimeout(() => reject(new Error('Timeout waiting for judge questions synthesis')), 60000).unref()
417
- )
418
- const res = await Promise.race([qPromise, qTimeout])
419
- questionsText = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
420
- } catch (err) {
421
- questionsText = `Не удалось сформировать вопросы судьи: ${err?.message || String(err)}`
422
- }
423
-
424
- return {
425
- kind: 'questions',
426
- content: questionsText,
427
- aggregator: slotLabel(aggregator),
428
- references: questionOutputs,
429
- presetName: preset?.name || 'default',
430
- }
366
+ promptForCandidates += '\n(Примечание: задача широкая. Предложи ключевые архитектурные и функциональные развилки для уточнения требований пользователя).'
431
367
  }
432
368
 
433
- // FAST MODE: single candidate model bypasses aggregator (#14)
434
- const isFastMode = referenceModels.length === 1
369
+ const enrichedMessages = [...messages, { role: 'user', content: promptForCandidates }]
435
370
 
436
- // Phase 2: Parallel execution & file generation
371
+ // 4. Parallel fan-out to candidate models
437
372
  if (typeof onProgress === 'function') {
438
- const modeLabel = isFastMode ? '⚡ Fast Mode' : `советниками (${referenceModels.length})`
439
- onProgress(`🚀 *Запускаю реализацию ${modeLabel}...*\n\n`)
373
+ onProgress(`🚀 *Запуск ${referenceModels.length} моделей-кандидатов параллельно...*\n`)
440
374
  }
441
375
 
442
376
  const referenceOutputs = await runReferencesParallel(
443
377
  referenceModels,
444
- [...messages, { role: 'user', content: userPrompt }],
445
- { systemPrompt: candidateSystemPrompt, referenceTemperature: refTemp, maxTokens },
378
+ enrichedMessages,
379
+ { systemPrompt: candidateSystemPrompt, temperature: refTemp, maxTokens },
446
380
  callLlm,
447
- onProgress,
381
+ onProgress
448
382
  )
449
383
 
450
- // Fast-fail: If all candidates failed, do not call judge to save tokens and avoid synthesized hallucinations
451
- const allFailed = referenceOutputs.length > 0 && referenceOutputs.every((r) => !r.ok)
452
- if (allFailed) {
453
- const errorDetails = referenceOutputs
454
- .map((r, i) => `• Кандидат ${i + 1} (${r.label}): ${r.text}`)
455
- .join('\n')
456
- const failMessage = `⚠️ **Все модели-советники (${referenceOutputs.length}) завершились с ошибкой**.\n\nСудья не вызывался для предотвращения бессмысленного расхода токенов.\n\n### Детали ошибок:\n${errorDetails}`
457
-
458
- await cleanMoaWorkspaces(workDir)
459
-
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('; ')
460
388
  return {
461
389
  kind: 'failure',
462
- content: failMessage,
463
- aggregator: slotLabel(aggregator),
390
+ content: `⚠️ Все модели-советники (${referenceOutputs.length}) завершились с ошибкой: ${reasons}`,
391
+ aggregator: aggLabel,
464
392
  references: referenceOutputs,
465
393
  presetName: preset?.name || 'default',
394
+ isRefinement,
395
+ isFastMode,
396
+ winningIndex: 0,
397
+ winnerModel: 'none',
466
398
  promotedFiles: [],
467
399
  usage: {
468
400
  totalTokens: 0,
469
401
  totalCostUsd: 0,
470
- candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: 0 })),
402
+ candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
471
403
  aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
472
404
  },
473
405
  durationMs: Date.now() - startTime,
474
406
  }
475
407
  }
476
- // Write files for each candidate into .moa/candidate-X/
408
+
409
+ // 5. Extract file blocks & write candidate workspaces (#1, #2)
477
410
  for (let i = 0; i < referenceOutputs.length; i++) {
478
411
  const ref = referenceOutputs[i]
479
- if (ref.ok && ref.files && ref.files.length > 0) {
480
- try {
481
- await writeCandidateWorkspace(workDir, i + 1, ref.files)
482
- if (typeof onProgress === 'function') {
483
- onProgress(`📁 *Кандидат ${i + 1} (${ref.label}) сохранил ${ref.files.length} файл(а) в .moa/candidate-${i + 1}/*\n`)
484
- }
485
- } catch (writeErr) {
486
- console.warn(`[dsh-moa] Failed to write candidate ${i + 1} workspace:`, writeErr)
487
- }
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)
488
417
  }
489
418
  }
490
419
 
491
- let synthesizedText = ''
492
- let winningIndex = 1
493
- let aggUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
494
- const aggLabel = slotLabel(aggregator)
495
-
496
- if (isFastMode) {
497
- // Fast mode: promote candidate 1 directly without judge
498
- synthesizedText = referenceOutputs[0]?.text || '(empty fast response)'
499
- winningIndex = 1
500
- } else {
501
- // Phase 3: Aggregator evaluation
420
+ // 6. Questionnaire synthesis branch (#7)
421
+ if (needsQuestions) {
502
422
  if (typeof onProgress === 'function') {
503
- onProgress(`\n⚖️ *Все кандидаты завершили генерацию. Судья (${aggLabel}) оценивает код и файлы...*\n\n`)
423
+ onProgress('📋 *Синтез опросника судьей...*\n')
504
424
  }
505
-
506
- const synthPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria)
507
-
425
+ const questionPrompt = buildQuestionSynthesisPrompt(userPrompt, referenceOutputs)
426
+ let questionsContent = ''
508
427
  try {
509
- const aggPromise = callLlm({
428
+ const qRes = await callLlm({
510
429
  provider: aggregator.provider,
511
430
  model: aggregator.model,
512
- messages: [{ role: 'user', content: synthPrompt }],
513
- temperature: aggTemp,
514
- maxTokens,
431
+ messages: [{ role: 'user', content: questionPrompt }],
432
+ temperature: 0.3,
433
+ maxTokens: 2048,
515
434
  })
516
- const aggTimeout = new Promise((_, reject) =>
517
- setTimeout(() => reject(new Error(`Timeout after 90s waiting for aggregator ${aggLabel}`)), 90000).unref()
518
- )
519
- const res = await Promise.race([aggPromise, aggTimeout])
520
- synthesizedText = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
521
-
522
- const rawAggUsage = res?.usage || {
523
- inputTokens: Math.round(synthPrompt.length / 4),
524
- outputTokens: Math.round(synthesizedText.length / 4),
525
- }
526
- aggUsage = estimateTokenCost(aggregator, rawAggUsage, prices)
527
- } catch (err) {
528
- synthesizedText = `[Aggregator error: ${err?.message || String(err)}]\n\nFallback candidate outputs:\n\n` +
529
- referenceOutputs.map((r, i) => `### ${r.label}\n${r.text}`).join('\n\n')
435
+ questionsContent = typeof qRes === 'string' ? qRes : (qRes?.content || qRes?.text || '')
436
+ } catch {
437
+ questionsContent = '### Уточнение требований к проекту\nПожалуйста, уточните детали реализации и желаемый стек.'
530
438
  }
531
439
 
532
- winningIndex = parseWinnerIndex(synthesizedText, 1)
440
+ return {
441
+ kind: 'questions',
442
+ content: questionsContent,
443
+ references: referenceOutputs,
444
+ presetName: preset?.name || 'default',
445
+ isRefinement: false,
446
+ }
533
447
  }
534
448
 
535
- // Phase 4: Promote winner files and cleanup
536
- let promotedFiles = []
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
+ }
458
+
459
+ const totalTokens = single.usage?.totalTokens || 0
460
+ const totalCostUsd = single.costUsd || 0
461
+ const durationMs = Date.now() - startTime
462
+
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,
481
+ }
482
+ }
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)
490
+ let synthesizedText = ''
491
+ let aggUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
492
+
537
493
  try {
538
- promotedFiles = await promoteCandidateWorkspace(workDir, winningIndex)
539
- if (typeof onProgress === 'function' && promotedFiles.length > 0) {
540
- onProgress(`\n✅ *Файлы победителя (Кандидат ${winningIndex}) перенесены в проект: ${promotedFiles.join(', ')}*\n\n`)
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)),
541
505
  }
542
- } catch (promoteErr) {
543
- console.warn('[dsh-moa] Error promoting candidate files:', promoteErr)
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')
544
511
  }
545
512
 
546
- // Phase 5: Live Canvas preview auto-registration
513
+ // 9. Evaluate winner & promote files (#1, #2)
514
+ const winningIndex = parseWinnerIndex(synthesizedText, 1)
515
+ let promotedFiles = []
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)
523
+ }
524
+
525
+ // 10. Live Canvas preview (#16)
547
526
  let liveCanvas = null
548
- if (promotedFiles && promotedFiles.length > 0) {
549
- const previewCandidate = promotedFiles.find(f => /\.(html|htm|jsx|tsx|vue|svelte)$/i.test(f)) || promotedFiles[0]
550
- if (previewCandidate) {
551
- try {
552
- const port = process.env.PORT || 3080
553
- const lcRes = await fetch(`http://127.0.0.1:${port}/dsh-live-canvas/api/open-file`, {
554
- method: 'POST',
555
- headers: { 'Content-Type': 'application/json' },
556
- body: JSON.stringify({ filePath: previewCandidate }),
557
- signal: AbortSignal.timeout(500),
558
- })
559
- if (lcRes.ok) {
560
- const lcData = await lcRes.json()
561
- if (lcData?.canvasId) {
562
- liveCanvas = {
563
- canvasId: lcData.canvasId,
564
- title: lcData.title || previewCandidate,
565
- filePath: previewCandidate,
566
- previewUrl: lcData.previewUrl || `/dsh-live-canvas/sandbox/${lcData.canvasId}`
567
- }
568
- if (typeof onProgress === 'function') {
569
- onProgress(`\n🎨 *[Live Canvas]: Файл ${previewCandidate} открыт для предпросмотра!*\n\n`)
570
- }
571
- }
572
- }
573
- } catch (err) {
574
- // live-canvas plugin might not be installed or reachable, non-fatal
575
- }
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',
576
533
  }
577
534
  }
578
535
 
579
- // Calculate totals
580
536
  const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + aggUsage.totalTokens
581
537
  const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + aggUsage.costUsd).toFixed(5))
582
538
  const durationMs = Date.now() - startTime
@@ -654,6 +610,12 @@ export function formatMoAResponse({ moaResult, presetName }) {
654
610
  return parts.join('\n')
655
611
  }
656
612
 
613
+ if (moaResult?.kind === 'failure') {
614
+ parts.push('## ⚠️ Mixture of Agents — Сбой выполнения')
615
+ parts.push(moaResult.content)
616
+ return parts.join('\n')
617
+ }
618
+
657
619
  const modeBadge = moaResult?.isFastMode ? '⚡ Fast Mode' : `Судья: ${judge}`
658
620
  parts.push(`## 🧠 Mixture of Agents (Пресет: ${pName} | ${modeBadge})`)
659
621
  parts.push('')
@@ -808,3 +770,4 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
808
770
  }
809
771
  }
810
772
  }
773
+