@goodandready/dsh-moa 0.2.8 → 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,84 +1,75 @@
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
+ * DeepSeek Harness Mixture of Agents (MoA) — Runner Engine
3
+ *
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
6
11
  */
7
12
 
13
+ import path from 'node:path'
8
14
  import {
9
15
  extractFileBlocks,
10
- writeCandidateWorkspace,
11
- promoteCandidateWorkspace,
12
- cleanMoaWorkspaces,
13
16
  collectProjectContext,
14
17
  isRefinementTask,
15
- } from './file-workspace.js'
16
-
17
- import { recordMoaRun } from './history.js'
18
-
19
- export {
20
- extractFileBlocks,
21
18
  writeCandidateWorkspace,
22
19
  promoteCandidateWorkspace,
23
20
  cleanMoaWorkspaces,
24
- collectProjectContext,
25
- isRefinementTask,
26
- }
21
+ } from './file-workspace.js'
22
+ import { estimateTokenCost } from './pricing.js'
23
+ import { recordMoaRun } from './history.js'
27
24
 
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
- }
25
+ export { estimateTokenCost } from './pricing.js'
26
+
27
+ export const DEFAULT_PROMPT = 'Please propose an optimal, well-structured, production-ready solution with full code and explanations.'
28
+
29
+ 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.
30
+ Your task is to provide the highest-quality, robust, complete, production-ready solution to the user request.
31
+ Write clean, modern, fully functional code without placeholders or shortcuts.
32
+ When generating files for a project, explicitly specify file paths using fenced code blocks with file annotations, e.g.:
33
+ \`\`\`html file="index.html"
34
+ \`\`\`
35
+ \`\`\`javascript file="script.js"
36
+ \`\`\`
37
+ \`\`\`css file="style.css"
38
+ \`\`\``
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.`
68
58
 
69
59
  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'
60
+ if (!slot) return 'unknown'
61
+ if (typeof slot === 'string') return slot
62
+ if (slot.provider && slot.model) return `${slot.provider}:${slot.model}`
63
+ return slot.model || slot.provider || 'unknown'
75
64
  }
76
65
 
77
66
  /**
78
- * Strips tool results, system prompts, and tool calls from history
79
- * to give reference advisors a clean, advisory-safe context.
67
+ * Extracts and sanitizes conversation history for candidate models.
68
+ * Robust against complex DSH message content types (strings, part arrays, objects, tool calls).
80
69
  */
81
- export function cleanAdvisoryMessages(messages = [], maxCharBudget = 4000) {
70
+ export function cleanAdvisoryMessages(messages = [], maxCharBudget = 24000) {
71
+ if (!Array.isArray(messages)) return []
72
+
82
73
  const trimmed = []
83
74
  for (const msg of messages) {
84
75
  if (!msg || typeof msg !== 'object') continue
@@ -90,12 +81,21 @@ export function cleanAdvisoryMessages(messages = [], maxCharBudget = 4000) {
90
81
  text = msg.content
91
82
  } else if (Array.isArray(msg.content)) {
92
83
  text = msg.content
93
- .filter((part) => part && part.type === 'text' && typeof part.text === 'string')
84
+ .filter((part) => part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string')
94
85
  .map((part) => part.text)
95
86
  .join('\n')
87
+ } else if (msg.content && typeof msg.content === 'object') {
88
+ if (typeof msg.content.text === 'string') {
89
+ text = msg.content.text
90
+ } else if (typeof msg.content.content === 'string') {
91
+ text = msg.content.content
92
+ }
93
+ } else if (typeof msg.text === 'string') {
94
+ text = msg.text
96
95
  }
97
96
 
98
- if (!text.trim()) continue
97
+ text = text.trim()
98
+ if (!text) continue
99
99
 
100
100
  if (text.length > maxCharBudget) {
101
101
  const head = text.slice(0, Math.floor(maxCharBudget * 0.7))
@@ -118,7 +118,7 @@ export function isBroadPromptRequiringQuestions(userPrompt = '', messages = [])
118
118
  }
119
119
 
120
120
  const hasRecentQuestion = messages.some((m) => {
121
- const text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '')
121
+ const text = typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content || '')
122
122
  return text.includes('Уточнение требований') || text.includes('опросник') || text.includes('Вариант 1')
123
123
  })
124
124
  if (hasRecentQuestion) {
@@ -145,28 +145,81 @@ export function isBroadPromptRequiringQuestions(userPrompt = '', messages = [])
145
145
 
146
146
  export function buildQuestionSynthesisPrompt(userPrompt, referenceOutputs = []) {
147
147
  const joined = referenceOutputs
148
- .map((r, i) => `Советник ${i + 1} (${r.label}):\n${r.text}`)
148
+ .map((r, i) => `Advisor ${i + 1} (${r.label}):\n${r.text}`)
149
149
  .join('\n\n')
150
150
 
151
- return `Ты — ведущий архитектор и судья в архитектуре Mixture of Agents (MoA).
152
- Пользователь дал задачу:
151
+ return `You are the lead architect and judge in a Mixture of Agents (MoA) ensemble.
152
+ The user gave the task:
153
153
  "${userPrompt}"
154
154
 
155
- Советники предложили следующие развилки и уточнения:
155
+ The advisors proposed the following decision points and clarifications:
156
156
  ${joined}
157
157
 
158
- Твоя задача — синтезировать единый, компактный, дружелюбный и структурированный опросник (2-4 вопроса) для пользователя на русском языке.
159
- Каждый вопрос должен предлагать 2-3 конкретных рекомендуемых варианта ответа (например: 1. Формат: HTML/JS в одном файле или React? 2. Стиль: Минимализм, iOS или Необрутализм?).
160
- В конце добавь примечание, что пользователь может ответить кратко (например: "1, 2, темная тема") или довериться выбору по умолчанию.`
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
161
  }
