@goodandready/dsh-moa 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/moa-runner.js CHANGED
@@ -1,17 +1,16 @@
1
1
  /**
2
- * Mixture of Agents (MoA) Orchestrator Engine (#1 - #16)
2
+ * DeepSeek Harness Mixture of Agents (MoA) — Runner Engine
3
3
  *
4
- * Implements:
5
- * 1. Parallel candidate proposers with isolated workspaces (.moa/candidate-X/)
6
- * 2. Real-time multi-file block extractor and project context collector
7
- * 3. Iterative modification (Refinement) vs greenfield project awareness
8
- * 4. Aggregator / Judge synthesis prompt and candidate winner evaluation
9
- * 5. Automatic promotion of winning candidate files to workspace root
10
- * 6. Native Live Canvas visual preview generation for interactive apps
11
- * 7. Real-time cost tracking, token metrics and history logging
12
- * 8. Zero-latency async push-queue streaming for llm/stream turns
4
+ * Implements the full ensemble pipeline:
5
+ * - Parallel fan-out to reference models with transient retry & quorum straggler mitigation
6
+ * - Prompt caching aligned message structures
7
+ * - Curator synthesis & antipatterns evaluation
8
+ * - Aggregator fallback chain for resilience & malformed output recovery
9
+ * - Live token streaming for aggregator
10
+ * - File promotion & Live Canvas sandbox preview
13
11
  */
14
12
 
