@goodandready/dsh-moa 0.2.2 → 0.2.4

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,7 @@
1
1
  /**
2
- * Mixture of Agents (MoA) execution engine with Interactive Questioning
3
- * and Isolated Multi-Candidate File Execution.
2
+ * Mixture of Agents (MoA) execution engine with Interactive Questioning,
3
+ * Isolated Multi-Candidate File Execution, Native Cost Tracking,
4
+ * Refinement Mode, and Multi-Candidate Scalability.
4
5
  */
5
6
 
6
7
  import {
@@ -8,13 +9,19 @@ import {
8
9
  writeCandidateWorkspace,
9
10
  promoteCandidateWorkspace,
10
11
  cleanMoaWorkspaces,
12
+ collectProjectContext,
13
+ isRefinementTask,
11
14
  } from './file-workspace.js'
12
15
 
16
+ import { recordMoaRun } from './history.js'
17
+
13
18
  export {
14
19
  extractFileBlocks,
15
20
  writeCandidateWorkspace,
16
21
  promoteCandidateWorkspace,
17
22
  cleanMoaWorkspaces,
23
+ collectProjectContext,
24
+ isRefinementTask,
18
25
  }
19
26
 
20
27
  export const REFERENCE_SYSTEM_PROMPT = `You are an expert candidate engineer model in a Mixture of Agents (MoA) architecture.
@@ -34,12 +41,56 @@ RULES:
34
41
  4. If the user request is broad, make opinionated, high-quality technical decisions and deliver a complete working project.
35
42
  5. Never leave placeholders like "// TODO" or "... rest of code". Deliver exhaustive, working code.`
36
43
 
44
+ export const REFINEMENT_SYSTEM_PROMPT = `You are an expert software engineer performing an iterative modification / refinement on an existing project.
45
+ You are provided with the current codebase files and the user's delta request.
46
+
47
+ RULES:
48
+ 1. Modify the existing files or create new files to fulfill the user's change request.
49
+ 2. Deliver complete, production-ready updated code for every modified file.
50
+ 3. Format each updated file in markdown code blocks with explicit file attribute:
51
+ \`\`\`javascript file="src/app.js"
52
+ ...
53
+ \`\`\`
54
+ 4. Keep the existing architecture, styling conventions and dependencies consistent.`
55
+
37
56
  export const ADVISOR_QUESTION_PROMPT = `You are a senior technical advisor in a Mixture of Agents (MoA) architecture.
38
57
  The user has provided a prompt that may have multiple design choices, architectural paths, or underspecified requirements.
39
58
 
40
59
  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
60
  Keep questions very clear, structured, and actionable. Avoid trivial questions. Respond directly in Russian.`
42
61
 
62
+ /**
63
+ * Built-in pricing table for major LLM model families ($ per 1M tokens) (#10).
64
+ */
65
+ export const MODEL_PRICING_REGISTRY = [
66
+ { pattern: /deepseek.*flash|v4.*flash/i, inputPerM: 0.14, outputPerM: 0.28 },
67
+ { pattern: /deepseek.*reasoner|r1/i, inputPerM: 0.55, outputPerM: 2.19 },
68
+ { pattern: /deepseek/i, inputPerM: 0.27, outputPerM: 1.10 },
69
+ { pattern: /gpt-4o-mini|gpt-5.*mini/i, inputPerM: 0.15, outputPerM: 0.60 },
70
+ { pattern: /gpt-5|gpt-4o|sol/i, inputPerM: 2.50, outputPerM: 10.00 },
71
+ { pattern: /grok/i, inputPerM: 2.00, outputPerM: 10.00 },
72
+ { pattern: /claude-3-5-sonnet|claude-3-7-sonnet/i, inputPerM: 3.00, outputPerM: 15.00 },
73
+ { pattern: /claude-3-5-haiku/i, inputPerM: 0.80, outputPerM: 4.00 },
74
+ { pattern: /claude/i, inputPerM: 3.00, outputPerM: 15.00 },
75
+ { pattern: /qwen/i, inputPerM: 0.40, outputPerM: 1.20 },
76
+ { pattern: /commandcode/i, inputPerM: 0.50, outputPerM: 1.50 },
77
+ { pattern: /.*/, inputPerM: 0.50, outputPerM: 1.50 },
78
+ ]
79
+
80
+ export function estimateTokenCost(slot, usage = {}) {
81
+ const modelStr = `${slot?.provider || ''} ${slot?.model || ''}`.trim()
82
+ const match = MODEL_PRICING_REGISTRY.find((p) => p.pattern.test(modelStr)) || MODEL_PRICING_REGISTRY[MODEL_PRICING_REGISTRY.length - 1]
83
+ const inTokens = usage.inputTokens || usage.promptTokens || usage.input_tokens || 0
84
+ const outTokens = usage.outputTokens || usage.completionTokens || usage.output_tokens || 0
85
+ const cost = (inTokens * match.inputPerM / 1_000_000) + (outTokens * match.outputPerM / 1_000_000)
86
+ return {
87
+ inputTokens: inTokens,
88
+ outputTokens: outTokens,
89
+ totalTokens: inTokens + outTokens,
90
+ costUsd: Number(cost.toFixed(5)),
91
+ }
92
+ }
93
+
43
94
  export function slotLabel(slot) {
44
95
  if (!slot || typeof slot !== 'object') return 'unknown'
45
96
  const prov = String(slot.provider || '').trim()
@@ -79,44 +130,38 @@ export function cleanAdvisoryMessages(messages = [], maxCharBudget = 4000) {
79
130
 
80
131
  trimmed.push({ role, content: text })
81
132
  }
82
-
83
133
  return trimmed
84
134
  }
85
135
 
86
- /**
87
- * Checks if the prompt should trigger a clarifying questions phase
88
- * instead of immediate execution.
89
- */
90
136
  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) {
137
+ if (!userPrompt || typeof userPrompt !== 'string') return false
138
+ const p = userPrompt.trim()
139
+ const wordCount = p.split(/\s+/).length
140
+
141
+ if (/^(да|нет|1|2|3|4|ок|погнали|давай|yes|no)\b/i.test(p) && wordCount <= 5) {
142
+ return false
143
+ }
144
+
145
+ const hasRecentQuestion = messages.some((m) => {
146
+ const text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '')
147
+ return text.includes('Уточнение требований') || text.includes('опросник') || text.includes('Вариант 1')
148
+ })
149
+ if (hasRecentQuestion) {
109
150
  return false
110
151
  }