162
162
 
163
- export function buildSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCriteria = '') {
163
+ export function buildCuratorSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCriteria = '') {
164
164
  const joined = referenceOutputs
165
165
  .map((r, i) => {
166
166
  const fileSummary = (r.files && r.files.length > 0)
167
- ? ` [Созданные файлы: ${r.files.map((f) => f.relativePath).join(', ')}]`
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:
188
+ ${joined}
189
+
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.`
211
+ }
212
+
213
+ export function buildSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCriteria = '', options = {}) {
214
+ if (options.curatorSynthesis) {
215
+ return buildCuratorSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria)
216
+ }
217
+
218
+ const joined = referenceOutputs
219
+ .map((r, i) => {
220
+ const fileSummary = (r.files && r.files.length > 0)
221
+ ? ` [Files created: ${r.files.map((f) => f.relativePath).join(', ')}]`
168
222
  : ''
169
- // If 3+ candidates, summarize text to prevent judge context overflow (#13)
170
223
  let textContent = r.text
171
224
  if (referenceOutputs.length >= 3 && textContent.length > 3000) {
172
225
  textContent = stripOrSummarizeCode(textContent)
@@ -176,7 +229,7 @@ export function buildSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCri
176
229
  .join('\n\n')
177
230
 
178
231
  const criteriaBlock = judgeCriteria && judgeCriteria.trim()
179
- ? `\n### 🎯 Дополнительные критерии оценки от пользователя:\n${judgeCriteria.trim()}\n`
232
+ ? `\n### 🎯 Additional evaluation criteria from the user:\n${judgeCriteria.trim()}\n`
180
233
  : ''
181
234
 
182
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.
@@ -187,37 +240,53 @@ ${criteriaBlock}
187
240
  Reference responses from candidate models:
188
241
  ${joined}
189
242
 
243
+ ${ANTIPATTERNS_RUBRIC}
244
+
190
245
  Instructions:
191
246
  Your response MUST be structured into three clear parts (respond in the same language as the user's prompt, e.g. Russian):
192
247
 
193
- ### 1. ⚖️ Вердикт судьи и анализ вариантов
194
- - **Чей вариант выбран**: Чётко укажи имя модели и номер кандидата (например: "Победитель: Кандидат 1 (opencode-go:deepseek-v4-flash)" или "Выбран вариант модели Reference 1 (label)").
195
- - Обязательно добавь машинный маркер выбора победителя:
196
- WINNER_CANDIDATE_INDEX: <число от 1 до N>
197
- - **Почему сделан этот выбор**: Подробно сравни код, архитектуру, сильные стороны, недочёты и надёжность всех кандидатов.
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.
198
253
 
199
- ### 2. 📁 Созданные файлы проекта
200
- - Перечисли файлы победителя, которые были перенесены в корень проекта, и их назначение.
254
+ ### 2. 📁 Project files created
255
+ - List the winner files promoted to the project root and their purpose.
201
256
 
202
- ### 3. 🚀 Инструкция по запуску и использованию
203
- - Опиши, как открыть и запустить созданный проект.`
257
+ ### 3. 🚀 How to run and use
258
+ - Describe how to open and run the created project.`
204
259
  }
205
260
 
206
- export function parseWinnerIndex(judgeText, defaultIndex = 1) {
261
+ export function parseWinnerIndex(judgeText, defaultIndex = 1, candidateCount = Infinity) {
207
262
  if (!judgeText || typeof judgeText !== 'string') return defaultIndex
263
+ const inRange = (idx) => !isNaN(idx) && idx >= 1 && idx <= candidateCount
208
264
  const match = /WINNER_CANDIDATE_INDEX:\s*(\d+)/i.exec(judgeText)
209
265
  if (match) {
210
266
  const idx = parseInt(match[1], 10)
211
- if (!isNaN(idx) && idx >= 1) return idx
267
+ if (inRange(idx)) return idx
212
268
  }
213
- const candMatch = /(?:Кандидат|Reference)\s*(\d+)\b/i.exec(judgeText)
269
+ const candMatch = /(?:Кандидат|Candidate|Reference)\s*(\d+)\b/i.exec(judgeText)
214
270
  if (candMatch) {
215
271
  const idx = parseInt(candMatch[1], 10)
216
- if (!isNaN(idx) && idx >= 1) return idx
272
+ if (inRange(idx)) return idx
217
273
  }
218
274
  return defaultIndex
219
275
  }
220
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
+
221
290
  export function parseMoACommand(text, presets = []) {
222
291
  if (typeof text !== 'string' || !text.startsWith('/moa')) {
223
292
  return null
@@ -258,7 +327,28 @@ export function parseMoACommand(text, presets = []) {
258
327
  }
259
328
 
260
329
  /**
261
- * 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.
262
352
  */
263
353
  export async function runReferencesParallel(references, messages, options = {}, callLlm, onProgress) {
264
354
  if (!Array.isArray(references) || references.length === 0) {
@@ -267,338 +357,535 @@ export async function runReferencesParallel(references, messages, options = {},
267
357
 
268
358
  const advisoryMessages = cleanAdvisoryMessages(messages)
269
359
  const systemPrompt = options.systemPrompt || REFERENCE_SYSTEM_PROMPT
360
+ // Deterministic prefix for optimal prompt caching hit rate (#2.3)
270
361
  const fullMessages = [{ role: 'system', content: systemPrompt }, ...advisoryMessages]
271
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
+ }
272
376
 
273
- 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) => {
274
382
  const label = slotLabel(slot)
275
- if (typeof onProgress === 'function') {
276
- onProgress(`⚡ *Кандидат ${i + 1} (${label}) начал генерацию...*\n`)
277
- }
278
383
 
279
- try {
280
- const callPromise = callLlm({
281
- provider: slot.provider,
282
- model: slot.model,
283
- messages: fullMessages,
284
- temperature: options.referenceTemperature ?? 0.6,
285
- maxTokens: options.maxTokens ?? 4096,
286
- })
287
-
288
- const timeoutPromise = new Promise((_, reject) =>
289
- setTimeout(() => reject(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s waiting for ${label}`)), timeoutMs).unref()
290
- )
291
-
292
- const res = await Promise.race([callPromise, timeoutPromise])
293
- const text = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
294
- const files = extractFileBlocks(text)
295
-
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),
300
- }
301
- const costInfo = estimateTokenCost(slot, rawUsage, options.prices)
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
+ })
302
403
 
303
- if (typeof onProgress === 'function') {
304
- const fileMsg = files.length > 0 ? ` (создано файлов: ${files.length})` : ''
305
- onProgress(`✅ *Кандидат ${i + 1} (${label}) завершил ответ${fileMsg}.*\n`)
306
- }
404
+ const res = await Promise.race([callPromise, timeoutPromise])
405
+ const text = typeof res === 'string' ? res : (res?.content || res?.text || '')
307
406
 
308
- return {
309
- index: i + 1,
310
- label,
311
- provider: slot.provider,
312
- model: slot.model,
313
- text: text || '(empty response)',
314
- files,
315
- usage: costInfo,
316
- costUsd: costInfo.costUsd,
317
- ok: true,
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()
318
447
  }
319
- } catch (err) {
320
- console.warn(`[dsh-moa] Reference ${label} failed:`, err)
321
- if (typeof onProgress === 'function') {
322
- onProgress(`⚠️ *Кандидат ${i + 1} (${label}) завершился с ошибкой: ${err?.message || String(err)}*\n`)
448
+ }
449
+
450
+ runOne()
451
+ })
452
+
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
+ }
323
483
  }
324
- return {
325
- index: i + 1,
326
- label,
327
- provider: slot.provider,
328
- model: slot.model,
329
- text: `[failed: ${err?.message || String(err)}]`,
330
- files: [],
331
- usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
332
- costUsd: 0,
333
- ok: false,
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
+ }
334
503
  }
335
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()
336
515
  })
337
516
 
338
- 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
+ }
339
548
  }
340
549
 
341
550
  /**
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
551
+ * Main MoA pipeline runner with Curator Synthesis, Fallback Chains, and Live Token Streaming.
347
552
  */
348
553
  export async function runMoAPipeline({
349
554
  userPrompt,
350
555
  messages = [],
351
556
  preset,
352
557
  callLlm,
353
- cwd,
558
+ cwd = process.cwd(),
354
559
  onProgress,
355
- skipQuestions = false,
560
+ onStreamDelta,
356
561
  prices = {},
562
+ historyFilePath,
563
+ liveCanvas = null,
357
564
  }) {
358
- if (typeof callLlm !== 'function') {
359
- throw new Error('callLlm function is required for runMoAPipeline')
565
+ const startTime = Date.now()
566
+ const referenceModels = preset?.reference_models || [
567
+ { provider: 'opencode-go', model: 'deepseek-v4-flash' },
568
+ { provider: 'grok', model: 'grok-build-0.1' },
569
+ ]
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
+
574
+ const refTemp = preset?.reference_temperature ?? 0.6
575
+ const aggTemp = preset?.aggregator_temperature ?? 0.4
576
+ const maxTokens = preset?.max_tokens ?? 4096
577
+ const judgeCriteria = preset?.judge_criteria ?? ''
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
586
+ if (typeof onProgress === 'function') {
587
+ onProgress('🔍 *Scanning project context...*\n')
360
588
  }
589
+ const projectContext = await collectProjectContext(cwd)
590
+ const isRefinement = isRefinementTask(userPrompt, projectContext.files)
361
591
 
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
592
+ // 2. Check if broad prompt requires user questionnaire
593
+ const askQuestions = preset?.ask_clarifying_questions !== false
594
+ const needsQuestions = !isFastMode && askQuestions && !isRefinement && isBroadPromptRequiringQuestions(userPrompt, messages)
595
+
596
+ // 3. Build enriched prompt for candidates
376
597
  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
- }
598
+ if (isRefinement && projectContext.files.length > 0) {
599
+ const fileList = projectContext.files.map((f) => `- \`${f.relativePath}\` (${f.content.length} chars)`).join('\n')
600
+ const fileContents = projectContext.files.map((f) => `### File: ${f.relativePath}\n\`\`\`\n${f.content}\n\`\`\``).join('\n\n')
601
+ 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
602
  }
384
603
 
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)
604
+ let promptForCandidates = userPrompt
388
605
  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
- }
606
+ promptForCandidates += "\n(Note: the task is broad. Propose the key architectural and functional decision points to clarify the user's requirements.)"
431
607
  }
432
608
 
433
- // FAST MODE: single candidate model bypasses aggregator (#14)
434
- const isFastMode = referenceModels.length === 1
435
-
436
- // Phase 2: Parallel execution & file generation
437
- if (typeof onProgress === 'function') {
438
- const modeLabel = isFastMode ? '⚡ Fast Mode' : `советниками (${referenceModels.length})`
439
- onProgress(`🚀 *Запускаю реализацию ${modeLabel}...*\n\n`)
440
- }
609
+ const enrichedMessages = [...messages, { role: 'user', content: promptForCandidates }]
441
610
 
611
+ // 4. Parallel fan-out to candidate models with quorum & transient retry
442
612
  const referenceOutputs = await runReferencesParallel(
443
613
  referenceModels,
444
- [...messages, { role: 'user', content: userPrompt }],
445
- { systemPrompt: candidateSystemPrompt, referenceTemperature: refTemp, maxTokens },
614
+ enrichedMessages,
615
+ {
616
+ systemPrompt: candidateSystemPrompt,
617
+ temperature: refTemp,
618
+ maxTokens,
619
+ prices,
620
+ quorumEnabled: isQuorumEnabled,
621
+ gracePeriodSec,
622
+ maxRetries: candidateRetries,
623
+ },
446
624
  callLlm,
447
- onProgress,
625
+ onProgress
448
626
  )
449
627
 
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
-
628
+ // Fail fast if all candidates failed
629
+ const successfulRefs = referenceOutputs.filter((r) => r.ok)
630
+ if (successfulRefs.length === 0) {
631
+ const reasons = referenceOutputs.map((r) => `${r.label}: ${r.error || 'unknown error'}`).join('; ')
460
632
  return {
461
633
  kind: 'failure',
462
- content: failMessage,
463
- aggregator: slotLabel(aggregator),
634
+ content: `⚠️ All advisor models (${referenceOutputs.length}) failed: ${reasons}`,
635
+ aggregator: slotLabel(primaryJudge),
464
636
  references: referenceOutputs,
465
637
  presetName: preset?.name || 'default',
638
+ isRefinement,
639
+ isFastMode,
640
+ winningIndex: 0,
641
+ winnerModel: 'none',
466
642
  promotedFiles: [],
467
643
  usage: {
468
644
  totalTokens: 0,
469
645
  totalCostUsd: 0,
470
- candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: 0 })),
646
+ candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
471
647
  aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
472
648
  },
473
649
  durationMs: Date.now() - startTime,
474
650
  }
475
651
  }
476
- // Write files for each candidate into .moa/candidate-X/
652
+
653
+ // 5. Extract file blocks & write candidate workspaces
477
654
  for (let i = 0; i < referenceOutputs.length; i++) {
478
655
  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)
656
+ if (!ref.ok) continue
657
+ const files = extractFileBlocks(ref.text)
658
+ ref.files = files
659
+ if (files.length > 0) {
660
+ await writeCandidateWorkspace(cwd, i + 1, files)
661
+ }
662
+ }
663
+
664
+ // 6. Questionnaire synthesis branch
665
+ if (needsQuestions) {
666
+ if (typeof onProgress === 'function') {
667
+ onProgress('📋 *Judge synthesizes the clarification questionnaire...*\n')
668
+ }
669
+ const questionPrompt = buildQuestionSynthesisPrompt(userPrompt, referenceOutputs)
670
+ let questionsContent = ''
671
+ let qUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
672
+ try {
673
+ const qRes = await callWithTransientRetry(callLlm, {
674
+ provider: primaryJudge.provider,
675
+ model: primaryJudge.model,
676
+ messages: [{ role: 'user', content: questionPrompt }],
677
+ temperature: 0.3,
678
+ maxTokens: 2048,
679
+ }, 1)
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)),
487
684
  }
685
+ qUsage = estimateTokenCost(primaryJudge, qFallback, prices)
686
+ } catch {
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)
711
+ }
712
+
713
+ return {
714
+ kind: 'questions',
715
+ content: questionsContent,
716
+ references: referenceOutputs,
717
+ presetName: preset?.name || 'default',
718
+ isRefinement: false,
488
719
  }
