@goodandready/dsh-moa 0.2.3 → 0.2.5

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,6 +1,8 @@
1
+ import { estimateTokenCost as calculateTokenCost, resolveModelRates, refreshCatalogInBackground, DIRECT_VENDOR_RATES, FALLBACK_RATES } from './pricing.js'
1
2
  /**
2
- * Mixture of Agents (MoA) execution engine with Interactive Questioning
3
- * and Isolated Multi-Candidate File Execution.
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.
4
6
  */
5
7
 
6
8
  import {
@@ -8,13 +10,19 @@ import {
8
10
  writeCandidateWorkspace,
9
11
  promoteCandidateWorkspace,
10
12
  cleanMoaWorkspaces,
13
+ collectProjectContext,
14
+ isRefinementTask,
11
15
  } from './file-workspace.js'
12
16
 
17
+ import { recordMoaRun } from './history.js'
18
+
13
19
  export {
14
20
  extractFileBlocks,
15
21
  writeCandidateWorkspace,
16
22
  promoteCandidateWorkspace,
17
23
  cleanMoaWorkspaces,
24
+ collectProjectContext,
25
+ isRefinementTask,
18
26
  }
19
27
 
20
28
  export const REFERENCE_SYSTEM_PROMPT = `You are an expert candidate engineer model in a Mixture of Agents (MoA) architecture.
@@ -34,12 +42,30 @@ RULES:
34
42
  4. If the user request is broad, make opinionated, high-quality technical decisions and deliver a complete working project.
35
43
  5. Never leave placeholders like "// TODO" or "... rest of code". Deliver exhaustive, working code.`
36
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
+
37
57
  export const ADVISOR_QUESTION_PROMPT = `You are a senior technical advisor in a Mixture of Agents (MoA) architecture.
38
58
  The user has provided a prompt that may have multiple design choices, architectural paths, or underspecified requirements.
39
59
 
40
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).
41
61
  Keep questions very clear, structured, and actionable. Avoid trivial questions. Respond directly in Russian.`
42
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
+ }
68
+
43
69
  export function slotLabel(slot) {
44
70
  if (!slot || typeof slot !== 'object') return 'unknown'
45
71
  const prov = String(slot.provider || '').trim()
@@ -79,44 +105,38 @@ export function cleanAdvisoryMessages(messages = [], maxCharBudget = 4000) {
79
105
 
80
106
  trimmed.push({ role, content: text })
81
107
  }
82
-
83
108
  return trimmed
84
109
  }
85
110
 
86
- /**
87
- * Checks if the prompt should trigger a clarifying questions phase
88
- * instead of immediate execution.
89
- */
90
111
  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) {
112
+ if (!userPrompt || typeof userPrompt !== 'string') return false
113
+ const p = userPrompt.trim()
114
+ const wordCount = p.split(/\s+/).length
115
+
116
+ if (/^(да|нет|1|2|3|4|ок|погнали|давай|yes|no)\b/i.test(p) && wordCount <= 5) {
117
+ return false
118
+ }
119
+
120
+ const hasRecentQuestion = messages.some((m) => {
121
+ const text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '')
122
+ return text.includes('Уточнение требований') || text.includes('опросник') || text.includes('Вариант 1')
123
+ })
124
+ if (hasRecentQuestion) {
109
125
  return false
110
126
  }
111
127
 
112
- // Trigger on Russian broad verbs
113
- const lower = prompt.toLowerCase()
114
- const broadKeywords = ['создай', 'сделай', 'разработай', 'напиши', 'построй', 'сгенерируй']
115
- const hasBroadKeyword = broadKeywords.some((k) => lower.includes(k))
128
+ const creationTriggers = [
129
+ 'сделай', 'создай', 'напиши', 'разработай', 'придумай', 'реализуй',
130
+ 'make', 'build', 'create', 'generate', 'develop',
131
+ ]
132
+ const startsWithCreation = creationTriggers.some((t) => p.toLowerCase().startsWith(t))
133
+
134
+ if (startsWithCreation && wordCount <= 18) {
135
+ return true
136
+ }
116
137
 
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
138
+ const vagueNouns = ['приложение', 'игру', 'сервис', 'сайт', 'лендинг', 'калькулятор', 'виджет', 'дашборд', 'app', 'game', 'tool', 'website']
139
+ if (vagueNouns.some((n) => p.toLowerCase().includes(n))) {
120
140
  if (wordCount <= 12) return true
121
141
  }