111
152
 
112
- // Trigger on Russian broad verbs
113
- const lower = prompt.toLowerCase()
114
- const broadKeywords = ['создай', 'сделай', 'разработай', 'напиши', 'построй', 'сгенерируй']
115
- const hasBroadKeyword = broadKeywords.some((k) => lower.includes(k))
153
+ const creationTriggers = [
154
+ 'сделай', 'создай', 'напиши', 'разработай', 'придумай', 'реализуй',
155
+ 'make', 'build', 'create', 'generate', 'develop',
156
+ ]
157
+ const startsWithCreation = creationTriggers.some((t) => p.toLowerCase().startsWith(t))
158
+
159
+ if (startsWithCreation && wordCount <= 18) {
160
+ return true
161
+ }
116
162
 
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
163
+ const vagueNouns = ['приложение', 'игру', 'сервис', 'сайт', 'лендинг', 'калькулятор', 'виджет', 'дашборд', 'app', 'game', 'tool', 'website']
164
+ if (vagueNouns.some((n) => p.toLowerCase().includes(n))) {
120
165
  if (wordCount <= 12) return true
121
166
  }
122
167
 
@@ -140,21 +185,30 @@ ${joined}
140
185
  В конце добавь примечание, что пользователь может ответить кратко (например: "1, 2, темная тема") или довериться выбору по умолчанию.`
141
186
  }
142
187
 
143
- export function buildSynthesisPrompt(userPrompt, referenceOutputs = []) {
188
+ export function buildSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCriteria = '') {
144
189
  const joined = referenceOutputs
145
190
  .map((r, i) => {
146
191
  const fileSummary = (r.files && r.files.length > 0)
147
192
  ? ` [Созданные файлы: ${r.files.map((f) => f.relativePath).join(', ')}]`
148
193
  : ''
149
- return `Reference ${i + 1} — ${r.label}:${fileSummary ? ` ${fileSummary}` : ''}\n${r.text}`
194
+ // If 3+ candidates, summarize text to prevent judge context overflow (#13)
195
+ let textContent = r.text
196
+ if (referenceOutputs.length >= 3 && textContent.length > 3000) {
197
+ textContent = stripOrSummarizeCode(textContent)
198
+ }
199
+ return `Reference ${i + 1} — ${r.label}:${fileSummary}\n${textContent}`
150
200
  })
151
201
  .join('\n\n')
152
202
 
203
+ const criteriaBlock = judgeCriteria && judgeCriteria.trim()
204
+ ? `\n### 🎯 Дополнительные критерии оценки от пользователя:\n${judgeCriteria.trim()}\n`
205
+ : ''
206
+
153
207
  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