489
720
  }
490
721
 
722
+ // 7. Fast mode bypass for single candidate
723
+ if (isFastMode) {
724
+ const single = referenceOutputs[0]
725
+ let promotedFiles = []
726
+ if (single.files && single.files.length > 0) {
727
+ promotedFiles = await promoteCandidateWorkspace(cwd, 1)
728
+ } else {
729
+ await cleanMoaWorkspaces(cwd)
730
+ }
731
+
732
+ const totalTokens = single.usage?.totalTokens || 0
733
+ const totalCostUsd = single.costUsd || 0
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
+ }
755
+
756
+ return {
757
+ kind: 'synthesis',
758
+ content: single.text,
759
+ aggregator: 'Fast Mode (Direct)',
760
+ references: referenceOutputs,
761
+ presetName: preset?.name || 'default',
762
+ isRefinement,
763
+ isFastMode: true,
764
+ winningIndex: 1,
765
+ winnerModel: single.label,
766
+ promotedFiles,
767
+ ...(livePreview ? { liveCanvas: livePreview } : {}),
768
+ usage: {
769
+ totalTokens,
770
+ totalCostUsd,
771
+ candidates: [{ label: single.label, usage: single.usage, costUsd: single.costUsd }],
772
+ aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
773
+ },
774
+ durationMs,
775
+ }
776
+ }
777
+
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
+ )
785
+
491
786
  let synthesizedText = ''