122
142
 
@@ -140,21 +160,30 @@ ${joined}
140
160
  В конце добавь примечание, что пользователь может ответить кратко (например: "1, 2, темная тема") или довериться выбору по умолчанию.`
141
161
  }
142
162
 
143
- export function buildSynthesisPrompt(userPrompt, referenceOutputs = []) {
163
+ export function buildSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCriteria = '') {
144
164
  const joined = referenceOutputs
145
165
  .map((r, i) => {
146
166
  const fileSummary = (r.files && r.files.length > 0)
147
167
  ? ` [Созданные файлы: ${r.files.map((f) => f.relativePath).join(', ')}]`
148
168
  : ''
149
- return `Reference ${i + 1} — ${r.label}:${fileSummary ? ` ${fileSummary}` : ''}\n${r.text}`
169
+ // If 3+ candidates, summarize text to prevent judge context overflow (#13)
170
+ let textContent = r.text
171
+ if (referenceOutputs.length >= 3 && textContent.length > 3000) {
172
+ textContent = stripOrSummarizeCode(textContent)
173
+ }
174
+ return `Reference ${i + 1} — ${r.label}:${fileSummary}\n${textContent}`
150
175
  })
151
176
  .join('\n\n')
152
177
 
178
+ const criteriaBlock = judgeCriteria && judgeCriteria.trim()
179
+ ? `\n### 🎯 Дополнительные критерии оценки от пользователя:\n${judgeCriteria.trim()}\n`
180
+ : ''
181
+
153
182
  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
183
 
155
184
  Original user prompt:
156
185
  ${userPrompt}
157
-
186
+ ${criteriaBlock}
158
187
  Reference responses from candidate models:
159
188
  ${joined}
160
189
 
@@ -181,9 +210,10 @@ export function parseWinnerIndex(judgeText, defaultIndex = 1) {
181
210
  const idx = parseInt(match[1], 10)
182
211
  if (!isNaN(idx) && idx >= 1) return idx
183
212
  }
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
213
+ const candMatch = /(?:Кандидат|Reference)\s*(\d+)\b/i.exec(judgeText)
214
+ if (candMatch) {
215
+ const idx = parseInt(candMatch[1], 10)
216
+ if (!isNaN(idx) && idx >= 1) return idx
187
217
  }
188
218
  return defaultIndex
189
219
  }
@@ -263,6 +293,13 @@ export async function runReferencesParallel(references, messages, options = {},
263
293
  const text = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
264
294
  const files = extractFileBlocks(text)
265
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)
302
+
266
303
  if (typeof onProgress === 'function') {
267
304
  const fileMsg = files.length > 0 ? ` (создано файлов: ${files.length})` : ''
268
305
  onProgress(`✅ *Кандидат ${i + 1} (${label}) завершил ответ${fileMsg}.*\n`)
@@ -275,6 +312,8 @@ export async function runReferencesParallel(references, messages, options = {},
275
312
  model: slot.model,
276
313
  text: text || '(empty response)',
277
314
  files,
315
+ usage: costInfo,
316
+ costUsd: costInfo.costUsd,
278
317
  ok: true,
279
318
  }
280
319
  } catch (err) {
@@ -289,6 +328,8 @@ export async function runReferencesParallel(references, messages, options = {},
289
328
  model: slot.model,
290
329
  text: `[failed: ${err?.message || String(err)}]`,
291
330
  files: [],
331
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
332
+ costUsd: 0,
292
333
  ok: false,
293
334
  }
294
335
  }