208
 
155
209
  Original user prompt:
156
210
  ${userPrompt}
157
-
211
+ ${criteriaBlock}
158
212
  Reference responses from candidate models:
159
213
  ${joined}
160
214
 
@@ -181,9 +235,10 @@ export function parseWinnerIndex(judgeText, defaultIndex = 1) {
181
235
  const idx = parseInt(match[1], 10)
182
236
  if (!isNaN(idx) && idx >= 1) return idx
183
237
  }
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
238
+ const candMatch = /(?:Кандидат|Reference)\s*(\d+)\b/i.exec(judgeText)
239
+ if (candMatch) {
240
+ const idx = parseInt(candMatch[1], 10)
241
+ if (!isNaN(idx) && idx >= 1) return idx
187
242
  }
188
243
  return defaultIndex
189
244
  }
@@ -263,6 +318,13 @@ export async function runReferencesParallel(references, messages, options = {},
263
318
  const text = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
264
319
  const files = extractFileBlocks(text)
265
320
 
321
+ // Estimate tokens & cost
322
+ const rawUsage = res?.usage || {
323
+ inputTokens: Math.round(JSON.stringify(fullMessages).length / 4),
324
+ outputTokens: Math.round(text.length / 4),
325
+ }
326
+ const costInfo = estimateTokenCost(slot, rawUsage)
327
+
266
328
  if (typeof onProgress === 'function') {
267
329
  const fileMsg = files.length > 0 ? ` (создано файлов: ${files.length})` : ''
268
330
  onProgress(`✅ *Кандидат ${i + 1} (${label}) завершил ответ${fileMsg}.*\n`)
@@ -275,6 +337,8 @@ export async function runReferencesParallel(references, messages, options = {},
275
337
  model: slot.model,
276
338
  text: text || '(empty response)',
277
339
  files,
340
+ usage: costInfo,
341
+ costUsd: costInfo.costUsd,
278
342
  ok: true,
279
343
  }
280
344
  } catch (err) {
@@ -289,6 +353,8 @@ export async function runReferencesParallel(references, messages, options = {},
289
353
  model: slot.model,
290
354
  text: `[failed: ${err?.message || String(err)}]`,
291
355
  files: [],
356
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
357
+ costUsd: 0,
292
358
  ok: false,
293
359
  }
294
360
  }
@@ -299,10 +365,10 @@ export async function runReferencesParallel(references, messages, options = {},
299
365
 
300
366
  /**
301
367
  * Runs the full Mixture of Agents pipeline:
302
- * 1. Checks if questions are needed
368
+ * 1. Checks if questions or refinement needed
303
369
  * 2. Parallel candidate proposers write to .moa/candidate-X/
304
- * 3. Aggregator judges and picks winner
305
- * 4. Promotes winner files and cleans up
370
+ * 3. Aggregator judges and picks winner (or bypassed in Fast Mode)
371
+ * 4. Promotes winner files, records history and calculates costs
306
372
  */
307
373
  export async function runMoAPipeline({
308
374
  userPrompt,
@@ -317,15 +383,31 @@ export async function runMoAPipeline({
317
383
  throw new Error('callLlm function is required for runMoAPipeline')
318
384
  }
319
385
 
386
+ const startTime = Date.now()
320
387
  const referenceModels = preset?.reference_models || preset?.referenceModels || []
321
388
  const aggregator = preset?.aggregator || { provider: 'default', model: 'default' }
322
389
  const refTemp = preset?.reference_temperature ?? preset?.referenceTemperature ?? 0.6
323
390
  const aggTemp = preset?.aggregator_temperature ?? preset?.aggregatorTemperature ?? 0.4
324
391
  const maxTokens = preset?.max_tokens ?? preset?.maxTokens ?? 4096
392
+ const judgeCriteria = preset?.judge_criteria || preset?.judgeCriteria || ''
325
393
  const workDir = cwd || process.cwd()
326
394
 
395
+ // Collect project context (#6) and check refinement task (#4)
396
+ const projectCtx = await collectProjectContext(workDir, 16000)
397
+ const isRefinement = isRefinementTask(userPrompt, projectCtx.files)
398
+
399
+ // System prompt selection
400
+ let candidateSystemPrompt = REFERENCE_SYSTEM_PROMPT
401
+ if (isRefinement && projectCtx.files.length > 0) {
402
+ const fileList = projectCtx.files.map((f) => `### File: ${f.relativePath}\n\`\`\`\n${f.content}\n\`\`\``).join('\n\n')
403
+ candidateSystemPrompt = `${REFINEMENT_SYSTEM_PROMPT}\n\n## Existing Project Files:\n${fileList}`
404
+ if (typeof onProgress === 'function') {
405
+ onProgress(`🔄 *[Refinement Mode]: Обнаружен существующий проект (${projectCtx.files.length} файлов). Кандидаты вносят точечные изменения...*\n\n`)
406
+ }
407
+ }
408
+
327
409
  // Phase 1: Check if clarifying questions should be asked
328
- const needsQuestions = !skipQuestions && isBroadPromptRequiringQuestions(userPrompt, messages)
410
+ const needsQuestions = !skipQuestions && !isRefinement && isBroadPromptRequiringQuestions(userPrompt, messages)
329
411
  if (needsQuestions) {
330
412
  if (typeof onProgress === 'function') {
331
413
  onProgress('🔍 *Задача общего характера. Советники формируют ключевые развилки...*\n\n')
@@ -371,15 +453,19 @@ export async function runMoAPipeline({
371
453
  }
372
454
  }
373
455
 
456
+ // FAST MODE: single candidate model bypasses aggregator (#14)
457
+ const isFastMode = referenceModels.length === 1
458
+
374
459
  // Phase 2: Parallel execution & file generation
375
460
  if (typeof onProgress === 'function') {
376
- onProgress(`🚀 *Запускаю параллельную реализацию советниками (${referenceModels.length})...*\n\n`)
461
+ const modeLabel = isFastMode ? '⚡ Fast Mode' : `советниками (${referenceModels.length})`
462
+ onProgress(`🚀 *Запускаю реализацию ${modeLabel}...*\n\n`)
377
463
  }
378
464
 
379
465
  const referenceOutputs = await runReferencesParallel(
380
466
  referenceModels,
381
467
  [...messages, { role: 'user', content: userPrompt }],
382
- { referenceTemperature: refTemp, maxTokens },
468
+ { systemPrompt: candidateSystemPrompt, referenceTemperature: refTemp, maxTokens },
383
469
  callLlm,
384
470
  onProgress,
385
471
  )
@@ -399,35 +485,51 @@ export async function runMoAPipeline({
399
485
  }
400
486
  }
401
487
 
402
- // Phase 3: Aggregator evaluation
488
+ let synthesizedText = ''
489
+ let winningIndex = 1
490
+ let aggUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
403
491
  const aggLabel = slotLabel(aggregator)
404
- if (typeof onProgress === 'function') {
405
- onProgress(`\n⚖️ *Все кандидаты завершили генерацию. Судья (${aggLabel}) оценивает код и файлы...*\n\n`)
406
- }
407
492
 
408
- const synthPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs)
493
+ if (isFastMode) {
494
+ // Fast mode: promote candidate 1 directly without judge
495
+ synthesizedText = referenceOutputs[0]?.text || '(empty fast response)'
496
+ winningIndex = 1
497
+ } else {
498
+ // Phase 3: Aggregator evaluation
499
+ if (typeof onProgress === 'function') {
500
+ onProgress(`\n⚖️ *Все кандидаты завершили генерацию. Судья (${aggLabel}) оценивает код и файлы...*\n\n`)
501
+ }
409
502
 
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')
503
+ const synthPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria)
504
+
505
+ try {
506
+ const aggPromise = callLlm({
507
+ provider: aggregator.provider,
508
+ model: aggregator.model,
509
+ messages: [{ role: 'user', content: synthPrompt }],
510
+ temperature: aggTemp,
511
+ maxTokens,
512
+ })
513
+ const aggTimeout = new Promise((_, reject) =>
514
+ setTimeout(() => reject(new Error(`Timeout after 90s waiting for aggregator ${aggLabel}`)), 90000).unref()
515
+ )
516
+ const res = await Promise.race([aggPromise, aggTimeout])
517
+ synthesizedText = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
518
+
519
+ const rawAggUsage = res?.usage || {
520
+ inputTokens: Math.round(synthPrompt.length / 4),
521
+ outputTokens: Math.round(synthesizedText.length / 4),
522
+ }
523
+ aggUsage = estimateTokenCost(aggregator, rawAggUsage)
524
+ } catch (err) {
525
+ synthesizedText = `[Aggregator error: ${err?.message || String(err)}]\n\nFallback candidate outputs:\n\n` +
526
+ referenceOutputs.map((r, i) => `### ${r.label}\n${r.text}`).join('\n\n')
527
+ }
528
+
529
+ winningIndex = parseWinnerIndex(synthesizedText, 1)
427
530
  }
428
531
 
429
532
  // Phase 4: Promote winner files and cleanup
430
- const winningIndex = parseWinnerIndex(synthesizedText, 1)
431
533
  let promotedFiles = []
432
534
  try {
433
535
  promotedFiles = await promoteCandidateWorkspace(workDir, winningIndex)
@@ -448,7 +550,8 @@ export async function runMoAPipeline({
448
550
  const lcRes = await fetch(`http://127.0.0.1:${port}/dsh-live-canvas/api/open-file`, {
449
551
  method: 'POST',
450
552
  headers: { 'Content-Type': 'application/json' },
451
- body: JSON.stringify({ filePath: previewCandidate })
553
+ body: JSON.stringify({ filePath: previewCandidate }),
554
+ signal: AbortSignal.timeout(500),
452
555
  })
453
556
  if (lcRes.ok) {
454
557
  const lcData = await lcRes.json()
@@ -470,21 +573,55 @@ export async function runMoAPipeline({
470
573
  }
471
574
  }
472
575
 
576
+ // Calculate totals
577
+ const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + aggUsage.totalTokens
578
+ const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + aggUsage.costUsd).toFixed(5))
579
+ const durationMs = Date.now() - startTime
580
+
581
+ const winningRef = referenceOutputs[winningIndex - 1]
582
+ const winnerModel = winningRef?.label || slotLabel(referenceModels[0])
583
+
584
+ // Record run to history (#9, #10, #11)
585
+ try {
586
+ recordMoaRun({
587
+ prompt: userPrompt,
588
+ preset: preset?.name || 'default',
589
+ isRefinement,
590
+ candidates: referenceOutputs,
591
+ aggregator: isFastMode ? null : { provider: aggregator.provider, model: aggregator.model, usage: aggUsage, costUsd: aggUsage.costUsd },
592
+ winnerIndex: winningIndex,
593
+ winnerModel,
594
+ promotedFiles,
595
+ totalTokens,
596
+ totalCostUsd,
597
+ durationMs,
598
+ })
599
+ } catch (histErr) {
600
+ console.warn('[dsh-moa] Failed to record run in history:', histErr)
601
+ }
602
+
473
603
  return {
474
604
  kind: 'synthesis',
475
605
  content: synthesizedText,
476
- aggregator: aggLabel,
606
+ aggregator: isFastMode ? 'Fast Mode (Direct)' : aggLabel,
477
607
  references: referenceOutputs,
478
608
  presetName: preset?.name || 'default',
609
+ isRefinement,
610
+ isFastMode,
479
611
  winningIndex,
612
+ winnerModel,
480
613
  promotedFiles,
481
614
  liveCanvas,
615
+ usage: {
616
+ totalTokens,
617
+ totalCostUsd,
618
+ candidates: referenceOutputs.map(r => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
619
+ aggregator: aggUsage,
620
+ },
621
+ durationMs,
482
622
  }
483
623
  }
484
624
 
485
- /**
486
- * Formats full MoA output showing candidate outputs, judge synthesis, and promoted files.
487
- */
488
625
  /**
489
626
  * Replaces large code blocks with concise file/code summaries to avoid token waste and huge chat dumps.
490
627
  */
@@ -492,7 +629,6 @@ export function stripOrSummarizeCode(text) {
492
629
  if (!text || typeof text !== 'string') return ''
493
630
  return text.replace(/```([a-zA-Z0-9_\-\.\/]*)\s*([\w\.\/\-]+\.[a-zA-Z0-9]+)?\n([\s\S]*?)```/g, (match, lang, fileTag, code) => {
494
631
  const lines = code.trim().split('\n')
495
- // Keep very short commands (<= 3 lines) that are not HTML/JSX/source files
496
632
  if (lines.length <= 3 && !/html|jsx|tsx|vue|svelte|css|js|ts/i.test(lang)) {
497
633
  return match
498
634
  }
@@ -515,9 +651,14 @@ export function formatMoAResponse({ moaResult, presetName }) {
515
651
  return parts.join('\n')
516
652
  }
517
653
 
518
- parts.push(`## 🧠 Mixture of Agents (Пресет: ${pName} | Судья: ${judge})`)
654
+ const modeBadge = moaResult?.isFastMode ? '⚡ Fast Mode' : `Судья: ${judge}`
655
+ parts.push(`## 🧠 Mixture of Agents (Пресет: ${pName} | ${modeBadge})`)
519
656
  parts.push('')
520
657
 
658
+ if (moaResult?.isRefinement) {
659
+ parts.push('> 🔄 **Режим**: Итеративная доработка проекта (Refinement)')
660
+ }
661
+
521
662
  const hasPromoted = moaResult?.promotedFiles && moaResult.promotedFiles.length > 0
522
663
  if (hasPromoted) {
523
664
  parts.push(`> 📦 **Созданы файлы в проекте**: \`${moaResult.promotedFiles.join('`, `')}\``)
@@ -527,25 +668,35 @@ export function formatMoAResponse({ moaResult, presetName }) {
527
668
  parts.push(`> 🎨 **Live Canvas**: [🚀 Открыть ${moaResult.liveCanvas.title || 'превью'} в Live Canvas](${moaResult.liveCanvas.previewUrl}) | [↗ Открыть в новой вкладке](${moaResult.liveCanvas.previewUrl})`)
528
669
  }
529
670
 
530
- if (hasPromoted || moaResult?.liveCanvas) {
531
- parts.push('')
671
+ // Cost tracking card (#10)
672
+ if (moaResult?.usage) {
673
+ const u = moaResult.usage
674
+ const costStr = u.totalCostUsd > 0 ? `~\$${u.totalCostUsd.toFixed(4)}` : 'Бесплатно'
675
+ const tokStr = u.totalTokens >= 1000 ? `${(u.totalTokens / 1000).toFixed(1)}k` : `${u.totalTokens}`
676
+ parts.push(`> 💰 **Стоимость запуска**: ${costStr} (всего ${tokStr} токенов)`)
532
677
  }
533
678
 
534
- parts.push(`### ⚖️ Вердикт судьи и итоговое решение (Синтез: ${judge})`)
535
- parts.push('')
536
- const cleanJudgeContent = hasPromoted
537
- ? stripOrSummarizeCode(moaResult?.content || '')
538
- : (moaResult?.content || '(нет ответа)')
539
- parts.push(cleanJudgeContent)
540
679
  parts.push('')
541
680
 
681
+ if (!moaResult?.isFastMode) {
682
+ parts.push(`### ⚖️ Вердикт судьи и итоговое решение (Синтез: ${judge})`)
683
+ parts.push('')
684
+ const cleanJudgeContent = hasPromoted
685
+ ? stripOrSummarizeCode(moaResult?.content || '')
686
+ : (moaResult?.content || '(нет ответа)')
687
+ parts.push(cleanJudgeContent)
688
+ parts.push('')
689
+ }
690
+
542
691
  if (Array.isArray(refs) && refs.length > 0) {
543
- parts.push(`### 👥 Ответы моделей-советников (${refs.length}):`)
692
+ const title = moaResult?.isFastMode ? '### 🚀 Результат генерации кандидата:' : `### 👥 Ответы моделей-советников (${refs.length}):`
693
+ parts.push(title)
544
694
  parts.push('')
545
695
  refs.forEach((ref, i) => {
546
696
  const statusIcon = ref.ok ? '✅' : '⚠️'
547
697
  const fileBadge = ref.files?.length ? ` (${ref.files.length} файл(ов))` : ''
548
- parts.push(`#### ${statusIcon} Модель ${i + 1}: ${ref.label}${fileBadge}`)
698
+ const costBadge = ref.costUsd > 0 ? ` [~\$${ref.costUsd.toFixed(4)}]` : ''
699
+ parts.push(`#### ${statusIcon} Модель ${i + 1}: ${ref.label}${fileBadge}${costBadge}`)
549
700
  parts.push('')
550
701
  parts.push(stripOrSummarizeCode(ref.text))
551
702
  parts.push('')
@@ -618,7 +769,7 @@ export class MoaRunnerAdapter {
618
769
  const startTime = Date.now()
619
770
  let lastYieldTime = Date.now()
620
771
 
621
- // Interval to yield progressive updates while pipeline runs
772
+ // Yield progressive updates while pipeline runs
622
773
  while (true) {
623
774
  if (signal?.aborted) return
624
775
 
@@ -659,7 +810,7 @@ export class MoaRunnerAdapter {
659
810
 
660
811
  yield { type: 'text-delta', index: 0, text: '\n---\n\n' + formatted }
661
812
  yield { type: 'block-end', index: 0, block: { type: 'text', text: formatted } }
662
- yield { type: 'usage', usage: { inputTokens: 100, outputTokens: formatted.length } }
813
+ yield { type: 'usage', usage: { inputTokens: moaResult?.usage?.totalTokens || 100, outputTokens: formatted.length } }
663
814
  yield { type: 'finish', reason: { kind: 'stop' } }
664
815
  } catch (err) {
665
816
  if (signal?.aborted) return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Mixture of Agents (MoA) plugin for DeepSeek Harness with /moa slash command",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",