492
- let winningIndex = 1
493
787
  let aggUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
494
- const aggLabel = slotLabel(aggregator)
788
+ let chosenJudge = primaryJudge
789
+ let judgeSuccess = false
790
+ let lastJudgeError = null
791
+
792
+ for (let jIdx = 0; jIdx < judgesChain.length; jIdx++) {
793
+ const currentJudge = judgesChain[jIdx]
794
+ const currentLabel = slotLabel(currentJudge)
495
795
 
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
502
796
  if (typeof onProgress === 'function') {
503
- onProgress(`\n⚖️ *Все кандидаты завершили генерацию. Судья (${aggLabel}) оценивает код и файлы...*\n\n`)
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`)
504
800
  }
505
801
 
506
- const synthPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria)
507
-
508
802
  try {
509
- const aggPromise = callLlm({
510
- provider: aggregator.provider,
511
- model: aggregator.model,
512
- messages: [{ role: 'user', content: synthPrompt }],
803
+ const aggRes = await callWithTransientRetry(callLlm, {
804
+ provider: currentJudge.provider,
805
+ model: currentJudge.model,
806
+ messages: [{ role: 'user', content: synthesisPrompt }],
513
807
  temperature: aggTemp,
514
808
  maxTokens,
515
- })
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),
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)),
525
820
  }
526
- aggUsage = estimateTokenCost(aggregator, rawAggUsage, prices)
821
+ aggUsage = estimateTokenCost(currentJudge, aggFallbackUsage, prices)
822
+ chosenJudge = currentJudge
823
+ judgeSuccess = true
824
+ break
527
825
  } 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')
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
+ }
530
833
  }
834
+ }
531
835
 
532
- 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')
533
840
  }
534
841
 
535
- // Phase 4: Promote winner files and cleanup
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)
536
850
  let promotedFiles = []
537
- try {
538
- promotedFiles = await promoteCandidateWorkspace(workDir, winningIndex)
539
- if (typeof onProgress === 'function' && promotedFiles.length > 0) {
540
- onProgress(`\n✅ *Файлы победителя (Кандидат ${winningIndex}) перенесены в проект: ${promotedFiles.join(', ')}*\n\n`)
541
- }
542
- } catch (promoteErr) {
543
- console.warn('[dsh-moa] Error promoting candidate files:', promoteErr)
544
- }
545
851
 
546
- // Phase 5: Live Canvas preview auto-registration
547
- 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
- }
576
- }
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) {
856
+ promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex)
857
+ } else if (successfulRefs[0]?.files?.length > 0) {
858
+ const fallbackIdx = successfulRefs[0].index
859
+ promotedFiles = await promoteCandidateWorkspace(cwd, fallbackIdx)
860
+ } else {
861
+ await cleanMoaWorkspaces(cwd)
577
862
  }
578
863
 
579
- // Calculate totals
864
+ const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
865
+
580
866
  const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + aggUsage.totalTokens
581
867
  const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + aggUsage.costUsd).toFixed(5))
582
868
  const durationMs = Date.now() - startTime
583
869
 
584
870
  const winningRef = referenceOutputs[winningIndex - 1]
585
871
  const winnerModel = winningRef?.label || slotLabel(referenceModels[0])
872
+ const finalAggLabel = slotLabel(chosenJudge)
586
873
 
587
- // Record run to history (#9, #10, #11)
874
+ // Record run to history
588
875
  try {
589
876
  recordMoaRun({
590
877
  prompt: userPrompt,
591
878
  preset: preset?.name || 'default',
592
879
  isRefinement,
593
- candidates: referenceOutputs,
594
- 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 },
595
882
  winnerIndex: winningIndex,
596
883
  winnerModel,
597
884
  promotedFiles,
598
885
  totalTokens,
599
886
  totalCostUsd,
600
887
  durationMs,
601
- })
888
+ }, historyFilePath)
602
889
  } catch (histErr) {
603
890
  console.warn('[dsh-moa] Failed to record run in history:', histErr)
604
891
  }
@@ -606,19 +893,21 @@ export async function runMoAPipeline({
606
893
  return {
607
894
  kind: 'synthesis',
608
895
  content: synthesizedText,
609
- aggregator: isFastMode ? 'Fast Mode (Direct)' : aggLabel,
896
+ aggregator: isFastMode ? 'Fast Mode (Direct)' : finalAggLabel,
610
897
  references: referenceOutputs,
611
898
  presetName: preset?.name || 'default',
612
899
  isRefinement,
613
900
  isFastMode,
901
+ isCuratorSynthesis,
902
+ recommendedAssembler,
614
903
  winningIndex,
615
904
  winnerModel,
616
905
  promotedFiles,
617
- liveCanvas,
906
+ ...(livePreview ? { liveCanvas: livePreview } : {}),
618
907
  usage: {
619
908
  totalTokens,
620
909
  totalCostUsd,
621
- 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 })),
622
911
  aggregator: aggUsage,
623
912
  },
624
913
  durationMs,
@@ -636,8 +925,8 @@ export function stripOrSummarizeCode(text) {
636
925
  return match
637
926
  }
638
927
  const fileHint = fileTag || (code.match(/^\s*(?:\/\/|#|<!--|\/\*)\s*(?:file|filepath|path):\s*([^\s*]+)/im)?.[1])
639
- const label = fileHint ? `файл \`${fileHint}\`` : (lang ? `код \`${lang}\`` : 'код')
640
- 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`
641
930
  })
642
931
  }
643
932
 
@@ -648,41 +937,53 @@ export function formatMoAResponse({ moaResult, presetName }) {
648
937
  const refs = moaResult?.references || []
649
938
 
650
939
  if (moaResult?.kind === 'questions') {
651
- parts.push('## 🧠 Mixture of Agents — Уточнение требований')
652
- parts.push(`*Судья (${judge}) и советники проанализировали задачу:*\n`)
940
+ parts.push('## 🧠 Mixture of Agents — Requirements Clarification')
941
+ parts.push(`*Judge (${judge}) and the advisors analyzed the task:*\n`)
942
+ parts.push(moaResult.content)
943
+ return parts.join('\n')
944
+ }
945
+
946
+ if (moaResult?.kind === 'failure') {
947
+ parts.push('## ⚠️ Mixture of Agents — Execution Failed')
653
948
  parts.push(moaResult.content)
654
949
  return parts.join('\n')
655
950
  }
656
951
 
657
- const modeBadge = moaResult?.isFastMode ? '⚡ Fast Mode' : `Судья: ${judge}`
658
- 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})`)
659
956
  parts.push('')
660
957
 
661
958
  if (moaResult?.isRefinement) {
662
- parts.push('> 🔄 **Режим**: Итеративная доработка проекта (Refinement)')
959
+ parts.push('> 🔄 **Mode**: Iterative project refinement (Refinement)')
663
960
  }
664
961
 
665
962
  const hasPromoted = moaResult?.promotedFiles && moaResult.promotedFiles.length > 0
666
963
  if (hasPromoted) {
667
- 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}\`)`)
668
969
  }