@@ -299,10 +340,10 @@ export async function runReferencesParallel(references, messages, options = {},
299
340
 
300
341
  /**
301
342
  * Runs the full Mixture of Agents pipeline:
302
- * 1. Checks if questions are needed
343
+ * 1. Checks if questions or refinement needed
303
344
  * 2. Parallel candidate proposers write to .moa/candidate-X/
304
- * 3. Aggregator judges and picks winner
305
- * 4. Promotes winner files and cleans up
345
+ * 3. Aggregator judges and picks winner (or bypassed in Fast Mode)
346
+ * 4. Promotes winner files, records history and calculates costs
306
347
  */
307
348
  export async function runMoAPipeline({
308
349
  userPrompt,
@@ -312,20 +353,37 @@ export async function runMoAPipeline({
312
353
  cwd,
313
354
  onProgress,
314
355
  skipQuestions = false,
356
+ prices = {},
315
357
  }) {
316
358
  if (typeof callLlm !== 'function') {
317
359
  throw new Error('callLlm function is required for runMoAPipeline')
318
360
  }
319
361
 
362
+ const startTime = Date.now()
320
363
  const referenceModels = preset?.reference_models || preset?.referenceModels || []
321
364
  const aggregator = preset?.aggregator || { provider: 'default', model: 'default' }
322
365
  const refTemp = preset?.reference_temperature ?? preset?.referenceTemperature ?? 0.6
323
366
  const aggTemp = preset?.aggregator_temperature ?? preset?.aggregatorTemperature ?? 0.4
324
367
  const maxTokens = preset?.max_tokens ?? preset?.maxTokens ?? 4096
368
+ const judgeCriteria = preset?.judge_criteria || preset?.judgeCriteria || ''
325
369
  const workDir = cwd || process.cwd()
326
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
376
+ 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
+ }
383
+ }
384
+
327
385
  // Phase 1: Check if clarifying questions should be asked
328
- const needsQuestions = !skipQuestions && isBroadPromptRequiringQuestions(userPrompt, messages)
386
+ const needsQuestions = !skipQuestions && !isRefinement && isBroadPromptRequiringQuestions(userPrompt, messages)
329
387
  if (needsQuestions) {
330
388
  if (typeof onProgress === 'function') {
331
389
  onProgress('🔍 *Задача общего характера. Советники формируют ключевые развилки...*\n\n')
@@ -371,15 +429,19 @@ export async function runMoAPipeline({
371
429
  }
372
430
  }
373
431
 
432
+ // FAST MODE: single candidate model bypasses aggregator (#14)
433
+ const isFastMode = referenceModels.length === 1
434
+
374
435
  // Phase 2: Parallel execution & file generation
375
436
  if (typeof onProgress === 'function') {
376
- onProgress(`🚀 *Запускаю параллельную реализацию советниками (${referenceModels.length})...*\n\n`)
437
+ const modeLabel = isFastMode ? '⚡ Fast Mode' : `советниками (${referenceModels.length})`
438
+ onProgress(`🚀 *Запускаю реализацию ${modeLabel}...*\n\n`)
377
439
  }
378
440
 
379
441
  const referenceOutputs = await runReferencesParallel(
380
442
  referenceModels,
381
443
  [...messages, { role: 'user', content: userPrompt }],
382
- { referenceTemperature: refTemp, maxTokens },
444
+ { systemPrompt: candidateSystemPrompt, referenceTemperature: refTemp, maxTokens },
383
445
  callLlm,
384
446
  onProgress,
385
447
  )
@@ -399,35 +461,51 @@ export async function runMoAPipeline({
399
461
  }
400
462
  }
401
463
 
402
- // Phase 3: Aggregator evaluation
464
+ let synthesizedText = ''
465
+ let winningIndex = 1
466
+ let aggUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
403
467
  const aggLabel = slotLabel(aggregator)
404
- if (typeof onProgress === 'function') {
405
- onProgress(`\n⚖️ *Все кандидаты завершили генерацию. Судья (${aggLabel}) оценивает код и файлы...*\n\n`)
406
- }
407
468
 
408
- const synthPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs)
469
+ if (isFastMode) {
470
+ // Fast mode: promote candidate 1 directly without judge
471
+ synthesizedText = referenceOutputs[0]?.text || '(empty fast response)'
472
+ winningIndex = 1
473
+ } else {
474
+ // Phase 3: Aggregator evaluation
475
+ if (typeof onProgress === 'function') {
476
+ onProgress(`\n⚖️ *Все кандидаты завершили генерацию. Судья (${aggLabel}) оценивает код и файлы...*\n\n`)
477
+ }
409
478
 
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')
479
+ const synthPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria)
480
+
481
+ try {
482
+ const aggPromise = callLlm({
483
+ provider: aggregator.provider,
484
+ model: aggregator.model,
485
+ messages: [{ role: 'user', content: synthPrompt }],
486
+ temperature: aggTemp,
487
+ maxTokens,
488
+ })
489
+ const aggTimeout = new Promise((_, reject) =>
490
+ setTimeout(() => reject(new Error(`Timeout after 90s waiting for aggregator ${aggLabel}`)), 90000).unref()
491
+ )
492
+ const res = await Promise.race([aggPromise, aggTimeout])
493
+ synthesizedText = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
494
+
495
+ const rawAggUsage = res?.usage || {
496
+ inputTokens: Math.round(synthPrompt.length / 4),
497
+ outputTokens: Math.round(synthesizedText.length / 4),
498
+ }
499
+ aggUsage = estimateTokenCost(aggregator, rawAggUsage, prices)
500
+ } catch (err) {
501
+ synthesizedText = `[Aggregator error: ${err?.message || String(err)}]\n\nFallback candidate outputs:\n\n` +
502
+ referenceOutputs.map((r, i) => `### ${r.label}\n${r.text}`).join('\n\n')
503
+ }
504
+
505
+ winningIndex = parseWinnerIndex(synthesizedText, 1)
427
506
  }
428
507
 
429
508
  // Phase 4: Promote winner files and cleanup
430
- const winningIndex = parseWinnerIndex(synthesizedText, 1)
431
509
  let promotedFiles = []
432
510
  try {
433
511
  promotedFiles = await promoteCandidateWorkspace(workDir, winningIndex)
@@ -448,7 +526,8 @@ export async function runMoAPipeline({
448
526
  const lcRes = await fetch(`http://127.0.0.1:${port}/dsh-live-canvas/api/open-file`, {
449
527
  method: 'POST',
450
528
  headers: { 'Content-Type': 'application/json' },
451
- body: JSON.stringify({ filePath: previewCandidate })
529
+ body: JSON.stringify({ filePath: previewCandidate }),
530
+ signal: AbortSignal.timeout(500),
452
531
  })
453
532
  if (lcRes.ok) {
454
533
  const lcData = await lcRes.json()
@@ -470,21 +549,55 @@ export async function runMoAPipeline({
470
549
  }
471
550
  }
472
551
 
552
+ // Calculate totals
553
+ const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + aggUsage.totalTokens
554
+ const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + aggUsage.costUsd).toFixed(5))
555
+ const durationMs = Date.now() - startTime
556
+
557
+ const winningRef = referenceOutputs[winningIndex - 1]
558
+ const winnerModel = winningRef?.label || slotLabel(referenceModels[0])
559
+
560
+ // Record run to history (#9, #10, #11)
561
+ try {
562
+ recordMoaRun({
563
+ prompt: userPrompt,
564
+ preset: preset?.name || 'default',
565
+ isRefinement,
566
+ candidates: referenceOutputs,
567
+ aggregator: isFastMode ? null : { provider: aggregator.provider, model: aggregator.model, usage: aggUsage, costUsd: aggUsage.costUsd },
568
+ winnerIndex: winningIndex,
569
+ winnerModel,
570
+ promotedFiles,
571
+ totalTokens,
572
+ totalCostUsd,
573
+ durationMs,
574
+ })
575
+ } catch (histErr) {
576
+ console.warn('[dsh-moa] Failed to record run in history:', histErr)
577
+ }
578
+
473
579
  return {
474
580
  kind: 'synthesis',
475
581
  content: synthesizedText,
476
- aggregator: aggLabel,
582
+ aggregator: isFastMode ? 'Fast Mode (Direct)' : aggLabel,
477
583
  references: referenceOutputs,
478
584
  presetName: preset?.name || 'default',
585
+ isRefinement,
586
+ isFastMode,
479
587
  winningIndex,
588
+ winnerModel,
480
589
  promotedFiles,
481
590
  liveCanvas,
591
+ usage: {
592
+ totalTokens,
593
+ totalCostUsd,
594
+ candidates: referenceOutputs.map(r => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
595
+ aggregator: aggUsage,
596
+ },
597
+ durationMs,
482
598
  }
483
599
  }
484
600
 
485
- /**
486
- * Formats full MoA output showing candidate outputs, judge synthesis, and promoted files.
487
- */
488
601
  /**
489
602
  * Replaces large code blocks with concise file/code summaries to avoid token waste and huge chat dumps.
490
603
  */
@@ -492,7 +605,6 @@ export function stripOrSummarizeCode(text) {
492
605
  if (!text || typeof text !== 'string') return ''
493
606
  return text.replace(/```([a-zA-Z0-9_\-\.\/]*)\s*([\w\.\/\-]+\.[a-zA-Z0-9]+)?\n([\s\S]*?)```/g, (match, lang, fileTag, code) => {
494
607
  const lines = code.trim().split('\n')
495
- // Keep very short commands (<= 3 lines) that are not HTML/JSX/source files
496
608
  if (lines.length <= 3 && !/html|jsx|tsx|vue|svelte|css|js|ts/i.test(lang)) {
497
609
  return match
498
610
  }
@@ -515,9 +627,14 @@ export function formatMoAResponse({ moaResult, presetName }) {
515
627
  return parts.join('\n')
516
628
  }
517
629
 
518
- parts.push(`## 🧠 Mixture of Agents (Пресет: ${pName} | Судья: ${judge})`)
630
+ const modeBadge = moaResult?.isFastMode ? '⚡ Fast Mode' : `Судья: ${judge}`
631
+ parts.push(`## 🧠 Mixture of Agents (Пресет: ${pName} | ${modeBadge})`)
519
632
  parts.push('')
520
633
 
634
+ if (moaResult?.isRefinement) {
635
+ parts.push('> 🔄 **Режим**: Итеративная доработка проекта (Refinement)')
636
+ }
637
+
521
638
  const hasPromoted = moaResult?.promotedFiles && moaResult.promotedFiles.length > 0
522
639
  if (hasPromoted) {
523
640
  parts.push(`> 📦 **Созданы файлы в проекте**: \`${moaResult.promotedFiles.join('`, `')}\``)
@@ -527,25 +644,35 @@ export function formatMoAResponse({ moaResult, presetName }) {
527
644
  parts.push(`> 🎨 **Live Canvas**: [🚀 Открыть ${moaResult.liveCanvas.title || 'превью'} в Live Canvas](${moaResult.liveCanvas.previewUrl}) | [↗ Открыть в новой вкладке](${moaResult.liveCanvas.previewUrl})`)
528
645
  }
529
646
 
530
- if (hasPromoted || moaResult?.liveCanvas) {
531
- parts.push('')
647
+ // Cost tracking card (#10)
648
+ if (moaResult?.usage) {
649
+ const u = moaResult.usage
650
+ const costStr = u.totalCostUsd > 0 ? `~\$${u.totalCostUsd.toFixed(4)}` : 'Бесплатно'
651
+ const tokStr = u.totalTokens >= 1000 ? `${(u.totalTokens / 1000).toFixed(1)}k` : `${u.totalTokens}`
652
+ parts.push(`> 💰 **Стоимость запуска**: ${costStr} (всего ${tokStr} токенов)`)
532
653
  }
533
654
 
534
- parts.push(`### ⚖️ Вердикт судьи и итоговое решение (Синтез: ${judge})`)
535
- parts.push('')
536
- const cleanJudgeContent = hasPromoted
537
- ? stripOrSummarizeCode(moaResult?.content || '')
538
- : (moaResult?.content || '(нет ответа)')
539
- parts.push(cleanJudgeContent)
540
655
  parts.push('')
541
656
 
657
+ if (!moaResult?.isFastMode) {
658
+ parts.push(`### ⚖️ Вердикт судьи и итоговое решение (Синтез: ${judge})`)
659
+ parts.push('')
660
+ const cleanJudgeContent = hasPromoted
661
+ ? stripOrSummarizeCode(moaResult?.content || '')
662
+ : (moaResult?.content || '(нет ответа)')
663
+ parts.push(cleanJudgeContent)
664
+ parts.push('')
665
+ }
666
+
542
667
  if (Array.isArray(refs) && refs.length > 0) {
543
- parts.push(`### 👥 Ответы моделей-советников (${refs.length}):`)
668
+ const title = moaResult?.isFastMode ? '### 🚀 Результат генерации кандидата:' : `### 👥 Ответы моделей-советников (${refs.length}):`
669
+ parts.push(title)
544
670
  parts.push('')
545
671
  refs.forEach((ref, i) => {
546
672
  const statusIcon = ref.ok ? '✅' : '⚠️'
547
673
  const fileBadge = ref.files?.length ? ` (${ref.files.length} файл(ов))` : ''
548
- parts.push(`#### ${statusIcon} Модель ${i + 1}: ${ref.label}${fileBadge}`)
674
+ const costBadge = ref.costUsd > 0 ? ` [~\$${ref.costUsd.toFixed(4)}]` : ''
675
+ parts.push(`#### ${statusIcon} Модель ${i + 1}: ${ref.label}${fileBadge}${costBadge}`)
549
676
  parts.push('')
550
677
  parts.push(stripOrSummarizeCode(ref.text))
551
678
  parts.push('')
@@ -583,7 +710,9 @@ export class MoaRunnerAdapter {
583
710
  return { provider, id: model, name: 'MoA Ensemble' }
584
711
  }
585
712
 
586
- async prepareCall(provider, model) {
713
+ async prepareCall(providerOrConfig, modelOrSignal, _signal) {
714
+ const provider = typeof providerOrConfig === 'object' && providerOrConfig !== null ? providerOrConfig.provider : providerOrConfig
715
+ const model = typeof providerOrConfig === 'object' && providerOrConfig !== null ? providerOrConfig.model : modelOrSignal
587
716
  return {
588
717
  model: { provider, id: model, name: 'MoA Ensemble' },
589
718
  stream: (options) => this.stream(options),
@@ -618,7 +747,7 @@ export class MoaRunnerAdapter {
618
747
  const startTime = Date.now()
619
748
  let lastYieldTime = Date.now()
620
749
 
621
- // Interval to yield progressive updates while pipeline runs
750
+ // Yield progressive updates while pipeline runs
622
751
  while (true) {
623
752
  if (signal?.aborted) return
624
753
 
@@ -659,7 +788,7 @@ export class MoaRunnerAdapter {
659
788
 
660
789
  yield { type: 'text-delta', index: 0, text: '\n---\n\n' + formatted }
661
790
  yield { type: 'block-end', index: 0, block: { type: 'text', text: formatted } }
662
- yield { type: 'usage', usage: { inputTokens: 100, outputTokens: formatted.length } }
791
+ yield { type: 'usage', usage: { inputTokens: moaResult?.usage?.totalTokens || 100, outputTokens: formatted.length } }
663
792
  yield { type: 'finish', reason: { kind: 'stop' } }
664
793
  } catch (err) {
665
794
  if (signal?.aborted) return