@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/history.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * MoA Run History & Analytics Module
3
3
  * Records every MoA pipeline execution, tracks model win rates and costs,
4
- * and provides history search and run replay capabilities.
4
+ * and provides history search, file rotation, and non-blocking I/O.
5
5
  */
6
6
 
7
7
  import fs from 'node:fs'
@@ -12,6 +12,9 @@ import crypto from 'node:crypto'
12
12
  const DEFAULT_HISTORY_DIR = path.join(os.homedir(), '.dsh')
13
13
  const DEFAULT_HISTORY_FILE = path.join(DEFAULT_HISTORY_DIR, 'moa-history.jsonl')
14
14
 
15
+ /** Max history file size before rotation (10 MB) */
16
+ export const MAX_HISTORY_BYTES = 10 * 1024 * 1024
17
+
15
18
  export function getHistoryFilePath(customDir) {
16
19
  if (customDir) {
17
20
  return path.join(customDir, '.moa-history.jsonl')
@@ -20,7 +23,67 @@ export function getHistoryFilePath(customDir) {
20
23
  }
21
24
 
22
25
  /**
23
- * Appends a completed MoA run record to the history file.
26
+ * Checks if the history file exceeds the size threshold and rotates it.
27
+ * Moves current file to `${filePath}.1`, removing any older `.1` backup.
28
+ */
29
+ export function rotateHistoryFileIfNeeded(filePath = DEFAULT_HISTORY_FILE, maxBytes = MAX_HISTORY_BYTES) {
30
+ try {
31
+ if (!fs.existsSync(filePath)) return false
32
+ const stat = fs.statSync(filePath)
33
+ if (stat.size >= maxBytes) {
34
+ const backupPath = `${filePath}.1`
35
+ if (fs.existsSync(backupPath)) {
36
+ try { fs.unlinkSync(backupPath) } catch {}
37
+ }
38
+ fs.renameSync(filePath, backupPath)
39
+ return true
40
+ }
41
+ } catch (err) {
42
+ console.warn('[dsh-moa] History rotation warning:', err?.message || err)
43
+ }
44
+ return false
45
+ }
46
+
47
+ /**
48
+ * Prepares a normalized history record entry.
49
+ */
50
+ function normalizeRunRecord(record) {
51
+ return {
52
+ id: record.id || crypto.randomUUID(),
53
+ timestamp: record.timestamp || new Date().toISOString(),
54
+ prompt: record.prompt || '',
55
+ preset: record.preset || 'default',
56
+ isRefinement: Boolean(record.isRefinement),
57
+ isFastMode: Boolean(record.isFastMode),
58
+ candidates: Array.isArray(record.candidates)
59
+ ? record.candidates.map((c) => ({
60
+ provider: c.provider || '',
61
+ model: c.model || '',
62
+ filesCount: Array.isArray(c.files) ? c.files.length : (c.filesCount || 0),
63
+ usage: c.usage || { inputTokens: 0, outputTokens: 0 },
64
+ costUsd: Number(c.costUsd || 0),
65
+ }))
66
+ : [],
67
+ aggregator: record.aggregator
68
+ ? {
69
+ provider: record.aggregator.provider || '',
70
+ model: record.aggregator.model || '',
71
+ usage: record.aggregator.usage || { inputTokens: 0, outputTokens: 0 },
72
+ costUsd: Number(record.aggregator.costUsd || 0),
73
+ }
74
+ : null,
75
+ winnerIndex: typeof record.winnerIndex === 'number' ? record.winnerIndex : -1,
76
+ winnerModel: record.winnerModel || '',
77
+ promotedFiles: Array.isArray(record.promotedFiles) ? record.promotedFiles : [],
78
+ totalTokens: Number(record.totalTokens || 0),
79
+ totalCostUsd: Number(record.totalCostUsd || 0),
80
+ durationMs: Number(record.durationMs || 0),
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Appends a completed MoA run record to the history file synchronously (preserves sync API).
86
+ * Performs automatic size-based rotation.
24
87
  */
25
88
  export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
26
89
  try {
@@ -29,37 +92,9 @@ export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
29
92
  fs.mkdirSync(dir, { recursive: true })
30
93
  }
31
94
 
32
- const entry = {
33
- id: record.id || crypto.randomUUID(),
34
- timestamp: record.timestamp || new Date().toISOString(),
35
- prompt: record.prompt || '',
36
- preset: record.preset || 'default',
37
- isRefinement: Boolean(record.isRefinement),
38
- candidates: Array.isArray(record.candidates)
39
- ? record.candidates.map((c) => ({
40
- provider: c.provider || '',
41
- model: c.model || '',
42
- filesCount: Array.isArray(c.files) ? c.files.length : (c.filesCount || 0),
43
- usage: c.usage || { inputTokens: 0, outputTokens: 0 },
44
- costUsd: Number(c.costUsd || 0),
45
- }))
46
- : [],
47
- aggregator: record.aggregator
48
- ? {
49
- provider: record.aggregator.provider || '',
50
- model: record.aggregator.model || '',
51
- usage: record.aggregator.usage || { inputTokens: 0, outputTokens: 0 },
52
- costUsd: Number(record.aggregator.costUsd || 0),
53
- }
54
- : null,
55
- winnerIndex: typeof record.winnerIndex === 'number' ? record.winnerIndex : -1,
56
- winnerModel: record.winnerModel || '',
57
- promotedFiles: Array.isArray(record.promotedFiles) ? record.promotedFiles : [],
58
- totalTokens: Number(record.totalTokens || 0),
59
- totalCostUsd: Number(record.totalCostUsd || 0),
60
- durationMs: Number(record.durationMs || 0),
61
- }
95
+ rotateHistoryFileIfNeeded(filePath)
62
96
 
97
+ const entry = normalizeRunRecord(record)
63
98
  const line = JSON.stringify(entry) + '\n'
64
99
  fs.appendFileSync(filePath, line, 'utf8')
65
100
  return entry
@@ -69,6 +104,26 @@ export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
69
104
  }
70
105
  }
71
106
 
107
+ /**
108
+ * Asynchronously appends a completed MoA run record without blocking the event loop.
109
+ */
110
+ export async function recordMoaRunAsync(record, filePath = DEFAULT_HISTORY_FILE) {
111
+ try {
112
+ const dir = path.dirname(filePath)
113
+ await fs.promises.mkdir(dir, { recursive: true }).catch(() => {})
114
+
115
+ rotateHistoryFileIfNeeded(filePath)
116
+
117
+ const entry = normalizeRunRecord(record)
118
+ const line = JSON.stringify(entry) + '\n'
119
+ await fs.promises.appendFile(filePath, line, 'utf8')
120
+ return entry
121
+ } catch (err) {
122
+ console.warn('[dsh-moa] Failed to async-record run to history:', err)
123
+ return null
124
+ }
125
+ }
126
+
72
127
  /**
73
128
  * Reads history runs with pagination (latest first).
74
129
  */
@@ -144,7 +199,7 @@ export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE) {
144
199
  st.totalTokens += inTok + outTok
145
200
  st.totalCostUsd += Number(cand.costUsd || 0)
146
201
 
147
- if (winner && (winner === key || winner === cand.model || winner.includes(cand.model))) {
202
+ if (winner && cand.model && (winner === key || winner === cand.model)) {
148
203
  st.wins += 1
149
204
  }
150
205
  }
package/lib/index.js CHANGED
@@ -6,7 +6,7 @@ import { refreshCatalogInBackground } from './pricing.js'
6
6
  * 1. User issues /moa <prompt>
7
7
  * 2. Parallel candidate proposers write to isolated workspaces (.moa/candidate-X/)
8
8
  * 3. Aggregator evaluates and synthesizes the optimal solution
9
- * 4. Winner candidate files are promoted to the project root and previewed in Live Canvas
9
+ * 4. Winner candidate files are promoted to the project root
10
10
  * 5. Native cost tracking, history logging and leaderboard analytics
11
11
  */
12
12
 
@@ -25,6 +25,8 @@ import {
25
25
  getMoaRunById,
26
26
  } from './history.js'
27
27
 
28
+ import { createLiveCanvasClient } from './live-canvas.js'
29
+
28
30
  export const name = 'dsh-moa'
29
31
  export const inject = ['webServer', 'llm', 'settings', 'sessions', 'tools']
30
32
 
@@ -45,13 +47,19 @@ export const PresetSchema = z.object({
45
47
  name: z.string(),
46
48
  enabled: z.boolean().default(true),
47
49
  ask_clarifying_questions: z.boolean().default(true),
50
+ curator_synthesis: z.boolean().default(false),
51
+ stream_aggregator: z.boolean().default(true),
52
+ quorum_enabled: z.boolean().default(false),
53
+ grace_period_sec: z.number().default(10),
48
54
  reference_models: z.array(ModelSlotSchema).default([]),
49
55
  aggregator: ModelSlotSchema.default({ provider: 'codex', model: 'gpt-5.6-sol' }),
56
+ aggregator_fallbacks: z.array(ModelSlotSchema).default([]),
50
57
  reference_temperature: z.number().default(0.6),
51
58
  aggregator_temperature: z.number().default(0.4),
59
+ reference_timeout_sec: z.number().default(60),
60
+ aggregator_timeout_sec: z.number().default(180),
52
61
  max_tokens: z.number().default(4096),
53
62
  judge_criteria: z.string().default(''),
54
- judge_mode: z.string().default('auto'),
55
63
  })
56
64
 
57
65
  export const Config = z.object({
@@ -72,7 +80,6 @@ export const Config = z.object({
72
80
  aggregator_temperature: 0.4,
73
81
  max_tokens: 4096,
74
82
  judge_criteria: '',
75
- judge_mode: 'auto',
76
83
  },
77
84
  {
78
85
  name: 'fast',
@@ -86,7 +93,6 @@ export const Config = z.object({
86
93
  aggregator_temperature: 0.2,
87
94
  max_tokens: 4096,
88
95
  judge_criteria: '',
89
- judge_mode: 'auto',
90
96
  },
91
97
  {
92
98
  name: 'deep-reasoning',
@@ -100,8 +106,7 @@ export const Config = z.object({
100
106
  reference_temperature: 0.6,
101
107
  aggregator_temperature: 0.2,
102
108
  max_tokens: 8192,
103
- judge_criteria: 'Приоритет: математическая строгость и отсутствие галлюцинаций',
104
- judge_mode: 'auto',
109
+ judge_criteria: '',
105
110
  },
106
111
  ]),
107
112
  })
@@ -153,7 +158,7 @@ export function apply(ctx, config) {
153
158
  /**
154
159
  * Unified LLM dispatch function using ctx.llm.prepareCall / stream
155
160
  */
156
- const callLlm = async ({ provider, model, messages, temperature = 0.6, maxTokens = 4096 }) => {
161
+ const callLlm = async ({ provider, model, messages, temperature = 0.6, maxTokens = 4096, timeoutMs, onStreamDelta }) => {
157
162
  if (!ctx.llm) {
158
163
  throw new Error('ctx.llm is not available in cordis context')
159
164
  }
@@ -177,8 +182,9 @@ export function apply(ctx, config) {
177
182
  throw new Error(`LLM provider/model ${provider}:${model} could not be prepared`)
178
183
  }
179
184
 
185
+ const effectiveTimeoutMs = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : 120000
180
186
  const abortCtrl = new AbortController()
181
- const timeoutId = setTimeout(() => abortCtrl.abort(new Error('LLM call timeout')), 120000)
187
+ const timeoutId = setTimeout(() => abortCtrl.abort(new Error(`LLM call timeout after ${Math.round(effectiveTimeoutMs / 1000)}s`)), effectiveTimeoutMs)
182
188
  timeoutId.unref?.()
183
189
 
184
190
  try {
@@ -195,6 +201,9 @@ export function apply(ctx, config) {
195
201
  for await (const chunk of stream) {
196
202
  if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
197
203
  fullText += chunk.text
204
+ if (typeof onStreamDelta === 'function') {
205
+ onStreamDelta(chunk.text)
206
+ }
198
207
  } else if (chunk.type === 'block-end' && chunk.block?.text) {
199
208
  if (!fullText) fullText = chunk.block.text
200
209
  } else if (chunk.type === 'usage' && chunk.usage) {
@@ -217,6 +226,11 @@ export function apply(ctx, config) {
217
226
  }
218
227
  }
219
228
 
229
+ // Optional Live Canvas preview client: the harness port is resolved per
230
+ // call and any failure degrades to "no preview link" (live-canvas is not
231
+ // required to be installed).
232
+ const liveCanvas = createLiveCanvasClient({ getPort: () => ctx.webServer?.port })
233
+
220
234
  // Route: /dsh-moa/status
221
235
  ctx.effect(() => {
222
236
  return ctx.webServer.register({
@@ -342,23 +356,35 @@ export function apply(ctx, config) {
342
356
  const raw = await readBody(req)
343
357
  const payload = JSON.parse(raw)
344
358
  const newConfig = {
345
- enabled: cfg.enabled !== false,
359
+ enabled: typeof payload.enabled === 'boolean' ? payload.enabled : cfg.enabled !== false,
346
360
  default_preset: payload.default_preset || cfg.default_preset || 'default',
347
361
  presets: Array.isArray(payload.presets) ? payload.presets : (cfg.presets || []),
348
362
  }
349
363
 
364
+ // Validate the merged payload through the plugin schema before it
365
+ // reaches the settings storage: malformed payloads must be
366
+ // rejected with 400 instead of being persisted.
367
+ let validated
368
+ try {
369
+ validated = Config(structuredClone(newConfig))
370
+ } catch (verr) {
371
+ writeJson(res, 400, { ok: false, error: 'Invalid presets payload: ' + (verr?.message || String(verr)) })
372
+ return
373
+ }
374
+
350
375
  // Persist via Cordis Settings API
351
376
  if (settingsScope && typeof settingsScope.replace === 'function') {
352
- await settingsScope.replace(newConfig)
377
+ await settingsScope.replace(validated)
353
378
  } else {
354
379
  // In-memory fallback if settings service is unavailable
355
- getConfig = () => newConfig
380
+ getConfig = () => validated
356
381
  }
357
382
 
358
383
  writeJson(res, 200, {
359
384
  ok: true,
360
- defaultPreset: newConfig.default_preset,
361
- presets: newConfig.presets,
385
+ enabled: validated.enabled !== false,
386
+ defaultPreset: validated.default_preset,
387
+ presets: validated.presets,
362
388
  })
363
389
  } catch (err) {
364
390
  writeJson(res, 400, { ok: false, error: err?.message || String(err) })
@@ -414,6 +440,29 @@ export function apply(ctx, config) {
414
440
  })
415
441
  }, 'dsh-moa: leaderboard route')
416
442
 
443
+ // Route: /dsh-moa/runs/<id> (run replay by id)
444
+ ctx.effect(() => {
445
+ return ctx.webServer.register({
446
+ kind: 'prefix',
447
+ path: '/dsh-moa/runs/',
448
+ handler: (req, res) => {
449
+ if (req.method !== 'GET') {
450
+ writeJson(res, 405, { ok: false, error: 'GET required' })
451
+ return
452
+ }
453
+ const urlPath = req.url.split('?')[0]
454
+ const parts = urlPath.split('/').filter(Boolean)
455
+ const runId = parts[2] || ''
456
+ const run = runId ? getMoaRunById(runId) : null
457
+ if (!run) {
458
+ writeJson(res, 404, { ok: false, error: 'Run not found' })
459
+ return
460
+ }
461
+ writeJson(res, 200, { ok: true, run })
462
+ },
463
+ })
464
+ }, 'dsh-moa: run by id route')
465
+
417
466
  // Route: /dsh-moa/run
418
467
  ctx.effect(() => {
419
468
  return ctx.webServer.register({
@@ -435,6 +484,10 @@ export function apply(ctx, config) {
435
484
  }
436
485
 
437
486
  const cfg = live()
487
+ if (cfg.enabled === false) {
488
+ writeJson(res, 400, { ok: false, error: 'MoA is disabled in settings' })
489
+ return
490
+ }
438
491
  const presetName = payload.preset || cfg.default_preset || 'default'
439
492
  const preset = (cfg.presets || []).find((p) => p.name === presetName) || cfg.presets?.[0]
440
493
 
@@ -444,6 +497,8 @@ export function apply(ctx, config) {
444
497
  preset,
445
498
  callLlm,
446
499
  cwd: payload.cwd || process.cwd(),
500
+ prices: cfg.prices || {},
501
+ liveCanvas,
447
502
  })
448
503
 
449
504
  writeJson(res, 200, { ok: true, ...result })
@@ -511,6 +566,8 @@ export function apply(ctx, config) {
511
566
  cwd: sessCwd,
512
567
  aggregator: { provider: aggProvider, model: aggModel },
513
568
  callLlm,
569
+ prices: cfg.prices || {},
570
+ liveCanvas,
514
571
  }
515
572
 
516
573
  if (event.signal) {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Optional Live Canvas preview client (@goodandready/dsh-live-canvas).
3
+ *
4
+ * The plugin posts the promoted HTML to live-canvas's own REST contract
5
+ * (POST /dsh-live-canvas/api/preview, served by the same harness webServer),
6
+ * so no cross-plugin service coupling or module imports are needed. Any
7
+ * failure — live-canvas not installed, endpoint missing, timeout, malformed
8
+ * response — degrades to null and the MoA answer simply carries no preview
9
+ * link, without errors in the harness log.
10
+ */
11
+ export function createLiveCanvasClient({ getPort, fetchImpl = globalThis.fetch, timeoutMs = 3000 } = {}) {
12
+ if (typeof fetchImpl !== 'function') return null
13
+ return {
14
+ async createPreviewFromContent({ content, title, filePath } = {}) {
15
+ if (!content || typeof content !== 'string') return null
16
+ const port = typeof getPort === 'function' ? getPort() : getPort
17
+ if (!port) return null
18
+ try {
19
+ const res = await fetchImpl(`http://127.0.0.1:${port}/dsh-live-canvas/api/preview`, {
20
+ method: 'POST',
21
+ headers: { 'Content-Type': 'application/json' },
22
+ body: JSON.stringify({ content, title, filePath }),
23
+ signal: AbortSignal.timeout(timeoutMs),
24
+ })
25
+ if (!res.ok) return null
26
+ const data = await res.json()
27
+ if (!data || data.success !== true || !data.canvasId || !data.previewUrl) return null
28
+ return { canvasId: data.canvasId, previewUrl: data.previewUrl, title: title || 'Live Preview' }
29
+ } catch {
30
+ return null
31
+ }
32
+ },
33
+ }
34
+ }
@@ -0,0 +1,181 @@
1
+ // lib/moa-parser.js
2
+ // Parsers for model outputs, winner selection, commands, and code blocks for @goodandready/dsh-moa.
3
+
4
+ /**
5
+ * Extracts candidate winner index from judge output.
6
+ * Looks for machine marker WINNER_CANDIDATE_INDEX: N first, then conversational fallbacks.
7
+ */
8
+ export function parseWinnerIndex(judgeText, defaultIndex = 1, candidateCount = Infinity) {
9
+ if (!judgeText || typeof judgeText !== 'string') return defaultIndex
10
+ const inRange = (idx) => !isNaN(idx) && idx >= 1 && idx <= candidateCount
11
+ const match = /WINNER_CANDIDATE_INDEX:\s*(\d+)/i.exec(judgeText)
12
+ if (match) {
13
+ const idx = parseInt(match[1], 10)
14
+ if (inRange(idx)) return idx
15
+ }
16
+ const candMatch = /(?:Кандидат|Candidate|Reference)\s*(\d+)\b/i.exec(judgeText)
17
+ if (candMatch) {
18
+ const idx = parseInt(candMatch[1], 10)
19
+ if (inRange(idx)) return idx
20
+ }
21
+ return defaultIndex
22
+ }
23
+
24
+ /**
25
+ * Parses the recommended assembler model index and label from curator output.
26
+ */
27
+ export function parseRecommendedAssembler(judgeText, defaultIndex = 1, candidateCount = Infinity) {
28
+ if (!judgeText || typeof judgeText !== 'string') return { index: defaultIndex, label: '' }
29
+ const inRange = (idx) => !isNaN(idx) && idx >= 1 && idx <= candidateCount
30
+ const match = /RECOMMENDED_ASSEMBLER:\s*(\d+)(?:\s*\(([^)]+)\))?/i.exec(judgeText)
31
+ if (match) {
32
+ const idx = parseInt(match[1], 10)
33
+ if (inRange(idx)) {
34
+ return { index: idx, label: match[2]?.trim() || '' }
35
+ }
36
+ }
37
+ return { index: defaultIndex, label: '' }
38
+ }
39
+
40
+ /**
41
+ * Parses a `/moa [preset] <prompt>` command invocation.
42
+ */
43
+ export function parseMoACommand(text, presets = []) {
44
+ if (typeof text !== 'string' || !text.startsWith('/moa')) {
45
+ return null
46
+ }
47
+
48
+ const remainder = text.slice(4).trim()
49
+ if (!remainder) {
50
+ return {
51
+ presetName: 'default',
52
+ prompt: '',
53
+ }
54
+ }
55
+
56
+ const matchPreset = /^--preset(?:=|\s+)([a-zA-Z0-9_-]+)\s*(.*)/s.exec(remainder)
57
+ if (matchPreset) {
58
+ return {
59
+ presetName: matchPreset[1],
60
+ prompt: matchPreset[2] || '',
61
+ }
62
+ }
63
+
64
+ const parts = remainder.split(/\s+/)
65
+ const firstWord = parts[0]
66
+ if (Array.isArray(presets)) {
67
+ const matched = presets.find((p) => p.name === firstWord)
68
+ if (matched) {
69
+ return {
70
+ presetName: matched.name,
71
+ prompt: parts.slice(1).join(' ').trim(),
72
+ }
73
+ }
74
+ }
75
+
76
+ return {
77
+ presetName: 'default',
78
+ prompt: remainder,
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Replaces large code blocks with concise file/code summaries to avoid token waste and huge chat dumps.
84
+ */
85
+ export function stripOrSummarizeCode(text) {
86
+ if (!text || typeof text !== 'string') return ''
87
+ return text.replace(/```([a-zA-Z0-9_\-\.\/]*)\s*([\w\.\/\-]+\.[a-zA-Z0-9]+)?\n([\s\S]*?)```/g, (match, lang, fileTag, code) => {
88
+ const lines = code.trim().split('\n')
89
+ if (lines.length <= 3 && !/html|jsx|tsx|vue|svelte|css|js|ts/i.test(lang)) {
90
+ return match
91
+ }
92
+ const fileHint = fileTag || (code.match(/^\s*(?:\/\/|#|<!--|\/\*)\s*(?:file|filepath|path):\s*([^\s*]+)/im)?.[1])
93
+ const label = fileHint ? `file \`${fileHint}\`` : (lang ? `code \`${lang}\`` : 'code')
94
+ return `\n> 📄 *[${label} - ${lines.length} lines saved to disk]*\n`
95
+ })
96
+ }
97
+
98
+ /**
99
+ * Formats user-facing markdown response for synthesis, questions, and failure kinds.
100
+ */
101
+ export function formatMoAResponse({ moaResult, presetName }) {
102
+ const parts = []
103
+ const pName = presetName || moaResult?.presetName || 'default'
104
+ const judge = moaResult?.aggregator || 'unknown'
105
+ const refs = moaResult?.references || []
106
+
107
+ if (moaResult?.kind === 'questions') {
108
+ parts.push('## 🧠 Mixture of Agents — Requirements Clarification')
109
+ parts.push(`*Judge (${judge}) and the advisors analyzed the task:*\n`)
110
+ parts.push(moaResult.content)
111
+ return parts.join('\n')
112
+ }
113
+
114
+ if (moaResult?.kind === 'failure') {
115
+ parts.push('## ⚠️ Mixture of Agents — Execution Failed')
116
+ parts.push(moaResult.content)
117
+ return parts.join('\n')
118
+ }
119
+
120
+ const modeBadge = moaResult?.isFastMode
121
+ ? '⚡ Fast Mode'
122
+ : (moaResult?.isCuratorSynthesis ? `🧠 Curator: ${judge}` : `Judge: ${judge}`)
123
+ parts.push(`## 🧠 Mixture of Agents (Preset: ${pName} | ${modeBadge})`)
124
+ parts.push('')
125
+
126
+ if (moaResult?.isRefinement) {
127
+ parts.push('> 🔄 **Mode**: Iterative project refinement (Refinement)')
128
+ }
129
+
130
+ const hasPromoted = moaResult?.promotedFiles && moaResult.promotedFiles.length > 0
131
+ if (hasPromoted) {
132
+ parts.push(`> 📦 **Files created in the project**: \`${moaResult.promotedFiles.join('`, `')}\``)
133
+ }
134
+
135
+ if (moaResult?.recommendedAssembler?.label) {
136
+ parts.push(`> 🎯 **Recommended Master Assembler**: Candidate ${moaResult.recommendedAssembler.index} (\`${moaResult.recommendedAssembler.label}\`)`)
137
+ }
138
+
139
+ if (moaResult?.liveCanvas?.previewUrl) {
140
+ parts.push(`> 🎨 **Live Canvas**: [🚀 Открыть ${moaResult.liveCanvas.title || 'превью'} в Live Canvas](${moaResult.liveCanvas.previewUrl}) | [↗ Открыть в новой вкладке](${moaResult.liveCanvas.previewUrl})`)
141
+ }
142
+
143
+ // Cost tracking card
144
+ if (moaResult?.usage) {
145
+ const u = moaResult.usage
146
+ const costStr = u.totalCostUsd > 0 ? `~\$${u.totalCostUsd.toFixed(4)}` : 'Free'
147
+ const tokStr = u.totalTokens >= 1000 ? `${(u.totalTokens / 1000).toFixed(1)}k` : `${u.totalTokens}`
148
+ parts.push(`> 💰 **Run cost**: ${costStr} (${tokStr} tokens total)`)
149
+ }
150
+
151
+ parts.push('')
152
+
153
+ if (!moaResult?.isFastMode) {
154
+ parts.push(`### ⚖️ Judge verdict and final synthesis (Synthesis: ${judge})`)
155
+ parts.push('')
156
+ const cleanJudgeContent = hasPromoted
157
+ ? stripOrSummarizeCode(moaResult?.content || '')
158
+ : (moaResult?.content || '(нет ответа)')
159
+ parts.push(cleanJudgeContent)
160
+ parts.push('')
161
+ }
162
+
163
+ if (Array.isArray(refs) && refs.length > 0) {
164
+ const title = moaResult?.isFastMode ? '### 🚀 Candidate generation result:' : `### 👥 Advisor responses (${refs.length}):`
165
+ parts.push(title)
166
+ parts.push('')
167
+ refs.forEach((ref, i) => {
168
+ const statusIcon = ref.ok ? '✅' : '⚠️'
169
+ const fileBadge = ref.files?.length ? ` (${ref.files.length} файл(ов))` : ''
170
+ const costBadge = ref.costUsd > 0 ? ` [~\$${ref.costUsd.toFixed(4)}]` : ''
171
+ parts.push(`#### ${statusIcon} Model ${i + 1}: ${ref.label}${fileBadge}${costBadge}`)
172
+ parts.push('')
173
+ parts.push(stripOrSummarizeCode(ref.text))
174
+ parts.push('')
175
+ parts.push('---')
176
+ parts.push('')
177
+ })
178
+ }
179
+
180
+ return parts.join('\n')
181
+ }