@goodandready/dsh-moa 0.2.0

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.
@@ -0,0 +1,672 @@
1
+ /**
2
+ * Mixture of Agents (MoA) execution engine with Interactive Questioning
3
+ * and Isolated Multi-Candidate File Execution.
4
+ */
5
+
6
+ import {
7
+ extractFileBlocks,
8
+ writeCandidateWorkspace,
9
+ promoteCandidateWorkspace,
10
+ cleanMoaWorkspaces,
11
+ } from './file-workspace.js'
12
+
13
+ export {
14
+ extractFileBlocks,
15
+ writeCandidateWorkspace,
16
+ promoteCandidateWorkspace,
17
+ cleanMoaWorkspaces,
18
+ }
19
+
20
+ export const REFERENCE_SYSTEM_PROMPT = `You are an expert candidate engineer model in a Mixture of Agents (MoA) architecture.
21
+ You are directly implementing the solution for the user's task.
22
+
23
+ RULES:
24
+ 1. Do NOT emit internal planning or pretend tool calls (no xml, no <invoke>, no brainstorming delays).
25
+ 2. Write complete, production-ready, functional code.
26
+ 3. Every file you produce MUST be formatted in markdown code blocks with clear file path annotation, e.g.:
27
+ \`\`\`html file="index.html"
28
+ ...
29
+ \`\`\`
30
+ or
31
+ \`\`\`javascript file="src/app.js"
32
+ ...
33
+ \`\`\`
34
+ 4. If the user request is broad, make opinionated, high-quality technical decisions and deliver a complete working project.
35
+ 5. Never leave placeholders like "// TODO" or "... rest of code". Deliver exhaustive, working code.`
36
+
37
+ export const ADVISOR_QUESTION_PROMPT = `You are a senior technical advisor in a Mixture of Agents (MoA) architecture.
38
+ The user has provided a prompt that may have multiple design choices, architectural paths, or underspecified requirements.
39
+
40
+ 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).
41
+ Keep questions very clear, structured, and actionable. Avoid trivial questions. Respond directly in Russian.`
42
+
43
+ export function slotLabel(slot) {
44
+ if (!slot || typeof slot !== 'object') return 'unknown'
45
+ const prov = String(slot.provider || '').trim()
46
+ const model = String(slot.model || '').trim()
47
+ if (prov && model) return `${prov}:${model}`
48
+ return prov || model || 'unknown'
49
+ }
50
+
51
+ /**
52
+ * Strips tool results, system prompts, and tool calls from history
53
+ * to give reference advisors a clean, advisory-safe context.
54
+ */
55
+ export function cleanAdvisoryMessages(messages = [], maxCharBudget = 4000) {
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 && part.type === 'text' && typeof part.text === 'string')
68
+ .map((part) => part.text)
69
+ .join('\n')
70
+ }
71
+
72
+ if (!text.trim()) continue
73
+
74
+ if (text.length > maxCharBudget) {
75
+ const head = text.slice(0, Math.floor(maxCharBudget * 0.7))
76
+ const tail = text.slice(-Math.floor(maxCharBudget * 0.3))
77
+ text = `${head}\n... [trimmed ${text.length - maxCharBudget} characters] ...\n${tail}`
78
+ }
79
+
80
+ trimmed.push({ role, content: text })
81
+ }
82
+
83
+ return trimmed
84
+ }
85
+
86
+ /**
87
+ * Checks if the prompt should trigger a clarifying questions phase
88
+ * instead of immediate execution.
89
+ */
90
+ export function isBroadPromptRequiringQuestions(userPrompt = '', messages = []) {
91
+ const prompt = (userPrompt || '').trim()
92
+ if (!prompt) return false
93
+
94
+ // Find the last assistant message in history
95
+ const lastAssistant = [...messages].reverse().find((m) => m && m.role === 'assistant')
96
+ const lastAssistantText = typeof lastAssistant?.content === 'string'
97
+ ? lastAssistant.content
98
+ : (Array.isArray(lastAssistant?.content)
99
+ ? lastAssistant.content.map((c) => (typeof c === 'string' ? c : c.text || '')).join('')
100
+ : '')
101
+
102
+ // If the last assistant message was an MoA questionnaire, the user is answering it now!
103
+ const isAnsweringQuestions =
104
+ lastAssistantText.includes('Уточнение требований') ||
105
+ lastAssistantText.includes('Уточните, пожалуйста') ||
106
+ lastAssistantText.includes('опросник')
107
+
108
+ if (isAnsweringQuestions) {
109
+ return false
110
+ }
111
+
112
+ // Trigger on Russian broad verbs
113
+ const lower = prompt.toLowerCase()
114
+ const broadKeywords = ['создай', 'сделай', 'разработай', 'напиши', 'построй', 'сгенерируй']
115
+ const hasBroadKeyword = broadKeywords.some((k) => lower.includes(k))
116
+
117
+ // If it contains a broad keyword and is relatively brief (<= 12 words), ask questions!
118
+ if (hasBroadKeyword) {
119
+ const wordCount = prompt.split(/\s+/).filter(Boolean).length
120
+ if (wordCount <= 12) return true
121
+ }
122
+
123
+ return false
124
+ }
125
+
126
+ export function buildQuestionSynthesisPrompt(userPrompt, referenceOutputs = []) {
127
+ const joined = referenceOutputs
128
+ .map((r, i) => `Советник ${i + 1} (${r.label}):\n${r.text}`)
129
+ .join('\n\n')
130
+
131
+ return `Ты — ведущий архитектор и судья в архитектуре Mixture of Agents (MoA).
132
+ Пользователь дал задачу:
133
+ "${userPrompt}"
134
+
135
+ Советники предложили следующие развилки и уточнения:
136
+ ${joined}
137
+
138
+ Твоя задача — синтезировать единый, компактный, дружелюбный и структурированный опросник (2-4 вопроса) для пользователя на русском языке.
139
+ Каждый вопрос должен предлагать 2-3 конкретных рекомендуемых варианта ответа (например: 1. Формат: HTML/JS в одном файле или React? 2. Стиль: Минимализм, iOS или Необрутализм?).
140
+ В конце добавь примечание, что пользователь может ответить кратко (например: "1, 2, темная тема") или довериться выбору по умолчанию.`
141
+ }
142
+
143
+ export function buildSynthesisPrompt(userPrompt, referenceOutputs = []) {
144
+ const joined = referenceOutputs
145
+ .map((r, i) => {
146
+ const fileSummary = (r.files && r.files.length > 0)
147
+ ? ` [Созданные файлы: ${r.files.map((f) => f.relativePath).join(', ')}]`
148
+ : ''
149
+ return `Reference ${i + 1} — ${r.label}:${fileSummary ? ` ${fileSummary}` : ''}\n${r.text}`
150
+ })
151
+ .join('\n\n')
152
+
153
+ 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.
154
+
155
+ Original user prompt:
156
+ ${userPrompt}
157
+
158
+ Reference responses from candidate models:
159
+ ${joined}
160
+
161
+ Instructions:
162
+ Your response MUST be structured into three clear parts (respond in the same language as the user's prompt, e.g. Russian):
163
+
164
+ ### 1. ⚖️ Вердикт судьи и анализ вариантов
165
+ - **Чей вариант выбран**: Чётко укажи имя модели и номер кандидата (например: "Победитель: Кандидат 1 (opencode-go:deepseek-v4-flash)" или "Выбран вариант модели Reference 1 (label)").
166
+ - Обязательно добавь машинный маркер выбора победителя:
167
+ WINNER_CANDIDATE_INDEX: <число от 1 до N>
168
+ - **Почему сделан этот выбор**: Подробно сравни код, архитектуру, сильные стороны, недочёты и надёжность всех кандидатов.
169
+
170
+ ### 2. 📁 Созданные файлы проекта
171
+ - Перечисли файлы победителя, которые были перенесены в корень проекта, и их назначение.
172
+
173
+ ### 3. 🚀 Инструкция по запуску и использованию
174
+ - Опиши, как открыть и запустить созданный проект.`
175
+ }
176
+
177
+ export function parseWinnerIndex(judgeText, defaultIndex = 1) {
178
+ if (!judgeText || typeof judgeText !== 'string') return defaultIndex
179
+ const match = /WINNER_CANDIDATE_INDEX:\s*(\d+)/i.exec(judgeText)
180
+ if (match) {
181
+ const idx = parseInt(match[1], 10)
182
+ if (!isNaN(idx) && idx >= 1) return idx
183
+ }
184
+ // Fallback: look for "Кандидат 1", "Reference 1", "Кандидат 2", "Reference 2"
185
+ if (/(?:Кандидат|Reference)\s*2\b/i.test(judgeText) && !/(?:Кандидат|Reference)\s*1\b/i.test(judgeText)) {
186
+ return 2
187
+ }
188
+ return defaultIndex
189
+ }
190
+
191
+ export function parseMoACommand(text, presets = []) {
192
+ if (typeof text !== 'string' || !text.startsWith('/moa')) {
193
+ return null
194
+ }
195
+
196
+ const remainder = text.slice(4).trim()
197
+ if (!remainder) {
198
+ return {
199
+ presetName: 'default',
200
+ prompt: '',
201
+ }
202
+ }
203
+
204
+ const matchPreset = /^--preset=([a-zA-Z0-9_-]+)\s*(.*)/s.exec(remainder)
205
+ if (matchPreset) {
206
+ return {
207
+ presetName: matchPreset[1],
208
+ prompt: matchPreset[2] || '',
209
+ }
210
+ }
211
+
212
+ const parts = remainder.split(/\s+/)
213
+ const firstWord = parts[0]
214
+ if (Array.isArray(presets)) {
215
+ const matched = presets.find((p) => p.name === firstWord)
216
+ if (matched) {
217
+ return {
218
+ presetName: matched.name,
219
+ prompt: parts.slice(1).join(' ').trim(),
220
+ }
221
+ }
222
+ }
223
+
224
+ return {
225
+ presetName: 'default',
226
+ prompt: remainder,
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Dispatches queries to all reference models in parallel.
232
+ */
233
+ export async function runReferencesParallel(references, messages, options = {}, callLlm, onProgress) {
234
+ if (!Array.isArray(references) || references.length === 0) {
235
+ return []
236
+ }
237
+
238
+ const advisoryMessages = cleanAdvisoryMessages(messages)
239
+ const systemPrompt = options.systemPrompt || REFERENCE_SYSTEM_PROMPT
240
+ const fullMessages = [{ role: 'system', content: systemPrompt }, ...advisoryMessages]
241
+ const timeoutMs = options.timeoutMs ?? 75000
242
+
243
+ const tasks = references.map(async (slot, i) => {
244
+ const label = slotLabel(slot)
245
+ if (typeof onProgress === 'function') {
246
+ onProgress(`⚡ *Кандидат ${i + 1} (${label}) начал генерацию...*\n`)
247
+ }
248
+
249
+ try {
250
+ const callPromise = callLlm({
251
+ provider: slot.provider,
252
+ model: slot.model,
253
+ messages: fullMessages,
254
+ temperature: options.referenceTemperature ?? 0.6,
255
+ maxTokens: options.maxTokens ?? 4096,
256
+ })
257
+
258
+ const timeoutPromise = new Promise((_, reject) =>
259
+ setTimeout(() => reject(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s waiting for ${label}`)), timeoutMs).unref()
260
+ )
261
+
262
+ const res = await Promise.race([callPromise, timeoutPromise])
263
+ const text = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
264
+ const files = extractFileBlocks(text)
265
+
266
+ if (typeof onProgress === 'function') {
267
+ const fileMsg = files.length > 0 ? ` (создано файлов: ${files.length})` : ''
268
+ onProgress(`✅ *Кандидат ${i + 1} (${label}) завершил ответ${fileMsg}.*\n`)
269
+ }
270
+
271
+ return {
272
+ index: i + 1,
273
+ label,
274
+ provider: slot.provider,
275
+ model: slot.model,
276
+ text: text || '(empty response)',
277
+ files,
278
+ ok: true,
279
+ }
280
+ } catch (err) {
281
+ console.warn(`[dsh-moa] Reference ${label} failed:`, err)
282
+ if (typeof onProgress === 'function') {
283
+ onProgress(`⚠️ *Кандидат ${i + 1} (${label}) завершился с ошибкой: ${err?.message || String(err)}*\n`)
284
+ }
285
+ return {
286
+ index: i + 1,
287
+ label,
288
+ provider: slot.provider,
289
+ model: slot.model,
290
+ text: `[failed: ${err?.message || String(err)}]`,
291
+ files: [],
292
+ ok: false,
293
+ }
294
+ }
295
+ })
296
+
297
+ return Promise.all(tasks)
298
+ }
299
+
300
+ /**
301
+ * Runs the full Mixture of Agents pipeline:
302
+ * 1. Checks if questions are needed
303
+ * 2. Parallel candidate proposers write to .moa/candidate-X/
304
+ * 3. Aggregator judges and picks winner
305
+ * 4. Promotes winner files and cleans up
306
+ */
307
+ export async function runMoAPipeline({
308
+ userPrompt,
309
+ messages = [],
310
+ preset,
311
+ callLlm,
312
+ cwd,
313
+ onProgress,
314
+ skipQuestions = false,
315
+ }) {
316
+ if (typeof callLlm !== 'function') {
317
+ throw new Error('callLlm function is required for runMoAPipeline')
318
+ }
319
+
320
+ const referenceModels = preset?.reference_models || preset?.referenceModels || []
321
+ const aggregator = preset?.aggregator || { provider: 'default', model: 'default' }
322
+ const refTemp = preset?.reference_temperature ?? preset?.referenceTemperature ?? 0.6
323
+ const aggTemp = preset?.aggregator_temperature ?? preset?.aggregatorTemperature ?? 0.4
324
+ const maxTokens = preset?.max_tokens ?? preset?.maxTokens ?? 4096
325
+ const workDir = cwd || process.cwd()
326
+
327
+ // Phase 1: Check if clarifying questions should be asked
328
+ const needsQuestions = !skipQuestions && isBroadPromptRequiringQuestions(userPrompt, messages)
329
+ if (needsQuestions) {
330
+ if (typeof onProgress === 'function') {
331
+ onProgress('🔍 *Задача общего характера. Советники формируют ключевые развилки...*\n\n')
332
+ }
333
+
334
+ const questionOutputs = await runReferencesParallel(
335
+ referenceModels,
336
+ [...messages, { role: 'user', content: userPrompt }],
337
+ { systemPrompt: ADVISOR_QUESTION_PROMPT, referenceTemperature: 0.5, maxTokens: 1024 },
338
+ callLlm,
339
+ onProgress,
340
+ )
341
+
342
+ if (typeof onProgress === 'function') {
343
+ onProgress(`\n⚖️ *Судья (${slotLabel(aggregator)}) синтезирует единый опросник для вас...*\n\n`)
344
+ }
345
+
346
+ const qSynthPrompt = buildQuestionSynthesisPrompt(userPrompt, questionOutputs)
347
+ let questionsText = ''
348
+ try {
349
+ const qPromise = callLlm({
350
+ provider: aggregator.provider,
351
+ model: aggregator.model,
352
+ messages: [{ role: 'user', content: qSynthPrompt }],
353
+ temperature: 0.3,
354
+ maxTokens: 1500,
355
+ })
356
+ const qTimeout = new Promise((_, reject) =>
357
+ setTimeout(() => reject(new Error('Timeout waiting for judge questions synthesis')), 60000).unref()
358
+ )
359
+ const res = await Promise.race([qPromise, qTimeout])
360
+ questionsText = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
361
+ } catch (err) {
362
+ questionsText = `Не удалось сформировать вопросы судьи: ${err?.message || String(err)}`
363
+ }
364
+
365
+ return {
366
+ kind: 'questions',
367
+ content: questionsText,
368
+ aggregator: slotLabel(aggregator),
369
+ references: questionOutputs,
370
+ presetName: preset?.name || 'default',
371
+ }
372
+ }
373
+
374
+ // Phase 2: Parallel execution & file generation
375
+ if (typeof onProgress === 'function') {
376
+ onProgress(`🚀 *Запускаю параллельную реализацию советниками (${referenceModels.length})...*\n\n`)
377
+ }
378
+
379
+ const referenceOutputs = await runReferencesParallel(
380
+ referenceModels,
381
+ [...messages, { role: 'user', content: userPrompt }],
382
+ { referenceTemperature: refTemp, maxTokens },
383
+ callLlm,
384
+ onProgress,
385
+ )
386
+
387
+ // Write files for each candidate into .moa/candidate-X/
388
+ for (let i = 0; i < referenceOutputs.length; i++) {
389
+ const ref = referenceOutputs[i]
390
+ if (ref.ok && ref.files && ref.files.length > 0) {
391
+ try {
392
+ await writeCandidateWorkspace(workDir, i + 1, ref.files)
393
+ if (typeof onProgress === 'function') {
394
+ onProgress(`📁 *Кандидат ${i + 1} (${ref.label}) сохранил ${ref.files.length} файл(а) в .moa/candidate-${i + 1}/*\n`)
395
+ }
396
+ } catch (writeErr) {
397
+ console.warn(`[dsh-moa] Failed to write candidate ${i + 1} workspace:`, writeErr)
398
+ }
399
+ }
400
+ }
401
+
402
+ // Phase 3: Aggregator evaluation
403
+ const aggLabel = slotLabel(aggregator)
404
+ if (typeof onProgress === 'function') {
405
+ onProgress(`\n⚖️ *Все кандидаты завершили генерацию. Судья (${aggLabel}) оценивает код и файлы...*\n\n`)
406
+ }
407
+
408
+ const synthPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs)
409
+
410
+ let synthesizedText = ''
411
+ try {
412
+ const aggPromise = callLlm({
413
+ provider: aggregator.provider,
414
+ model: aggregator.model,
415
+ messages: [{ role: 'user', content: synthPrompt }],
416
+ temperature: aggTemp,
417
+ maxTokens,
418
+ })
419
+ const aggTimeout = new Promise((_, reject) =>
420
+ setTimeout(() => reject(new Error(`Timeout after 90s waiting for aggregator ${aggLabel}`)), 90000).unref()
421
+ )
422
+ const res = await Promise.race([aggPromise, aggTimeout])
423
+ synthesizedText = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
424
+ } catch (err) {
425
+ synthesizedText = `[Aggregator error: ${err?.message || String(err)}]\n\nFallback candidate outputs:\n\n` +
426
+ referenceOutputs.map((r, i) => `### ${r.label}\n${r.text}`).join('\n\n')
427
+ }
428
+
429
+ // Phase 4: Promote winner files and cleanup
430
+ const winningIndex = parseWinnerIndex(synthesizedText, 1)
431
+ let promotedFiles = []
432
+ try {
433
+ promotedFiles = await promoteCandidateWorkspace(workDir, winningIndex)
434
+ if (typeof onProgress === 'function' && promotedFiles.length > 0) {
435
+ onProgress(`\n✅ *Файлы победителя (Кандидат ${winningIndex}) перенесены в проект: ${promotedFiles.join(', ')}*\n\n`)
436
+ }
437
+ } catch (promoteErr) {
438
+ console.warn('[dsh-moa] Error promoting candidate files:', promoteErr)
439
+ }
440
+
441
+ // Phase 5: Live Canvas preview auto-registration
442
+ let liveCanvas = null
443
+ if (promotedFiles && promotedFiles.length > 0) {
444
+ const previewCandidate = promotedFiles.find(f => /\.(html|htm|jsx|tsx|vue|svelte)$/i.test(f)) || promotedFiles[0]
445
+ if (previewCandidate) {
446
+ try {
447
+ const port = process.env.PORT || 3080
448
+ const lcRes = await fetch(`http://127.0.0.1:${port}/dsh-live-canvas/api/open-file`, {
449
+ method: 'POST',
450
+ headers: { 'Content-Type': 'application/json' },
451
+ body: JSON.stringify({ filePath: previewCandidate })
452
+ })
453
+ if (lcRes.ok) {
454
+ const lcData = await lcRes.json()
455
+ if (lcData?.canvasId) {
456
+ liveCanvas = {
457
+ canvasId: lcData.canvasId,
458
+ title: lcData.title || previewCandidate,
459
+ filePath: previewCandidate,
460
+ previewUrl: lcData.previewUrl || `/dsh-live-canvas/sandbox/${lcData.canvasId}`
461
+ }
462
+ if (typeof onProgress === 'function') {
463
+ onProgress(`\n🎨 *[Live Canvas]: Файл ${previewCandidate} открыт для предпросмотра!*\n\n`)
464
+ }
465
+ }
466
+ }
467
+ } catch (err) {
468
+ // live-canvas plugin might not be installed or reachable, non-fatal
469
+ }
470
+ }
471
+ }
472
+
473
+ return {
474
+ kind: 'synthesis',
475
+ content: synthesizedText,
476
+ aggregator: aggLabel,
477
+ references: referenceOutputs,
478
+ presetName: preset?.name || 'default',
479
+ winningIndex,
480
+ promotedFiles,
481
+ liveCanvas,
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Formats full MoA output showing candidate outputs, judge synthesis, and promoted files.
487
+ */
488
+ /**
489
+ * Replaces large code blocks with concise file/code summaries to avoid token waste and huge chat dumps.
490
+ */
491
+ export function stripOrSummarizeCode(text) {
492
+ if (!text || typeof text !== 'string') return ''
493
+ return text.replace(/```([a-zA-Z0-9_\-\.\/]*)\s*([\w\.\/\-]+\.[a-zA-Z0-9]+)?\n([\s\S]*?)```/g, (match, lang, fileTag, code) => {
494
+ const lines = code.trim().split('\n')
495
+ // Keep very short commands (<= 3 lines) that are not HTML/JSX/source files
496
+ if (lines.length <= 3 && !/html|jsx|tsx|vue|svelte|css|js|ts/i.test(lang)) {
497
+ return match
498
+ }
499
+ const fileHint = fileTag || (code.match(/^\s*(?:\/\/|#|<!--|\/\*)\s*(?:file|filepath|path):\s*([^\s*]+)/im)?.[1])
500
+ const label = fileHint ? `файл \`${fileHint}\`` : (lang ? `код \`${lang}\`` : 'код')
501
+ return `\n> 📄 *[${label} — ${lines.length} строк сохранены на диск]*\n`
502
+ })
503
+ }
504
+
505
+ export function formatMoAResponse({ moaResult, presetName }) {
506
+ const parts = []
507
+ const pName = presetName || moaResult?.presetName || 'default'
508
+ const judge = moaResult?.aggregator || 'unknown'
509
+ const refs = moaResult?.references || []
510
+
511
+ if (moaResult?.kind === 'questions') {
512
+ parts.push('## 🧠 Mixture of Agents — Уточнение требований')
513
+ parts.push(`*Судья (${judge}) и советники проанализировали задачу:*\n`)
514
+ parts.push(moaResult.content)
515
+ return parts.join('\n')
516
+ }
517
+
518
+ parts.push(`## 🧠 Mixture of Agents (Пресет: ${pName} | Судья: ${judge})`)
519
+ parts.push('')
520
+
521
+ const hasPromoted = moaResult?.promotedFiles && moaResult.promotedFiles.length > 0
522
+ if (hasPromoted) {
523
+ parts.push(`> 📦 **Созданы файлы в проекте**: \`${moaResult.promotedFiles.join('`, `')}\``)
524
+ }
525
+
526
+ if (moaResult?.liveCanvas?.previewUrl) {
527
+ parts.push(`> 🎨 **Live Canvas**: [🚀 Открыть ${moaResult.liveCanvas.title || 'превью'} в Live Canvas](${moaResult.liveCanvas.previewUrl}) | [↗ Открыть в новой вкладке](${moaResult.liveCanvas.previewUrl})`)
528
+ }
529
+
530
+ if (hasPromoted || moaResult?.liveCanvas) {
531
+ parts.push('')
532
+ }
533
+
534
+ parts.push(`### ⚖️ Вердикт судьи и итоговое решение (Синтез: ${judge})`)
535
+ parts.push('')
536
+ const cleanJudgeContent = hasPromoted
537
+ ? stripOrSummarizeCode(moaResult?.content || '')
538
+ : (moaResult?.content || '(нет ответа)')
539
+ parts.push(cleanJudgeContent)
540
+ parts.push('')
541
+
542
+ if (Array.isArray(refs) && refs.length > 0) {
543
+ parts.push(`### 👥 Ответы моделей-советников (${refs.length}):`)
544
+ parts.push('')
545
+ refs.forEach((ref, i) => {
546
+ const statusIcon = ref.ok ? '✅' : '⚠️'
547
+ const fileBadge = ref.files?.length ? ` (${ref.files.length} файл(ов))` : ''
548
+ parts.push(`#### ${statusIcon} Модель ${i + 1}: ${ref.label}${fileBadge}`)
549
+ parts.push('')
550
+ parts.push(stripOrSummarizeCode(ref.text))
551
+ parts.push('')
552
+ parts.push('---')
553
+ parts.push('')
554
+ })
555
+ }
556
+
557
+ return parts.join('\n')
558
+ }
559
+
560
+ export class MoaRunnerAdapter {
561
+ constructor(getMoaContext) {
562
+ this.getMoaContext = getMoaContext
563
+ }
564
+
565
+ providerInfo(provider) {
566
+ return { id: provider, name: 'Mixture of Agents Runner' }
567
+ }
568
+
569
+ providerRetryPolicy(_provider) {
570
+ return undefined
571
+ }
572
+
573
+ imageRequestPricing(_provider, _model) {
574
+ return undefined
575
+ }
576
+
577
+ async listModels(provider) {
578
+ const prov = typeof provider === 'string' && provider.length > 0 ? provider : 'moa-runner'
579
+ return [{ provider: prov, id: 'ensemble', name: 'MoA Ensemble' }]
580
+ }
581
+
582
+ async resolveModel(provider, model) {
583
+ return { provider, id: model, name: 'MoA Ensemble' }
584
+ }
585
+
586
+ async prepareCall(provider, model) {
587
+ return {
588
+ model: { provider, id: model, name: 'MoA Ensemble' },
589
+ stream: (options) => this.stream(options),
590
+ }
591
+ }
592
+
593
+ async *stream(options) {
594
+ const signal = options.signal
595
+ if (signal?.aborted) return
596
+
597
+ yield { type: 'block-start', index: 0, blockType: 'text' }
598
+ yield { type: 'text-delta', index: 0, text: '🧠 *Mixture of Agents запущен...*\n\n' }
599
+
600
+ const { targetPreset, userPrompt, messages, callLlm, cwd } = this.getMoaContext(options)
601
+
602
+ // Queue for streaming progress updates from within runMoAPipeline
603
+ const progressChunks = []
604
+ const onProgress = (text) => {
605
+ progressChunks.push(text)
606
+ }
607
+
608
+ try {
609
+ const pipelinePromise = runMoAPipeline({
610
+ userPrompt,
611
+ messages,
612
+ preset: targetPreset,
613
+ callLlm,
614
+ cwd,
615
+ onProgress,
616
+ })
617
+
618
+ const startTime = Date.now()
619
+ let lastYieldTime = Date.now()
620
+
621
+ // Interval to yield progressive updates while pipeline runs
622
+ while (true) {
623
+ if (signal?.aborted) return
624
+
625
+ let emittedAny = false
626
+ while (progressChunks.length > 0) {
627
+ const chunk = progressChunks.shift()
628
+ yield { type: 'text-delta', index: 0, text: chunk }
629
+ emittedAny = true
630
+ lastYieldTime = Date.now()
631
+ }
632
+
633
+ const done = await Promise.race([
634
+ pipelinePromise.then(() => true),
635
+ new Promise((r) => { const t = setTimeout(() => r(false), 500); t.unref?.(); }),
636
+ ])
637
+ if (done) break
638
+
639
+ const elapsedSec = Math.floor((Date.now() - startTime) / 1000)
640
+ // If 3 seconds passed without any progress message, send a live tick
641
+ if (!emittedAny && Date.now() - lastYieldTime >= 3000) {
642
+ yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}с] Идёт обработка...*\n` }
643
+ lastYieldTime = Date.now()
644
+ }
645
+ }
646
+
647
+ while (progressChunks.length > 0) {
648
+ const chunk = progressChunks.shift()
649
+ yield { type: 'text-delta', index: 0, text: chunk }
650
+ }
651
+
652
+ const moaResult = await pipelinePromise
653
+ if (signal?.aborted) return
654
+
655
+ const formatted = formatMoAResponse({
656
+ moaResult,
657
+ presetName: targetPreset?.name || 'default',
658
+ })
659
+
660
+ yield { type: 'text-delta', index: 0, text: '\n---\n\n' + formatted }
661
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: formatted } }
662
+ yield { type: 'usage', usage: { inputTokens: 100, outputTokens: formatted.length } }
663
+ yield { type: 'finish', reason: { kind: 'stop' } }
664
+ } catch (err) {
665
+ if (signal?.aborted) return
666
+ const errText = '\n\n⚠️ **Ошибка Mixture of Agents**: ' + (err?.message || String(err))
667
+ yield { type: 'text-delta', index: 0, text: errText }
668
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
669
+ yield { type: 'finish', reason: { kind: 'stop' } }
670
+ }
671
+ }
672
+ }