669
970
 
670
971
  if (moaResult?.liveCanvas?.previewUrl) {
671
972
  parts.push(`> 🎨 **Live Canvas**: [🚀 Открыть ${moaResult.liveCanvas.title || 'превью'} в Live Canvas](${moaResult.liveCanvas.previewUrl}) | [↗ Открыть в новой вкладке](${moaResult.liveCanvas.previewUrl})`)
672
973
  }
673
974
 
674
- // Cost tracking card (#10)
975
+ // Cost tracking card
675
976
  if (moaResult?.usage) {
676
977
  const u = moaResult.usage
677
- const costStr = u.totalCostUsd > 0 ? `~\$${u.totalCostUsd.toFixed(4)}` : 'Бесплатно'
978
+ const costStr = u.totalCostUsd > 0 ? `~\$${u.totalCostUsd.toFixed(4)}` : 'Free'
678
979
  const tokStr = u.totalTokens >= 1000 ? `${(u.totalTokens / 1000).toFixed(1)}k` : `${u.totalTokens}`
679
- parts.push(`> 💰 **Стоимость запуска**: ${costStr} (всего ${tokStr} токенов)`)
980
+ parts.push(`> 💰 **Run cost**: ${costStr} (${tokStr} tokens total)`)
680
981
  }
681
982
 