13
+ import path from 'node:path'
15
14
  import {
16
15
  extractFileBlocks,
17
16
  collectProjectContext,
@@ -20,306 +19,275 @@ import {
20
19
  promoteCandidateWorkspace,
21
20
  cleanMoaWorkspaces,
22
21
  } from './file-workspace.js'
23
-
24
22
  import { estimateTokenCost } from './pricing.js'
25
- import { recordMoaRun } from './history.js'
23
+ import { recordMoaRun, recordMoaRunAsync } from './history.js'
24
+ import {
25
+ slotLabel,
26
+ cleanAdvisoryMessages,
27
+ isBroadPromptRequiringQuestions,
28
+ buildQuestionSynthesisPrompt,
29
+ buildCuratorSynthesisPrompt,
30
+ buildSynthesisPrompt,
31
+ SYSTEM_ROLE_PROPOSER,
32
+ ANTIPATTERNS_RUBRIC,
33
+ } from './moa-prompts.js'
34
+ import {
35
+ parseWinnerIndex,
36
+ parseRecommendedAssembler,
37
+ parseMoACommand,
38
+ stripOrSummarizeCode,
39
+ formatMoAResponse,
40
+ } from './moa-parser.js'
41
+
42
+ // Re-export prompt and parser functions for external consumers / backwards compatibility
43
+ export {
44
+ slotLabel,
45
+ cleanAdvisoryMessages,
46
+ isBroadPromptRequiringQuestions,
47
+ buildQuestionSynthesisPrompt,
48
+ buildCuratorSynthesisPrompt,
49
+ buildSynthesisPrompt,
50
+ ANTIPATTERNS_RUBRIC,
51
+ } from './moa-prompts.js'
52
+
53
+ export {
54
+ parseWinnerIndex,
55
+ parseRecommendedAssembler,
56
+ parseMoACommand,
57
+ stripOrSummarizeCode,
58
+ formatMoAResponse,
59
+ } from './moa-parser.js'
26
60
 
27
61
  export { estimateTokenCost } from './pricing.js'
28
62
 
29
63
  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
- \`\`\``
41
-
42
- export function slotLabel(slot) {
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'
47
- }
64
+ export const REFERENCE_SYSTEM_PROMPT = SYSTEM_ROLE_PROPOSER
48
65
 
49
66
  /**
50
- * Extracts and sanitizes conversation history for candidate models.
51
- * Robust against complex DSH message content types (strings, part arrays, objects, tool calls).
67
+ * Invokes LLM call with transient retry for recoverable network/rate-limit errors.
52
68
  */
53
- export function cleanAdvisoryMessages(messages = [], maxCharBudget = 24000) {
54
- if (!Array.isArray(messages)) return []
55
-
56
- const trimmed = []
57
- for (const msg of messages) {
58
- if (!msg || typeof msg !== 'object') continue
59
- const role = msg.role
60
- if (role !== 'user' && role !== 'assistant') continue
61
-
62
- let text = ''
63
- if (typeof msg.content === 'string') {
64
- text = msg.content
65
- } else if (Array.isArray(msg.content)) {
66
- text = msg.content
67
- .filter((part) => part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string')
68
- .map((part) => part.text)
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
69
+ export async function callWithTransientRetry(callLlmFn, callArgs, maxRetries = 0, retryDelayMs = 1200) {
70
+ let attempt = 0
71
+ while (true) {
72
+ try {
73
+ return await callLlmFn(callArgs)
74
+ } catch (err) {
75
+ attempt++
76
+ const msg = err?.message || String(err)
77
+ const isTransient = /429|rate limit|502|503|504|econnreset|etimedout|socket hang up/i.test(msg)
78
+ if (attempt <= maxRetries && isTransient) {
79
+ await new Promise((r) => setTimeout(r, retryDelayMs))
80
+ continue
75
81
  }
76
- } else if (typeof msg.text === 'string') {
77
- text = msg.text
78
- }
79
-
80
- text = text.trim()
81
- if (!text) continue
82
-
83
- if (text.length > maxCharBudget) {
84
- const head = text.slice(0, Math.floor(maxCharBudget * 0.7))
85
- const tail = text.slice(-Math.floor(maxCharBudget * 0.3))
86
- text = `${head}\n... [trimmed ${text.length - maxCharBudget} characters] ...\n${tail}`
82
+ throw err
87
83
  }
88
-
89
- trimmed.push({ role, content: text })
90
84
  }
91
- return trimmed
92
85
  }
93
86
 
94
- export function isBroadPromptRequiringQuestions(userPrompt = '', messages = []) {
95
- if (!userPrompt || typeof userPrompt !== 'string') return false
96
- const p = userPrompt.trim()
97
- const wordCount = p.split(/\s+/).length
98
-
99
- if (/^(да|нет|1|2|3|4|ок|погнали|давай|yes|no)\b/i.test(p) && wordCount <= 5) {
100
- return false
87
+ /**
88
+ * Dispatches queries to all reference models in parallel with transient retry and quorum straggler mitigation.
89
+ */
90
+ export async function runReferencesParallel(references, messages, options = {}, callLlm, onProgress) {
91
+ if (!Array.isArray(references) || references.length === 0) {
92
+ return []
101
93
  }
102
94
 
103
- const hasRecentQuestion = messages.some((m) => {
104
- const text = typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content || '')
105
- return text.includes('Уточнение требований') || text.includes('опросник') || text.includes('Вариант 1')
106
- })
107
- if (hasRecentQuestion) {
108
- return false
109
- }
95
+ const advisoryMessages = cleanAdvisoryMessages(messages)
96
+ const systemPrompt = options.systemPrompt || REFERENCE_SYSTEM_PROMPT
97
+ // Deterministic prefix for optimal prompt caching hit rate
98
+ const fullMessages = [{ role: 'system', content: systemPrompt }, ...advisoryMessages]
99
+ const timeoutMs = options.timeoutMs ?? ((options.timeoutSec ?? 60) * 1000)
100
+ const maxRetries = options.maxRetries ?? 0
101
+ const quorumEnabled = Boolean(options.quorumEnabled)
102
+ const gracePeriodMs = (options.gracePeriodSec ?? 10) * 1000
110
103
 
111
- const creationTriggers = [
112
- 'сделай', 'создай', 'напиши', 'разработай', 'придумай', 'реализуй',
113
- 'make', 'build', 'create', 'generate', 'develop',
114
- ]
115
- const startsWithCreation = creationTriggers.some((t) => p.toLowerCase().startsWith(t))
104
+ const total = references.length
105
+ let finishedCount = 0
106
+ const results = new Array(total)
107
+ const abortControllers = references.map(() => new AbortController())
116
108
 
117
- if (startsWithCreation && wordCount <= 18) {
118
- return true
109
+ let onTaskFinished = null
110
+ const notifyFinished = () => {
111
+ if (typeof onTaskFinished === 'function') onTaskFinished()
119
112
  }
120
113
 
121
- const vagueNouns = ['приложение', 'игру', 'сервис', 'сайт', 'лендинг', 'калькулятор', 'виджет', 'дашборд', 'app', 'game', 'tool', 'website']
122
- if (vagueNouns.some((n) => p.toLowerCase().includes(n))) {
123
- if (wordCount <= 12) return true
114
+ if (typeof onProgress === 'function') {
115
+ onProgress(`⚡ *Launching ${total} candidate models in parallel...*\n`)
124
116
  }
125
117
 
126
- return false
127
- }
128
-
129
- export function buildQuestionSynthesisPrompt(userPrompt, referenceOutputs = []) {
130
- const joined = referenceOutputs
131
- .map((r, i) => `Советник ${i + 1} (${r.label}):\n${r.text}`)
132
- .join('\n\n')
133
-
134
- return `Ты — ведущий архитектор и судья в архитектуре Mixture of Agents (MoA).
135
- Пользователь дал задачу:
136
- "${userPrompt}"
137
-
138
- Советники предложили следующие развилки и уточнения:
139
- ${joined}
140
-
141
- Твоя задача — синтезировать единый, компактный, дружелюбный и структурированный опросник (2-4 вопроса) для пользователя на русском языке.
142
- Каждый вопрос должен предлагать 2-3 конкретных рекомендуемых варианта ответа (например: 1. Формат: HTML/JS в одном файле или React? 2. Стиль: Минимализм, iOS или Необрутализм?).
143
- В конце добавь примечание, что пользователь может ответить кратко (например: "1, 2, темная тема") или довериться выбору по умолчанию.`
144
- }
118
+ references.forEach((slot, i) => {
119
+ const label = slotLabel(slot)
145
120
 
146
- export function buildSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCriteria = '') {
147
- const joined = referenceOutputs
148
- .map((r, i) => {
149
- const fileSummary = (r.files && r.files.length > 0)
150
- ? ` [Созданные файлы: ${r.files.map((f) => f.relativePath).join(', ')}]`
151
- : ''
152
- // If 3+ candidates, summarize text to prevent judge context overflow (#13)
153
- let textContent = r.text
154
- if (referenceOutputs.length >= 3 && textContent.length > 3000) {
155
- textContent = stripOrSummarizeCode(textContent)
121
+ const runOne = async () => {
122
+ try {
123
+ const callPromise = callWithTransientRetry(
124
+ callLlm,
125
+ {
126
+ provider: slot.provider,
127
+ model: slot.model,
128
+ messages: fullMessages,
129
+ temperature: options.temperature ?? 0.6,
130
+ maxTokens: options.maxTokens ?? 4096,
131
+ timeoutMs,
132
+ signal: abortControllers[i].signal,
133
+ },
134
+ maxRetries
135
+ )
136
+
137
+ const timeoutPromise = new Promise((_, reject) => {
138
+ const timer = setTimeout(() => reject(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s`)), timeoutMs)
139
+ timer.unref?.()
140
+ abortControllers[i].signal.addEventListener('abort', () => clearTimeout(timer), { once: true })
141
+ })
142
+
143
+ const res = await Promise.race([callPromise, timeoutPromise])
144
+ const text = typeof res === 'string' ? res : (res?.content || res?.text || '')
145
+
146
+ const fallbackUsage = (typeof res === 'object' && res?.usage) ? res.usage : {
147
+ inputTokens: Math.max(1, Math.round(fullMessages.map((m) => m.content).join('').length / 4)),
148
+ outputTokens: Math.max(1, Math.round(text.length / 4)),
149
+ }
150
+ const costInfo = estimateTokenCost(slot, fallbackUsage, options.prices)
151
+
152
+ finishedCount++
153
+ if (typeof onProgress === 'function') {
154
+ const costStr = costInfo.costUsd > 0 ? ` (~\$${costInfo.costUsd.toFixed(4)})` : ''
155
+ onProgress(`✅ *Candidate ${i + 1}/${total} (${label}) finished${costStr}*\n`)
156
+ }
157
+
158
+ results[i] = {
159
+ index: i + 1,
160
+ slot,
161
+ label,
162
+ text,
163
+ usage: costInfo,
164
+ costUsd: costInfo.costUsd,
165
+ ok: true,
166
+ }
167
+ } catch (err) {
168
+ finishedCount++
169
+ const errMsg = err?.message || String(err)
170
+ console.warn(`[dsh-moa] Reference ${label} failed:`, errMsg)
171
+ if (typeof onProgress === 'function') {
172
+ onProgress(`⚠️ *Candidate ${i + 1}/${total} (${label}) error: ${errMsg}*\n`)
173
+ }
174
+ results[i] = {
175
+ index: i + 1,
176
+ slot,
177
+ label,
178
+ text: `[Model ${label} error: ${errMsg}]`,
179
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
180
+ costUsd: 0,
181
+ ok: false,
182
+ error: errMsg,
183
+ }
184
+ } finally {
185
+ notifyFinished()
156
186
  }
157
- return `Reference ${i + 1} — ${r.label}:${fileSummary}\n${textContent}`
158
- })
159
- .join('\n\n')
160
-
161
- const criteriaBlock = judgeCriteria && judgeCriteria.trim()
162
- ? `\n### 🎯 Дополнительные критерии оценки от пользователя:\n${judgeCriteria.trim()}\n`
163
- : ''
164
-
165
- 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.
166
-
167
- Original user prompt:
168
- ${userPrompt}
169
- ${criteriaBlock}
170
- Reference responses from candidate models:
171
- ${joined}
172
-
173
- Instructions:
174
- Your response MUST be structured into three clear parts (respond in the same language as the user's prompt, e.g. Russian):
175
-
176
- ### 1. ⚖️ Вердикт судьи и анализ вариантов
177
- - **Чей вариант выбран**: Чётко укажи имя модели и номер кандидата (например: "Победитель: Кандидат 1 (opencode-go:deepseek-v4-flash)" или "Выбран вариант модели Reference 1 (label)").
178
- - Обязательно добавь машинный маркер выбора победителя:
179
- WINNER_CANDIDATE_INDEX: <число от 1 до N>
180
- - **Почему сделан этот выбор**: Подробно сравни код, архитектуру, сильные стороны, недочёты и надёжность всех кандидатов.
181
-
182
- ### 2. 📁 Созданные файлы проекта
183
- - Перечисли файлы победителя, которые были перенесены в корень проекта, и их назначение.
184
-
185
- ### 3. 🚀 Инструкция по запуску и использованию
186
- - Опиши, как открыть и запустить созданный проект.`
187
- }
188
-
189
- export function parseWinnerIndex(judgeText, defaultIndex = 1) {
190
- if (!judgeText || typeof judgeText !== 'string') return defaultIndex
191
- const match = /WINNER_CANDIDATE_INDEX:\s*(\d+)/i.exec(judgeText)
192
- if (match) {
193
- const idx = parseInt(match[1], 10)
194
- if (!isNaN(idx) && idx >= 1) return idx
195
- }
196
- const candMatch = /(?:Кандидат|Reference)\s*(\d+)\b/i.exec(judgeText)
197
- if (candMatch) {
198
- const idx = parseInt(candMatch[1], 10)
199
- if (!isNaN(idx) && idx >= 1) return idx
200
- }
201
- return defaultIndex
202
- }
203
-
204
- export function parseMoACommand(text, presets = []) {
205
- if (typeof text !== 'string' || !text.startsWith('/moa')) {
206
- return null
207
- }
208
-
209
- const remainder = text.slice(4).trim()
210
- if (!remainder) {
211
- return {
212
- presetName: 'default',
213
- prompt: '',
214
187
  }
215
- }
216
188
 
217
- const matchPreset = /^--preset(?:=|\s+)([a-zA-Z0-9_-]+)\s*(.*)/s.exec(remainder)
218
- if (matchPreset) {
219
- return {
220
- presetName: matchPreset[1],
221
- prompt: matchPreset[2] || '',
222
- }
223
- }
189
+ runOne()
190
+ })
224
191
 
225
- const parts = remainder.split(/\s+/)
226
- const firstWord = parts[0]
227
- if (Array.isArray(presets)) {
228
- const matched = presets.find((p) => p.name === firstWord)
229
- if (matched) {
230
- return {
231
- presetName: matched.name,
232
- prompt: parts.slice(1).join(' ').trim(),
192
+ // Straggler mitigation via Quorum + Grace Period
193
+ if (quorumEnabled && total >= 3) {
194
+ const quorumTarget = Math.max(2, Math.min(total - 1, Math.ceil(total * 0.6)))
195
+ let graceTimer = null
196
+
197
+ await new Promise((resolve) => {
198
+ const checkQuorum = () => {
199
+ if (finishedCount >= total) {
200
+ if (graceTimer) clearTimeout(graceTimer)
201
+ resolve()
202
+ return
203
+ }
204
+
205
+ if (finishedCount >= quorumTarget && !graceTimer) {
206
+ if (typeof onProgress === 'function') {
207
+ onProgress(`⏳ *Quorum reached (${finishedCount}/${total}). Starting ${Math.round(gracePeriodMs / 1000)}s grace period for stragglers...*\n`)
208
+ }
209
+ graceTimer = setTimeout(() => {
210
+ if (typeof onProgress === 'function' && finishedCount < total) {
211
+ onProgress(`⏩ *Grace period expired. Proceeding with ${finishedCount}/${total} ready candidates.*\n`)
212
+ }
213
+ for (let k = 0; k < total; k++) {
214
+ if (!results[k]) {
215
+ try { abortControllers[k].abort(new Error('Quorum grace period timed out')) } catch {}
216
+ }
217
+ }
218
+ resolve()
219
+ }, gracePeriodMs)
220
+ graceTimer.unref?.()
221
+ }
233
222
  }
234
- }
235
- }
236
223
 
237
- return {
238
- presetName: 'default',
239
- prompt: remainder,
240
- }
241
- }
224
+ onTaskFinished = checkQuorum
225
+ checkQuorum()
226
+ })
242
227
 
243
- /**
244
- * Dispatches queries to all reference models in parallel.
245
- */
246
- export async function runReferencesParallel(references, messages, options = {}, callLlm, onProgress) {
247
- if (!Array.isArray(references) || references.length === 0) {
248
- return []
228
+ for (let i = 0; i < total; i++) {
229
+ if (!results[i]) {
230
+ const slot = references[i]
231
+ const label = slotLabel(slot)
232
+ results[i] = {
233
+ index: i + 1,
234
+ slot,
235
+ label,
236
+ text: `[Model ${label} timed out (quorum grace period exceeded)]`,
237
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
238
+ costUsd: 0,
239
+ ok: false,
240
+ error: 'Quorum grace period timed out',
241
+ }
242
+ }
243
+ }
244
+ return results
249
245
  }
250
246
 
251
- const advisoryMessages = cleanAdvisoryMessages(messages)
252
- const systemPrompt = options.systemPrompt || REFERENCE_SYSTEM_PROMPT
253
- const fullMessages = [{ role: 'system', content: systemPrompt }, ...advisoryMessages]
254
- const timeoutMs = options.timeoutMs ?? 75000
255
-
256
- const tasks = references.map(async (slot, i) => {
257
- const label = slotLabel(slot)
258
- if (typeof onProgress === 'function') {
259
- onProgress(`⚡ *Кандидат ${i + 1} (${label}) начал генерацию...*\n`)
247
+ // Standard mode: wait for all tasks to complete
248
+ await new Promise((resolve) => {
249
+ const checkAll = () => {
250
+ if (finishedCount >= total) resolve()
260
251
  }
252
+ onTaskFinished = checkAll
253
+ checkAll()
254
+ })
261
255
 
262
- try {
263
- const callPromise = callLlm({
264
- provider: slot.provider,
265
- model: slot.model,
266
- messages: fullMessages,
267
- temperature: options.temperature ?? 0.6,
268
- maxTokens: options.maxTokens ?? 4096,
269
- })
270
-
271
- const timeoutPromise = new Promise((_, reject) => {
272
- const timer = setTimeout(() => reject(new Error(`Timeout after ${timeoutMs / 1000}s`)), timeoutMs)
273
- timer.unref?.()
274
- })
275
-
276
- const res = await Promise.race([callPromise, timeoutPromise])
277
- const text = typeof res === 'string' ? res : (res?.content || res?.text || '')
278
-
279
- const fallbackUsage = (typeof res === 'object' && res?.usage) ? res.usage : {
280
- inputTokens: Math.max(1, Math.round(fullMessages.map((m) => m.content).join('').length / 4)),
281
- outputTokens: Math.max(1, Math.round(text.length / 4)),
282
- }
283
- const costInfo = estimateTokenCost(slot, fallbackUsage)
256
+ return results
257
+ }
284
258
 
285
- if (typeof onProgress === 'function') {
286
- const costStr = costInfo.costUsd > 0 ? ` (~\$${costInfo.costUsd.toFixed(4)})` : ''
287
- onProgress(`✅ *Кандидат ${i + 1} (${label}) завершил ответ${costStr}*\n`)
288
- }
259
+ function candidatesForHistory(referenceOutputs) {
260
+ return (referenceOutputs || []).map((r) => ({
261
+ provider: r.slot?.provider || '',
262
+ model: r.slot?.model || '',
263
+ files: (r.files || []).map((f) => f.relativePath),
264
+ usage: r.usage || { inputTokens: 0, outputTokens: 0 },
265
+ costUsd: r.costUsd || 0,
266
+ }))
267
+ }
289
268
 
290
- return {
291
- index: i + 1,
292
- slot,
293
- label,
294
- text,
295
- usage: costInfo,
296
- costUsd: costInfo.costUsd,
297
- ok: true,
298
- }
299
- } catch (err) {
300
- const errMsg = err?.message || String(err)
301
- console.warn(`[dsh-moa] Reference ${label} failed:`, errMsg)
302
- if (typeof onProgress === 'function') {
303
- onProgress(`⚠️ *Кандидат ${i + 1} (${label}) ошибка: ${errMsg}*\n`)
304
- }
305
- return {
306
- index: i + 1,
307
- slot,
308
- label,
309
- text: `[Ошибка модели ${label}: ${errMsg}]`,
310
- usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
311
- costUsd: 0,
312
- ok: false,
313
- error: errMsg,
314
- }
269
+ async function createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs) {
270
+ if (!liveCanvas || typeof liveCanvas.createPreviewFromContent !== 'function') return null
271
+ const htmlRel = (promotedFiles || []).find((f) => f.endsWith('.html') || f.endsWith('.htm'))
272
+ if (!htmlRel) return null
273
+ let content = null
274
+ for (const r of referenceOutputs || []) {
275
+ const block = (r?.files || []).find((f) => f.relativePath === htmlRel)
276
+ if (block?.content) {
277
+ content = block.content
278
+ break
315
279
  }
316
- })
317
-
318
- return Promise.all(tasks)
280
+ }
281
+ if (!content) return null
282
+ try {
283
+ return await liveCanvas.createPreviewFromContent({ content, title: htmlRel, filePath: path.join(cwd, htmlRel) })
284
+ } catch {
285
+ return null
286
+ }
319
287
  }
320
288
 
321
289
  /**
322
- * Main MoA pipeline runner.
290
+ * Executes the full Mixture of Agents pipeline.
323
291
  */
324
292
  export async function runMoAPipeline({
325
293
  userPrompt,
@@ -327,68 +295,85 @@ export async function runMoAPipeline({
327
295
  preset,
328
296
  callLlm,
329
297
  cwd = process.cwd(),
330
- onProgress,
298
+ prices = {},
299
+ onProgress = null,
300
+ historyFilePath = null,
301
+ liveCanvas = null,
302
+ onStreamDelta = null,
331
303
  }) {
332
304
  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')
348
- }
349
- const projectContext = await collectProjectContext(cwd)
350
- const isRefinement = isRefinementTask(userPrompt, projectContext.files)
351
-
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
357
- let candidateSystemPrompt = REFERENCE_SYSTEM_PROMPT
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.`
362
- }
363
305
 
364
- let promptForCandidates = userPrompt
365
- if (needsQuestions) {
366
- promptForCandidates += '\n(Примечание: задача широкая. Предложи ключевые архитектурные и функциональные развилки для уточнения требований пользователя).'
306
+ // 1. Resolve configurations
307
+ const referenceModels = Array.isArray(preset?.reference_models) && preset.reference_models.length > 0
308
+ ? preset.reference_models
309
+ : [{ provider: 'opencode-go', model: 'deepseek-v4-flash' }]
310
+
311
+ const primaryJudge = preset?.aggregator?.provider && preset?.aggregator?.model
312
+ ? preset.aggregator
313
+ : { provider: 'codex', model: 'gpt-5.6-sol' }
314
+
315
+ const fallbackJudges = Array.isArray(preset?.aggregator_fallbacks) ? preset.aggregator_fallbacks : []
316
+ const judgesChain = [primaryJudge, ...fallbackJudges]
317
+
318
+ const refTemp = typeof preset?.reference_temperature === 'number' ? preset.reference_temperature : 0.6
319
+ const aggTemp = typeof preset?.aggregator_temperature === 'number' ? preset.aggregator_temperature : 0.4
320
+ const maxTokens = typeof preset?.max_tokens === 'number' ? preset.max_tokens : 4096
321
+ const judgeCriteria = preset?.judge_criteria || ''
322
+ const isFastMode = referenceModels.length === 1 && !preset?.curator_synthesis
323
+ const isCuratorSynthesis = Boolean(preset?.curator_synthesis)
324
+ const isStreamAggregator = preset?.stream_aggregator !== false
325
+ const isQuorumEnabled = Boolean(preset?.quorum_enabled)
326
+ const gracePeriodSec = typeof preset?.grace_period_sec === 'number' ? preset.grace_period_sec : 10
327
+ const candidateRetries = 1
328
+ const refTimeoutSec = typeof preset?.reference_timeout_sec === 'number' ? preset.reference_timeout_sec : 60
329
+ const aggTimeoutSec = typeof preset?.aggregator_timeout_sec === 'number' ? preset.aggregator_timeout_sec : 180
330
+
331
+ // 2. Collect project context for refinement tasks
332
+ const isRefinement = isRefinementTask(userPrompt)
333
+ let projectContext = ''
334
+ if (isRefinement) {
335
+ if (typeof onProgress === 'function') {
336
+ onProgress('🔍 *Reading project files for refinement context...*\n')
337
+ }
338
+ projectContext = await collectProjectContext(cwd, 16000)
367
339
  }
368
340
 
369
- const enrichedMessages = [...messages, { role: 'user', content: promptForCandidates }]
341
+ // 3. Build prompts & evaluate broad questionnaire needs
342
+ const candidateSystemPrompt = projectContext
343
+ ? `${REFERENCE_SYSTEM_PROMPT}\n\n### Current Project Files & Context:\n${projectContext}`
344
+ : REFERENCE_SYSTEM_PROMPT
370
345
 
371
- // 4. Parallel fan-out to candidate models
372
- if (typeof onProgress === 'function') {
373
- onProgress(`🚀 *Запуск ${referenceModels.length} моделей-кандидатов параллельно...*\n`)
374
- }
346
+ const askClarifyingQuestions = preset?.ask_clarifying_questions !== false
347
+ const needsQuestions = askClarifyingQuestions && isBroadPromptRequiringQuestions(userPrompt, messages)
348
+
349
+ const enrichedMessages = [...messages, { role: 'user', content: userPrompt }]
375
350
 
351
+ // 4. Parallel fan-out to candidate models with quorum & transient retry
376
352
  const referenceOutputs = await runReferencesParallel(
377
353
  referenceModels,
378
354
  enrichedMessages,
379
- { systemPrompt: candidateSystemPrompt, temperature: refTemp, maxTokens },
355
+ {
356
+ systemPrompt: candidateSystemPrompt,
357
+ temperature: refTemp,
358
+ maxTokens,
359
+ prices,
360
+ timeoutSec: refTimeoutSec,
361
+ quorumEnabled: isQuorumEnabled,
362
+ gracePeriodSec,
363
+ maxRetries: candidateRetries,
364
+ },
380
365
  callLlm,
381
366
  onProgress
382
367
  )
383
368
 
384
- // Fail fast if all candidates failed (#12)
369
+ // Fail fast if all candidates failed
385
370
  const successfulRefs = referenceOutputs.filter((r) => r.ok)
386
371
  if (successfulRefs.length === 0) {
387
372
  const reasons = referenceOutputs.map((r) => `${r.label}: ${r.error || 'unknown error'}`).join('; ')
388
373
  return {
389
374
  kind: 'failure',
390
- content: `⚠️ Все модели-советники (${referenceOutputs.length}) завершились с ошибкой: ${reasons}`,
391
- aggregator: aggLabel,
375
+ content: `⚠️ All advisor models (${referenceOutputs.length}) failed: ${reasons}`,
376
+ aggregator: slotLabel(primaryJudge),
392
377
  references: referenceOutputs,
393
378
  presetName: preset?.name || 'default',
394
379
  isRefinement,
@@ -406,7 +391,7 @@ export async function runMoAPipeline({
406
391
  }
407
392
  }
408
393
 
409
- // 5. Extract file blocks & write candidate workspaces (#1, #2)
394
+ // 5. Extract file blocks & write candidate workspaces
410
395
  for (let i = 0; i < referenceOutputs.length; i++) {
411
396
  const ref = referenceOutputs[i]
412
397
  if (!ref.ok) continue
@@ -417,49 +402,112 @@ export async function runMoAPipeline({
417
402
  }
418
403
  }
419
404
 
420
- // 6. Questionnaire synthesis branch (#7)
405
+ // 6. Questionnaire synthesis branch
421
406
  if (needsQuestions) {
422
407
  if (typeof onProgress === 'function') {
423
- onProgress('📋 *Синтез опросника судьей...*\n')
408
+ onProgress('📋 *Judge synthesizes the clarification questionnaire...*\n')
424
409
  }
425
410
  const questionPrompt = buildQuestionSynthesisPrompt(userPrompt, referenceOutputs)
426
411
  let questionsContent = ''
412
+ let qUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
427
413
  try {
428
- const qRes = await callLlm({
429
- provider: aggregator.provider,
430
- model: aggregator.model,
414
+ const qRes = await callWithTransientRetry(callLlm, {
415
+ provider: primaryJudge.provider,
416
+ model: primaryJudge.model,
431
417
  messages: [{ role: 'user', content: questionPrompt }],
432
418
  temperature: 0.3,
433
419
  maxTokens: 2048,
434
- })
420
+ timeoutMs: aggTimeoutSec * 1000,
421
+ }, 1)
435
422
  questionsContent = typeof qRes === 'string' ? qRes : (qRes?.content || qRes?.text || '')
436
- } catch {
437
- questionsContent = '### Уточнение требований к проекту\nПожалуйста, уточните детали реализации и желаемый стек.'
423
+ const qFallbackUsage = (typeof qRes === 'object' && qRes?.usage) ? qRes.usage : {
424
+ inputTokens: Math.max(1, Math.round(questionPrompt.length / 4)),
425
+ outputTokens: Math.max(1, Math.round(questionsContent.length / 4)),
426
+ }
427
+ qUsage = estimateTokenCost(primaryJudge, qFallbackUsage, prices)
428
+ } catch (err) {
429
+ console.warn('[dsh-moa] Questionnaire synthesis failed, proceeding with fallback questions:', err)
430
+ questionsContent = `### Уточнение требований по задаче: "${userPrompt}"\n\n` +
431
+ '1. **Формат решения**: однофайловый HTML/JS или многомодульный проект?\n' +
432
+ '2. **Стиль и визуальное оформление**: минимализм, темная тема или нейтральный интерфейс?\n' +
433
+ '3. **Функциональные приоритеты**: базовый MVP или расширенная реализация?\n\n' +
434
+ '*Ответьте кратко (например, "1, 2") или доверьтесь выбору по умолчанию.*'
438
435
  }
439
436
 
437
+ await cleanMoaWorkspaces(cwd)
438
+ const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + qUsage.totalTokens
439
+ const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + qUsage.costUsd).toFixed(5))
440
+ const durationMs = Date.now() - startTime
441
+
442
+ try {
443
+ recordMoaRun({
444
+ prompt: userPrompt,
445
+ preset: preset?.name || 'default',
446
+ isRefinement,
447
+ candidates: candidatesForHistory(referenceOutputs),
448
+ aggregator: { provider: primaryJudge.provider, model: primaryJudge.model, usage: qUsage, costUsd: qUsage.costUsd },
449
+ winnerIndex: -1,
450
+ winnerModel: 'none',
451
+ promotedFiles: [],
452
+ totalTokens,
453
+ totalCostUsd,
454
+ durationMs,
455
+ }, historyFilePath || undefined)
456
+ } catch {}
457
+
440
458
  return {
441
459
  kind: 'questions',
442
460
  content: questionsContent,
461
+ aggregator: slotLabel(primaryJudge),
443
462
  references: referenceOutputs,
444
463
  presetName: preset?.name || 'default',
445
- isRefinement: false,
464
+ isRefinement,
465
+ isFastMode,
466
+ winningIndex: 0,
467
+ winnerModel: 'none',
468
+ promotedFiles: [],
469
+ usage: {
470
+ totalTokens,
471
+ totalCostUsd,
472
+ candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
473
+ aggregator: qUsage,
474
+ },
475
+ durationMs,
446
476
  }
447
477
  }
448
478
 
449
- // 7. Fast mode bypass for single candidate
479
+ // 7. Fast Mode (1 candidate, bypass judge)
450
480
  if (isFastMode) {
451
- const single = referenceOutputs[0]
481
+ const single = successfulRefs[0]
452
482
  let promotedFiles = []
453
- if (single.files && single.files.length > 0) {
454
- promotedFiles = await promoteCandidateWorkspace(cwd, 1)
483
+ if (single.files?.length > 0) {
484
+ promotedFiles = await promoteCandidateWorkspace(cwd, single.index)
455
485
  } else {
456
486
  await cleanMoaWorkspaces(cwd)
457
487
  }
458
488
 
489
+ const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
459
490
  const totalTokens = single.usage?.totalTokens || 0
460
- const totalCostUsd = single.costUsd || 0
491
+ const totalCostUsd = Number((single.costUsd || 0).toFixed(5))
461
492
  const durationMs = Date.now() - startTime
462
493
 
494
+ try {
495
+ recordMoaRun({
496
+ prompt: userPrompt,
497
+ preset: preset?.name || 'default',
498
+ isRefinement,
499
+ isFastMode: true,
500
+ candidates: candidatesForHistory(referenceOutputs),
501
+ aggregator: null,
502
+ winnerIndex: single.index,
503
+ winnerModel: single.label,
504
+ promotedFiles,
505
+ totalTokens,
506
+ totalCostUsd,
507
+ durationMs,
508
+ }, historyFilePath || undefined)
509
+ } catch {}
510
+
463
511
  return {
464
512
  kind: 'synthesis',
465
513
  content: single.text,
@@ -468,52 +516,94 @@ export async function runMoAPipeline({
468
516
  presetName: preset?.name || 'default',
469
517
  isRefinement,
470
518
  isFastMode: true,
471
- winningIndex: 1,
519
+ winningIndex: single.index,
472
520
  winnerModel: single.label,
473
521
  promotedFiles,
522
+ ...(livePreview ? { liveCanvas: livePreview } : {}),
474
523
  usage: {
475
524
  totalTokens,
476
525
  totalCostUsd,
477
- candidates: [{ label: single.label, usage: single.usage, costUsd: single.costUsd }],
526
+ candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
478
527
  aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
479
528
  },
480
529
  durationMs,
481
530
  }
482
531
  }
483
532
 
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)
533
+ // 8. Synthesis phase via primary judge or fallback chain
534
+ const synthesisPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria, { curatorSynthesis: isCuratorSynthesis })
490
535
  let synthesizedText = ''
491
536
  let aggUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
537
+ let chosenJudge = primaryJudge
538
+ let judgeSuccess = false
539
+ let lastJudgeError = null
492
540
 
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)),
541
+ for (let jIdx = 0; jIdx < judgesChain.length; jIdx++) {
542
+ const currentJudge = judgesChain[jIdx]
543
+ const currentLabel = slotLabel(currentJudge)
544
+
545
+ if (typeof onProgress === 'function') {
546
+ const modeTitle = isCuratorSynthesis ? 'Curator' : 'Judge'
547
+ const fallbackBadge = jIdx > 0 ? ` (Fallback #${jIdx})` : ''
548
+ onProgress(`⚖️ *${modeTitle} (${currentLabel}${fallbackBadge}) synthesizing solution...*\n`)
505
549
  }
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')
550
+
551
+ try {
552
+ const aggRes = await callWithTransientRetry(callLlm, {
553
+ provider: currentJudge.provider,
554
+ model: currentJudge.model,
555
+ messages: [{ role: 'user', content: synthesisPrompt }],
556
+ temperature: aggTemp,
557
+ maxTokens,
558
+ timeoutMs: aggTimeoutSec * 1000,
559
+ onStreamDelta: (delta) => {
560
+ if (isStreamAggregator && typeof onStreamDelta === 'function') {
561
+ onStreamDelta(delta)
562
+ }
563
+ },
564
+ }, 0)
565
+
566
+ synthesizedText = typeof aggRes === 'string' ? aggRes : (aggRes?.content || aggRes?.text || '')
567
+
568
+ const aggFallbackUsage = (typeof aggRes === 'object' && aggRes?.usage) ? aggRes.usage : {
569
+ inputTokens: Math.max(1, Math.round(synthesisPrompt.length / 4)),
570
+ outputTokens: Math.max(1, Math.round(synthesizedText.length / 4)),
571
+ }
572
+ aggUsage = estimateTokenCost(currentJudge, aggFallbackUsage, prices)
573
+ chosenJudge = currentJudge
574
+ judgeSuccess = true
575
+ break
576
+ } catch (err) {
577
+ lastJudgeError = err
578
+ console.warn(`[dsh-moa] Judge ${currentLabel} failed:`, err)
579
+ if (typeof onProgress === 'function') {
580
+ const nextJudge = judgesChain[jIdx + 1]
581
+ const nextHint = nextJudge ? ` Trying fallback ${slotLabel(nextJudge)}...` : ''
582
+ onProgress(`⚠️ *Judge ${currentLabel} failed: ${err?.message || err}.${nextHint}*\n`)
583
+ }
584
+ }
585
+ }
586
+
587
+ if (!judgeSuccess) {
588
+ const firstErr = lastJudgeError ? (lastJudgeError.message || String(lastJudgeError)) : 'unknown error'
589
+ synthesizedText = `⚠️ *[Aggregator error: ${firstErr}. Fallback candidate outputs:]*\n\n` +
590
+ successfulRefs.map((r, i) => `### Candidate ${i + 1} (${r.label})\n${r.text}`).join('\n\n')
511
591
  }
512
592
 
513
- // 9. Evaluate winner & promote files (#1, #2)
514
- const winningIndex = parseWinnerIndex(synthesizedText, 1)
593
+ // 9. Evaluate winner & promote files
594
+ const winningIndex = parseWinnerIndex(synthesizedText, 1, referenceOutputs.length)
595
+ const recommendedAssembler = isCuratorSynthesis
596
+ ? parseRecommendedAssembler(synthesizedText, winningIndex, referenceOutputs.length)
597
+ : null
598
+
599
+ // Check if aggregator synthesized unified file blocks directly
600
+ const synthesizedFiles = extractFileBlocks(synthesizedText)
515
601
  let promotedFiles = []
516
- if (referenceOutputs[winningIndex - 1]?.files?.length > 0) {
602
+
603
+ if (synthesizedFiles.length > 0) {
604
+ await writeCandidateWorkspace(cwd, 'curator-synthesis', synthesizedFiles)
605
+ promotedFiles = await promoteCandidateWorkspace(cwd, 'curator-synthesis')
606
+ } else if (referenceOutputs[winningIndex - 1]?.files?.length > 0) {
517
607
  promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex)
518
608
  } else if (successfulRefs[0]?.files?.length > 0) {
519
609
  const fallbackIdx = successfulRefs[0].index
@@ -522,16 +612,7 @@ export async function runMoAPipeline({
522
612
  await cleanMoaWorkspaces(cwd)
523
613
  }
524
614
 
525
- // 10. Live Canvas preview (#16)
526
- let liveCanvas = null
527
- const htmlFile = promotedFiles.find((f) => f.endsWith('.html') || f.endsWith('index.html'))
528
- if (htmlFile) {
529
- liveCanvas = {
530
- previewUrl: `http://localhost:3000/preview/${encodeURIComponent(htmlFile)}`,
531
- file: htmlFile,
532
- title: 'Interactive Web Preview',
533
- }
534
- }
615
+ const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
535
616
 
536
617
  const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + aggUsage.totalTokens
537
618
  const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + aggUsage.costUsd).toFixed(5))
@@ -539,22 +620,23 @@ export async function runMoAPipeline({
539
620
 
540
621
  const winningRef = referenceOutputs[winningIndex - 1]
541
622
  const winnerModel = winningRef?.label || slotLabel(referenceModels[0])
623
+ const finalAggLabel = slotLabel(chosenJudge)
542
624
 
543
- // Record run to history (#9, #10, #11)
625
+ // Record run to history
544
626
  try {
545
627
  recordMoaRun({
546
628
  prompt: userPrompt,
547
629
  preset: preset?.name || 'default',
548
630
  isRefinement,
549
- candidates: referenceOutputs,
550
- aggregator: isFastMode ? null : { provider: aggregator.provider, model: aggregator.model, usage: aggUsage, costUsd: aggUsage.costUsd },
631
+ candidates: candidatesForHistory(referenceOutputs),
632
+ aggregator: { provider: chosenJudge.provider, model: chosenJudge.model, usage: aggUsage, costUsd: aggUsage.costUsd },
551
633
  winnerIndex: winningIndex,
552
634
  winnerModel,
553
635
  promotedFiles,
554
636
  totalTokens,
555
637
  totalCostUsd,
556
638
  durationMs,
557
- })
639
+ }, historyFilePath || undefined)
558
640
  } catch (histErr) {
559
641
  console.warn('[dsh-moa] Failed to record run in history:', histErr)
560
642
  }
@@ -562,19 +644,21 @@ export async function runMoAPipeline({
562
644
  return {
563
645
  kind: 'synthesis',
564
646
  content: synthesizedText,
565
- aggregator: isFastMode ? 'Fast Mode (Direct)' : aggLabel,
647
+ aggregator: isFastMode ? 'Fast Mode (Direct)' : finalAggLabel,
566
648
  references: referenceOutputs,
567
649
  presetName: preset?.name || 'default',
568
650
  isRefinement,
569
651
  isFastMode,
652
+ isCuratorSynthesis,
653
+ recommendedAssembler,
570
654
  winningIndex,
571
655
  winnerModel,
572
656
  promotedFiles,
573
- liveCanvas,
657
+ ...(livePreview ? { liveCanvas: livePreview } : {}),
574
658
  usage: {
575
659
  totalTokens,
576
660
  totalCostUsd,
577
- candidates: referenceOutputs.map(r => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
661
+ candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
578
662
  aggregator: aggUsage,
579
663
  },
580
664
  durationMs,
@@ -582,103 +666,14 @@ export async function runMoAPipeline({
582
666
  }
583
667
 
584
668
  /**
585
- * Replaces large code blocks with concise file/code summaries to avoid token waste and huge chat dumps.
669
+ * Streams a full MoA turn into chat with live aggregator tokens and progress feedback.
586
670
  */
587
- export function stripOrSummarizeCode(text) {
588
- if (!text || typeof text !== 'string') return ''
589
- return text.replace(/```([a-zA-Z0-9_\-\.\/]*)\s*([\w\.\/\-]+\.[a-zA-Z0-9]+)?\n([\s\S]*?)```/g, (match, lang, fileTag, code) => {
590
- const lines = code.trim().split('\n')
591
- if (lines.length <= 3 && !/html|jsx|tsx|vue|svelte|css|js|ts/i.test(lang)) {
592
- return match
593
- }
594
- const fileHint = fileTag || (code.match(/^\s*(?:\/\/|#|<!--|\/\*)\s*(?:file|filepath|path):\s*([^\s*]+)/im)?.[1])
595
- const label = fileHint ? `файл \`${fileHint}\`` : (lang ? `код \`${lang}\`` : 'код')
596
- return `\n> 📄 *[${label} — ${lines.length} строк сохранены на диск]*\n`
597
- })
598
- }
599
-
600
- export function formatMoAResponse({ moaResult, presetName }) {
601
- const parts = []
602
- const pName = presetName || moaResult?.presetName || 'default'
603
- const judge = moaResult?.aggregator || 'unknown'
604
- const refs = moaResult?.references || []
605
-
606
- if (moaResult?.kind === 'questions') {
607
- parts.push('## 🧠 Mixture of Agents — Уточнение требований')
608
- parts.push(`*Судья (${judge}) и советники проанализировали задачу:*\n`)
609
- parts.push(moaResult.content)
610
- return parts.join('\n')
611
- }
612
-
613
- if (moaResult?.kind === 'failure') {
614
- parts.push('## ⚠️ Mixture of Agents — Сбой выполнения')
615
- parts.push(moaResult.content)
616
- return parts.join('\n')
617
- }
618
-
619
- const modeBadge = moaResult?.isFastMode ? '⚡ Fast Mode' : `Судья: ${judge}`
620
- parts.push(`## 🧠 Mixture of Agents (Пресет: ${pName} | ${modeBadge})`)
621
- parts.push('')
622
-
623
- if (moaResult?.isRefinement) {
624
- parts.push('> 🔄 **Режим**: Итеративная доработка проекта (Refinement)')
625
- }
626
-
627
- const hasPromoted = moaResult?.promotedFiles && moaResult.promotedFiles.length > 0
628
- if (hasPromoted) {
629
- parts.push(`> 📦 **Созданы файлы в проекте**: \`${moaResult.promotedFiles.join('`, `')}\``)
630
- }
631
-
632
- if (moaResult?.liveCanvas?.previewUrl) {
633
- parts.push(`> 🎨 **Live Canvas**: [🚀 Открыть ${moaResult.liveCanvas.title || 'превью'} в Live Canvas](${moaResult.liveCanvas.previewUrl}) | [↗ Открыть в новой вкладке](${moaResult.liveCanvas.previewUrl})`)
634
- }
635
-
636
- // Cost tracking card (#10)
637
- if (moaResult?.usage) {
638
- const u = moaResult.usage
639
- const costStr = u.totalCostUsd > 0 ? `~\$${u.totalCostUsd.toFixed(4)}` : 'Бесплатно'
640
- const tokStr = u.totalTokens >= 1000 ? `${(u.totalTokens / 1000).toFixed(1)}k` : `${u.totalTokens}`
641
- parts.push(`> 💰 **Стоимость запуска**: ${costStr} (всего ${tokStr} токенов)`)
642
- }
643
-
644
- parts.push('')
645
-
646
- if (!moaResult?.isFastMode) {
647
- parts.push(`### ⚖️ Вердикт судьи и итоговое решение (Синтез: ${judge})`)
648
- parts.push('')
649
- const cleanJudgeContent = hasPromoted
650
- ? stripOrSummarizeCode(moaResult?.content || '')
651
- : (moaResult?.content || '(нет ответа)')
652
- parts.push(cleanJudgeContent)
653
- parts.push('')
654
- }
655
-
656
- if (Array.isArray(refs) && refs.length > 0) {
657
- const title = moaResult?.isFastMode ? '### 🚀 Результат генерации кандидата:' : `### 👥 Ответы моделей-советников (${refs.length}):`
658
- parts.push(title)
659
- parts.push('')
660
- refs.forEach((ref, i) => {
661
- const statusIcon = ref.ok ? '✅' : '⚠️'
662
- const fileBadge = ref.files?.length ? ` (${ref.files.length} файл(ов))` : ''
663
- const costBadge = ref.costUsd > 0 ? ` [~\$${ref.costUsd.toFixed(4)}]` : ''
664
- parts.push(`#### ${statusIcon} Модель ${i + 1}: ${ref.label}${fileBadge}${costBadge}`)
665
- parts.push('')
666
- parts.push(stripOrSummarizeCode(ref.text))
667
- parts.push('')
668
- parts.push('---')
669
- parts.push('')
670
- })
671
- }
672
-
673
- return parts.join('\n')
674
- }
675
-
676
- export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd }, options = {}) {
671
+ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd, prices, historyFilePath, liveCanvas }, options = {}) {
677
672
  const signal = options?.signal
678
673
  if (signal?.aborted) return
679
674
 
680
675
  yield { type: 'block-start', index: 0, blockType: 'text' }
681
- yield { type: 'text-delta', index: 0, text: '🧠 *Mixture of Agents запущен...*\n\n' }
676
+ yield { type: 'text-delta', index: 0, text: '🧠 *Mixture of Agents started...*\n\n' }
682
677
 
683
678
  // Async push-queue for zero-latency live delta streaming
684
679
  const queue = []
@@ -700,6 +695,12 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
700
695
  callLlm,
701
696
  cwd,
702
697
  onProgress: pushUpdate,
698
+ onStreamDelta: (delta) => {
699
+ pushUpdate(delta)
700
+ },
701
+ prices,
702
+ historyFilePath,
703
+ liveCanvas,
703
704
  })
704
705
  .catch((err) => ({ error: err }))
705
706
  .finally(() => {
@@ -736,7 +737,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
736
737
 
737
738
  const elapsedSec = Math.floor((Date.now() - startTime) / 1000)
738
739
  if (!done && Date.now() - lastYieldTime >= 3000) {
739
- yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}с] Идёт обработка...*\n` }
740
+ yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}s] Still processing...*\n` }
740
741
  lastYieldTime = Date.now()
741
742
  }
742
743
  }
@@ -748,7 +749,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
748
749
  }
749
750
 
750
751
  if (result?.error) {
751
- const errText = '\n\n⚠️ **Ошибка Mixture of Agents**: ' + (result.error?.message || String(result.error))
752
+ const errText = '\n\n⚠️ **Mixture of Agents error**: ' + (result.error?.message || String(result.error))
752
753
  yield { type: 'text-delta', index: 0, text: errText }
753
754
  yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
754
755
  yield { type: 'finish', reason: { kind: 'stop' } }
@@ -762,12 +763,15 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
762
763
 
763
764
  yield { type: 'text-delta', index: 0, text: '\n---\n\n' + formatted }
764
765
  yield { type: 'block-end', index: 0, block: { type: 'text', text: formatted } }
765
- yield { type: 'usage', usage: { inputTokens: result?.usage?.totalTokens || 100, outputTokens: formatted.length } }
766
+ yield {
767
+ type: 'usage',
768
+ usage: {
769
+ inputTokens: result?.usage?.totalTokens || 0,
770
+ outputTokens: Math.round((formatted.length || 0) / 4),
771
+ },
772
+ }
766
773
  yield { type: 'finish', reason: { kind: 'stop' } }
767
774
  } finally {
768
- if (signal?.aborted) {
769
- await cleanMoaWorkspaces(cwd)
770
- }
775
+ // cleanup
771
776
  }
772
777
  }
773
-