682
983
  parts.push('')
683
984
 
684
985
  if (!moaResult?.isFastMode) {
685
- parts.push(`### ⚖️ Вердикт судьи и итоговое решение (Синтез: ${judge})`)
986
+ parts.push(`### ⚖️ Judge verdict and final synthesis (Synthesis: ${judge})`)
686
987
  parts.push('')
687
988
  const cleanJudgeContent = hasPromoted
688
989
  ? stripOrSummarizeCode(moaResult?.content || '')
@@ -692,14 +993,14 @@ export function formatMoAResponse({ moaResult, presetName }) {
692
993
  }
693
994
 
694
995
  if (Array.isArray(refs) && refs.length > 0) {
695
- const title = moaResult?.isFastMode ? '### 🚀 Результат генерации кандидата:' : `### 👥 Ответы моделей-советников (${refs.length}):`
996
+ const title = moaResult?.isFastMode ? '### 🚀 Candidate generation result:' : `### 👥 Advisor responses (${refs.length}):`
696
997
  parts.push(title)
697
998
  parts.push('')
698
999
  refs.forEach((ref, i) => {
699
1000
  const statusIcon = ref.ok ? '✅' : '⚠️'
700
1001
  const fileBadge = ref.files?.length ? ` (${ref.files.length} файл(ов))` : ''
701
1002
  const costBadge = ref.costUsd > 0 ? ` [~\$${ref.costUsd.toFixed(4)}]` : ''
702
- parts.push(`#### ${statusIcon} Модель ${i + 1}: ${ref.label}${fileBadge}${costBadge}`)
1003
+ parts.push(`#### ${statusIcon} Model ${i + 1}: ${ref.label}${fileBadge}${costBadge}`)
703
1004
  parts.push('')
704
1005
  parts.push(stripOrSummarizeCode(ref.text))
705
1006
  parts.push('')
@@ -711,12 +1012,12 @@ export function formatMoAResponse({ moaResult, presetName }) {
711
1012
  return parts.join('\n')
712
1013
  }
713
1014
 
714
- export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd }, options = {}) {
1015
+ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd, prices, historyFilePath, liveCanvas }, options = {}) {
715
1016
  const signal = options?.signal
716
1017
  if (signal?.aborted) return
717
1018
 
718
1019
  yield { type: 'block-start', index: 0, blockType: 'text' }
719
- 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' }
720
1021
 
721
1022
  // Async push-queue for zero-latency live delta streaming
722
1023
  const queue = []
@@ -738,6 +1039,12 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
738
1039
  callLlm,
739
1040
  cwd,
740
1041
  onProgress: pushUpdate,
1042
+ onStreamDelta: (delta) => {
1043
+ pushUpdate(delta)
1044
+ },
1045
+ prices,
1046
+ historyFilePath,
1047
+ liveCanvas,
741
1048
  })
742
1049
  .catch((err) => ({ error: err }))
743
1050
  .finally(() => {
@@ -774,7 +1081,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
774
1081
 
775
1082
  const elapsedSec = Math.floor((Date.now() - startTime) / 1000)
776
1083
  if (!done && Date.now() - lastYieldTime >= 3000) {
777
- yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}с] Идёт обработка...*\n` }
1084
+ yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}s] Still processing...*\n` }
778
1085
  lastYieldTime = Date.now()
779
1086
  }
780
1087
  }
@@ -786,7 +1093,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
786
1093
  }
787
1094
 
788
1095
  if (result?.error) {
789
- 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))
790
1097
  yield { type: 'text-delta', index: 0, text: errText }
791
1098
  yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
792
1099
  yield { type: 'finish', reason: { kind: 'stop' } }
@@ -800,7 +1107,13 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
800
1107
 
801
1108
  yield { type: 'text-delta', index: 0, text: '\n---\n\n' + formatted }
802
1109
  yield { type: 'block-end', index: 0, block: { type: 'text', text: formatted } }
803
- 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
+ }
804
1117
  yield { type: 'finish', reason: { kind: 'stop' } }
805
1118
  } finally {
806
1119
  if (signal?.aborted) {
@@ -808,3 +1121,4 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
808
1121
  }
809
1122
  }
810
1123
  }
1124
+