@goodandready/dsh-moa 0.2.41 → 0.2.43

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.
@@ -78,7 +78,17 @@ export function resolveCandidateFolder(baseDir, candidateTarget, options = {}) {
78
78
  const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
79
79
  const rawRunId = typeof options === 'string' ? options : options?.runId
80
80
  const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
81
- const moaRoot = path.join(baseDir, '.moa')
81
+ const moaPath = path.resolve(baseDir, '.moa')
82
+ try {
83
+ const lst = fsSync.lstatSync(moaPath)
84
+ if (lst.isSymbolicLink()) {
85
+ const realMoa = fsSync.realpathSync(moaPath), realBase = fsSync.realpathSync(path.resolve(baseDir))
86
+ if (realMoa !== realBase && !realMoa.startsWith(realBase + path.sep)) fsSync.unlinkSync(moaPath)
87
+ }
88
+ } catch (err) {
89
+ if (err?.code !== 'ENOENT') throw err
90
+ }
91
+ const moaRoot = assertPathContained(baseDir, '.moa')
82
92
  const runRoot = safeRunId ? assertPathContained(moaRoot, safeRunId) : moaRoot
83
93
  return { moaRoot, runRoot, safeRunId, sub, candidateDir: assertPathContained(runRoot, sub) }
84
94
  }
@@ -97,18 +107,16 @@ export function extractFileBlocks(text) {
97
107
  }
98
108
  }
99
109
 
100
- const fenceRegex = /```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi
101
- let match
102
- while ((match = fenceRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
103
-
104
- const commentRegex = /(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi
105
- while ((match = commentRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
106
-
107
- const headerRegex = /#{1,4}\s+(?:File|Path):\s*([^\s\n\r]+)[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi
108
- while ((match = headerRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
109
-
110
- const inlineRegex = /```[\w-]*[\r\n]+(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+([\s\S]*?)```/gi
111
- while ((match = inlineRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
110
+ const patterns = [
111
+ /```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi,
112
+ /(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
113
+ /#{1,4}\s+(?:File|Path):\s*([^\s\n\r]+)[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
114
+ /```[\w-]*[\r\n]+(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+([\s\S]*?)```/gi,
115
+ ]
116
+ for (const regex of patterns) {
117
+ let match
118
+ while ((match = regex.exec(text)) !== null) addFile(match[1].trim(), match[2])
119
+ }
112
120
 
113
121
  if (files.length === 0) {
114
122
  const htmlBlockRegex = /```(?:html)?[\r\n]+([\s\S]*?)```/gi
@@ -211,15 +219,12 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
211
219
  * Checks if a task prompt is an iterative refinement / modification.
212
220
  */
213
221
  export function isRefinementTask(userPrompt = '', projectFiles = null) {
214
- if (Array.isArray(projectFiles) && projectFiles.length === 0) return false
222
+ if ((Array.isArray(projectFiles) && projectFiles.length === 0) || !userPrompt?.trim()) return false
215
223
  const p = userPrompt.toLowerCase().trim()
216
- if (!p) return false
217
-
218
- const freshPhrases = ['from scratch', 'new project', 'new app', 'create a new', 'build a new', 'generate a new', '从头开始', '新建项目', '创建项目', '新应用', '新建应用', 'с нуля', 'новый проект', 'новое приложение', 'создай проект', 'создай приложение', 'создай игру', 'создай сервис', 'сделай проект', 'сделай приложение', 'сделай игру']
219
- if (freshPhrases.some((phrase) => p.includes(phrase))) return false
220
-
221
- const modKeywords = ['add', 'change', 'update', 'fix', 'modify', 'refactor', 'improve', 'enhance', 'adjust', 'style', 'css', 'color', 'theme', 'bug', '添加', '修改', '更新', '修复', '重构', '改进', '调整', '样式', '颜色', '主题', 'добавь', 'измени', 'поменяй', 'исправь', 'обнови', 'переделай', 'доработай', 'удали', 'темн', 'светл', 'цвет', 'кнопк', 'баг']
222
- return modKeywords.some((kw) => p.includes(kw))
224
+ const fresh = ['from scratch', 'new project', 'new app', 'create a new', 'build a new', 'generate a new', '从头开始', '新建项目', '创建项目', '新应用', '新建应用', 'с нуля', 'новый проект', 'новое приложение', 'создай проект', 'создай приложение', 'создай игру', 'создай сервис', 'сделай проект', 'сделай приложение', 'сделай игру']
225
+ if (fresh.some((phrase) => p.includes(phrase))) return false
226
+ const mod = ['add', 'change', 'update', 'fix', 'modify', 'refactor', 'improve', 'enhance', 'adjust', 'style', 'css', 'color', 'theme', 'bug', '添加', '修改', '更新', '修复', '重构', '改进', '调整', '样式', '颜色', '主题', 'добавь', 'измени', 'поменяй', 'исправь', 'обнови', 'переделай', 'доработай', 'удали', 'темн', 'светл', 'цвет', 'кнопк', 'баг']
227
+ return mod.some((kw) => p.includes(kw))
223
228
  }
224
229
 
225
230
  /**
@@ -344,6 +349,9 @@ async function doPromoteCandidateWorkspace(baseDir, winningIndex, options = { ke
344
349
  logger.warn(errMsg)
345
350
  throw new Error(errMsg)
346
351
  }
352
+ if (options.signal?.aborted) {
353
+ throw (options.signal.reason instanceof Error ? options.signal.reason : new Error('Turn aborted'))
354
+ }
347
355
 
348
356
  const promoted = []
349
357
  const baseReal = await fs.realpath(baseDir).catch(() => path.resolve(baseDir))
@@ -392,11 +400,9 @@ export async function cleanMoaWorkspaces(baseDir, options = {}) {
392
400
  const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
393
401
  const moaRoot = path.join(baseDir, '.moa')
394
402
  if (safeRunId) {
395
- const runRoot = path.join(moaRoot, safeRunId)
396
- await bestEffort('cleanMoaWorkspaces runId', () => fs.rm(runRoot, { recursive: true, force: true }))
403
+ await bestEffort('cleanMoaWorkspaces runId', () => fs.rm(path.join(moaRoot, safeRunId), { recursive: true, force: true }))
397
404
  await bestEffort('clean empty moaRoot', async () => {
398
- const remaining = await fs.readdir(moaRoot)
399
- if (remaining.length === 0) await fs.rmdir(moaRoot)
405
+ if ((await fs.readdir(moaRoot)).length === 0) await fs.rmdir(moaRoot)
400
406
  })
401
407
  } else {
402
408
  await bestEffort('cleanMoaWorkspaces', () => fs.rm(moaRoot, { recursive: true, force: true }))
@@ -563,33 +569,24 @@ export function computeLineDiff(oldText = '', newText = '') {
563
569
  const subN = subOld.length, subM = subNew.length
564
570
  const middleItems = []
565
571
 
566
- if (subN === 0) {
567
- for (let j = 0; j < subM; j++) middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
568
- } else if (subM === 0) {
569
- for (let i = 0; i < subN; i++) middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
570
- } else if (subN > 2000 || subM > 2000) {
572
+ if (subN === 0) for (let j = 0; j < subM; j++) middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
573
+ else if (subM === 0) for (let i = 0; i < subN; i++) middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
574
+ else if (subN > 2000 || subM > 2000) {
571
575
  for (let i = 0; i < subN; i++) middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
572
576
  for (let j = 0; j < subM; j++) middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
573
577
  } else {
574
578
  const dp = Array.from({ length: subN + 1 }, () => new Uint16Array(subM + 1))
575
579
  for (let i = 0; i < subN; i++) {
576
- for (let j = 0; j < subM; j++) {
577
- dp[i + 1][j + 1] = subOld[i] === subNew[j] ? dp[i][j] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
578
- }
580
+ for (let j = 0; j < subM; j++) dp[i + 1][j + 1] = subOld[i] === subNew[j] ? dp[i][j] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
579
581
  }
580
-
581
- let i = subN, j = subM
582
- const stack = []
582
+ let i = subN, j = subM; const stack = []
583
583
  while (i > 0 || j > 0) {
584
584
  if (i > 0 && j > 0 && subOld[i - 1] === subNew[j - 1]) {
585
- stack.push({ type: 'same', line: subOld[i - 1], oldNum: start + i, newNum: start + j })
586
- i--; j--
585
+ stack.push({ type: 'same', line: subOld[--i], oldNum: start + i + 1, newNum: start + j }); j--
587
586
  } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
588
- stack.push({ type: 'added', line: subNew[j - 1], newNum: start + j })
589
- j--
590
- } else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
591
- stack.push({ type: 'removed', line: subOld[i - 1], oldNum: start + i })
592
- i--
587
+ stack.push({ type: 'added', line: subNew[--j], newNum: start + j + 1 })
588
+ } else if (i > 0) {
589
+ stack.push({ type: 'removed', line: subOld[--i], oldNum: start + i + 1 })
593
590
  }
594
591
  }
595
592
  middleItems.push(...stack.reverse())
package/lib/history.js CHANGED
@@ -86,6 +86,7 @@ function normalizeRunRecord(record) {
86
86
  preset: record.preset || 'default',
87
87
  isRefinement: Boolean(record.isRefinement),
88
88
  isFastMode: Boolean(record.isFastMode),
89
+ cwd: record.cwd ? path.resolve(record.cwd) : undefined,
89
90
  candidates: Array.isArray(record.candidates)
90
91
  ? record.candidates.map((c, idx) => ({
91
92
  index: typeof c.index === 'number' ? c.index : (idx + 1),
@@ -357,7 +358,13 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = getDefaultHisto
357
358
  })
358
359
  .filter(Boolean)
359
360
 
360
- let total = _historyCache?.totalCount
361
+ let total = (_historyCache &&
362
+ _historyCache.filePath === filePath &&
363
+ _historyCache.mtimeMs === stat.mtimeMs &&
364
+ _historyCache.size === stat.size &&
365
+ typeof _historyCache.totalCount === 'number')
366
+ ? _historyCache.totalCount
367
+ : undefined
361
368
  if (typeof total !== 'number') {
362
369
  total = tailLines.length < needed ? tailLines.length : countFileLinesSync(filePath, stat)
363
370
  }
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto'
1
2
  import path from 'node:path'
2
3
  import { setLogger, logger } from './logger.js'
3
4
  import { refreshCatalogInBackground } from './pricing.js'
@@ -12,12 +13,14 @@ import { createLlmCaller } from './moa-llm.js'
12
13
  import {
13
14
  parseMoACommand,
14
15
  streamMoATurn,
16
+ streamPromoteTurn,
15
17
  } from './moa-runner.js'
16
18
  import { registerMoaRoutes } from './routes.js'
17
19
  import { resolvePresetForPrompt } from './moa-router.js'
18
20
  import { createLiveCanvasClient } from './live-canvas.js'
19
21
  import { registerPluginUpdater } from './updater.js'
20
22
  import { bestEffort } from './best-effort.js'
23
+ import { getMoaHistory } from './history.js'
21
24
 
22
25
  export const name = '@goodandready/dsh-moa'
23
26
  export { collectProjectContext, formatProjectContext } from './file-workspace.js'
@@ -38,12 +41,24 @@ export function apply(ctx, config) {
38
41
  refreshCatalogInBackground()
39
42
 
40
43
  let localOverrides = null
44
+ let baseAtOverride = {}
41
45
  const getSourceConfig = () => {
42
46
  let liveCandidate = ctx.fiber?.config
43
47
  if (!liveCandidate && ('config' in ctx)) {
44
48
  bestEffort('ctx.config probe', () => { liveCandidate = ctx.config }, ctx.logger)
45
49
  }
46
50
  const liveSource = plainConfig(liveCandidate ?? config ?? {})
51
+ if (localOverrides) {
52
+ for (const key of Object.keys(localOverrides)) {
53
+ if (baseAtOverride[key] !== undefined && JSON.stringify(liveSource[key]) !== baseAtOverride[key]) {
54
+ delete localOverrides[key]
55
+ delete baseAtOverride[key]
56
+ }
57
+ }
58
+ if (Object.keys(localOverrides).length === 0) {
59
+ localOverrides = null
60
+ }
61
+ }
47
62
  return localOverrides ? { ...liveSource, ...localOverrides } : liveSource
48
63
  }
49
64
  const live = () => {
@@ -85,6 +100,15 @@ export function apply(ctx, config) {
85
100
  } else if (settingsService && typeof settingsService.update === 'function') {
86
101
  await settingsService.update(NS, plain)
87
102
  }
103
+ let liveCandidate = ctx.fiber?.config
104
+ if (!liveCandidate && ('config' in ctx)) {
105
+ bestEffort('ctx.config probe', () => { liveCandidate = ctx.config }, ctx.logger)
106
+ }
107
+ const currentLive = plainConfig(liveCandidate ?? config ?? {})
108
+ baseAtOverride = baseAtOverride || {}
109
+ for (const key of Object.keys(plain)) {
110
+ baseAtOverride[key] = JSON.stringify(currentLive[key])
111
+ }
88
112
  localOverrides = { ...(localOverrides || {}), ...plain }
89
113
  return live()
90
114
  }
@@ -174,14 +198,36 @@ export function apply(ctx, config) {
174
198
  if (cfg.enabled !== false) {
175
199
  const effectiveDefault = cfg.default_preset || 'default'
176
200
  const parsed = parseMoACommand(userText, cfg.presets || [], effectiveDefault) || { prompt: userText, presetName: effectiveDefault, isExplicit: false }
201
+ if (parsed.isPromote) {
202
+ let promoteRunId = parsed.runId
203
+ if (!promoteRunId) {
204
+ const latestRun = getMoaHistory(1, 0)?.runs?.[0]
205
+ if (latestRun?.id) promoteRunId = latestRun.id
206
+ }
207
+ const sessCwd = event.cwd || event.session?.cwd || (event.session?.path ? path.dirname(event.session.path) : undefined) || process.cwd()
208
+ const turnContext = {
209
+ isPromoteTurn: true,
210
+ runId: promoteRunId,
211
+ candidateIndex: parsed.candidateIndex,
212
+ cwd: sessCwd,
213
+ webServerPort: ctx.webServer?.port,
214
+ timeMachineEngine: ctx.reflect?.get?.('timeMachineEngine', false),
215
+ }
216
+ if (event.signal) moaPendingTurns.set(event.signal, turnContext)
217
+ if (event.session?.id) setSessionTurn(event.session.id, turnContext)
218
+ return next()
219
+ }
177
220
  const isExplicitPreset = Boolean(parsed.isExplicit || userText.includes('--preset') || userText.includes('-p '))
178
221
  if (!isExplicitPreset && (cfg.smart_routing_enabled || cfg.presets?.some((p) => p.smart_routing_enabled))) {
179
222
  try {
223
+ const activePreset = (cfg.presets || []).find((p) => p.name === (parsed.presetName || effectiveDefault)) || cfg.presets?.[0]
224
+ const routingModel = (activePreset?.smart_routing_model?.model && activePreset.smart_routing_model)
225
+ || (cfg.smart_routing_model?.model && cfg.smart_routing_model) || activePreset?.smart_routing_model || cfg.smart_routing_model
180
226
  const routed = await resolvePresetForPrompt({
181
227
  prompt: parsed.prompt || userText,
182
228
  presets: cfg.presets || [],
183
229
  defaultPreset: parsed.presetName || effectiveDefault,
184
- routingModel: cfg.smart_routing_model,
230
+ routingModel,
185
231
  callLlm,
186
232
  enabled: true,
187
233
  })
@@ -201,8 +247,10 @@ export function apply(ctx, config) {
201
247
  const aggProvider = targetPreset?.aggregator?.provider || ''
202
248
  const aggModel = targetPreset?.aggregator?.model || ''
203
249
  const sessCwd = event.cwd || event.session?.cwd || (event.session?.path ? path.dirname(event.session.path) : undefined) || process.cwd()
250
+ const runId = crypto.randomUUID()
204
251
 
205
252
  const turnContext = {
253
+ runId,
206
254
  targetPreset,
207
255
  userPrompt: parsed.prompt || userText,
208
256
  messages: messages.slice(0, -1),
@@ -234,11 +282,11 @@ export function apply(ctx, config) {
234
282
  const turnContext = (payload.signal ? moaPendingTurns.get(payload.signal) : null)
235
283
  || (payload.session?.id ? getSessionTurn(payload.session.id) : null)
236
284
 
237
- if (turnContext) {
285
+ if (turnContext && !turnContext.isPromoteTurn) {
238
286
  return {
239
287
  ...resolved,
240
- provider: turnContext.aggregator.provider,
241
- model: turnContext.aggregator.model,
288
+ provider: turnContext.aggregator?.provider || resolved.provider,
289
+ model: turnContext.aggregator?.model || resolved.model,
242
290
  }
243
291
  }
244
292
  return resolved
@@ -254,6 +302,9 @@ export function apply(ctx, config) {
254
302
  if (options.signal) moaPendingTurns.delete(options.signal)
255
303
  if (options.session?.id) consumeSessionTurn(options.session.id)
256
304
 
305
+ if (turnContext.isPromoteTurn) {
306
+ return streamPromoteTurn(turnContext, options)
307
+ }
257
308
  return streamMoATurn(turnContext, options)
258
309
  }
259
310
 
@@ -1,17 +1,50 @@
1
1
  import path from "node:path"
2
+ import fs from "node:fs/promises"
2
3
 
3
- export async function createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs) {
4
+ export async function createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs, options = {}) {
4
5
  if (!liveCanvas || typeof liveCanvas.createPreviewFromContent !== "function") return null
5
6
  const htmlRel = (promotedFiles || []).find((f) => f.endsWith(".html") || f.endsWith(".htm"))
6
7
  if (!htmlRel) return null
8
+
7
9
  let content = null
8
- for (const r of referenceOutputs || []) {
9
- const block = (r?.files || []).find((f) => f.relativePath === htmlRel)
10
- if (block?.content) {
11
- content = block.content
12
- break
10
+
11
+ // 1. Try reading directly from disk in cwd (the promoted winner or curator synthesis)
12
+ if (cwd) {
13
+ try {
14
+ const fullPath = path.resolve(cwd, htmlRel)
15
+ content = await fs.readFile(fullPath, "utf8")
16
+ } catch {
17
+ content = null
18
+ }
19
+ }
20
+
21
+ // 2. Fallback to synthesized files / winning files
22
+ if (!content) {
23
+ const winningFiles = options.winningFiles || options.synthesizedFiles
24
+ if (Array.isArray(winningFiles)) {
25
+ const block = winningFiles.find((f) => f.relativePath === htmlRel)
26
+ if (block?.content) content = block.content
27
+ }
28
+ }
29
+
30
+ // 3. Fallback to winning candidate by index
31
+ if (!content && typeof options.winningIndex === 'number' && referenceOutputs) {
32
+ const winnerRef = referenceOutputs[options.winningIndex - 1]
33
+ const block = (winnerRef?.files || []).find((f) => f.relativePath === htmlRel)
34
+ if (block?.content) content = block.content
35
+ }
36
+
37
+ // 4. Fallback to searching referenceOutputs
38
+ if (!content) {
39
+ for (const r of referenceOutputs || []) {
40
+ const block = (r?.files || []).find((f) => f.relativePath === htmlRel)
41
+ if (block?.content) {
42
+ content = block.content
43
+ break
44
+ }
13
45
  }
14
46
  }
47
+
15
48
  if (!content) return null
16
49
  try {
17
50
  return await liveCanvas.createPreviewFromContent({ content, title: htmlRel, filePath: path.join(cwd, htmlRel) })
package/lib/moa-budget.js CHANGED
@@ -74,25 +74,29 @@ export function applyBudgetGuardrails({
74
74
  // Base candidate cost per slot (Round 1)
75
75
  const { totalEstimatedUsd, slotEstimates } = estimateCandidateRunCost(references, prices, promptTokens, expectedOutputTokens)
76
76
 
77
- // Estimate fixed cost for downstream phases (Aggregator / Judge panel)
78
- let downstreamUsd = 0
79
- if (aggregator?.provider && aggregator?.model) {
80
- const aggInput = promptTokens + expectedOutputTokens * references.length
81
- const aggCost = estimateTokenCost(aggregator, { inputTokens: aggInput, outputTokens: expectedOutputTokens }, prices)
82
- downstreamUsd += aggCost?.costUsd || 0
83
- }
84
- if (Array.isArray(judgeModels) && judgeModels.length > 0) {
85
- for (const jm of judgeModels) {
86
- if (jm?.provider && jm?.model) {
87
- const jInput = promptTokens + expectedOutputTokens * references.length
88
- const jCost = estimateTokenCost(jm, { inputTokens: jInput, outputTokens: expectedOutputTokens }, prices)
89
- downstreamUsd += jCost?.costUsd || 0
77
+ // Estimate cost for downstream phases (Aggregator / Judge panel)
78
+ function calcDownstreamUsd(numCandidates) {
79
+ let downstream = 0
80
+ if (aggregator?.provider && aggregator?.model) {
81
+ const aggInput = promptTokens + expectedOutputTokens * numCandidates
82
+ const aggCost = estimateTokenCost(aggregator, { inputTokens: aggInput, outputTokens: expectedOutputTokens }, prices)
83
+ downstream += aggCost?.costUsd || 0
84
+ }
85
+ if (Array.isArray(judgeModels) && judgeModels.length > 0) {
86
+ for (const jm of judgeModels) {
87
+ if (jm?.provider && jm?.model) {
88
+ const jInput = promptTokens + expectedOutputTokens * numCandidates
89
+ const jCost = estimateTokenCost(jm, { inputTokens: jInput, outputTokens: expectedOutputTokens }, prices)
90
+ downstream += jCost?.costUsd || 0
91
+ }
90
92
  }
91
93
  }
94
+ return downstream
92
95
  }
93
96
 
94
97
  // Multiplier if peer critique (Round 2) is enabled: each candidate runs another turn with input from all peers
95
98
  const roundMultiplier = peerCritiqueEnabled ? 2 : 1
99
+ const downstreamUsd = calcDownstreamUsd(references.length)
96
100
  const fullEstimatedUsd = Number(((totalEstimatedUsd * roundMultiplier) + downstreamUsd).toFixed(6))
97
101
 
98
102
  if (fullEstimatedUsd <= maxBudgetUsd) {
@@ -116,38 +120,43 @@ export function applyBudgetGuardrails({
116
120
  }
117
121
 
118
122
  // Action is 'trim': Sort by estimated cost ascending (cheapest first)
123
+ // Factor downstream synthesis cost into candidate selection (#158)
119
124
  const sorted = [...slotEstimates].sort((a, b) => a.costUsd - b.costUsd)
120
125
  const kept = []
121
- let cumulative = 0
126
+ let cumulativeCandidateCost = 0
122
127
 
123
128
  for (const item of sorted) {
124
129
  const candidateCost = item.costUsd * roundMultiplier
125
- if (cumulative + candidateCost <= maxBudgetUsd) {
130
+ const nextDownstream = calcDownstreamUsd(kept.length + 1)
131
+ if (cumulativeCandidateCost + candidateCost + nextDownstream <= maxBudgetUsd) {
126
132
  kept.push(item.slot)
127
- cumulative += candidateCost
133
+ cumulativeCandidateCost += candidateCost
128
134
  }
129
135
  }
130
136
 
131
137
  if (kept.length === 0) {
138
+ const minCandidateCost = (sorted[0]?.costUsd || 0) * roundMultiplier
139
+ const minTotal = minCandidateCost + calcDownstreamUsd(1)
132
140
  return {
133
141
  allowed: false,
134
142
  references: [],
135
143
  action: 'trim',
136
144
  originalCount: references.length,
137
145
  trimmedCount: 0,
138
- estimatedCostUsd: Number((sorted[0]?.costUsd * roundMultiplier || 0).toFixed(6)),
146
+ estimatedCostUsd: Number(minTotal.toFixed(6)),
139
147
  maxBudgetUsd,
140
- reason: `Cannot fit any candidate model within budget ($${maxBudgetUsd.toFixed(4)}). Cheapest candidate requires $${((sorted[0]?.costUsd || 0) * roundMultiplier).toFixed(4)}.`,
148
+ reason: `Cannot fit any candidate model within budget ($${maxBudgetUsd.toFixed(4)}). Cheapest candidate requires $${minTotal.toFixed(4)}.`,
141
149
  }
142
150
  }
143
151
 
152
+ const finalEstimatedUsd = Number((cumulativeCandidateCost + calcDownstreamUsd(kept.length)).toFixed(6))
144
153
  return {
145
154
  allowed: true,
146
155
  references: kept,
147
156
  action: 'trim',
148
157
  originalCount: references.length,
149
158
  trimmedCount: kept.length,
150
- estimatedCostUsd: Number(cumulative.toFixed(6)),
159
+ estimatedCostUsd: finalEstimatedUsd,
151
160
  maxBudgetUsd,
152
161
  reason: `Trimmed candidate pool from ${references.length} to ${kept.length} models to stay within budget ($${maxBudgetUsd.toFixed(4)}).`,
153
162
  }
@@ -122,9 +122,11 @@ export async function runReferencesParallel(references, messages, options = {},
122
122
  })
123
123
  reject(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s`))
124
124
  }, timeoutMs)
125
+ candidateTimer?.unref?.()
125
126
  })
126
127
 
127
128
  const res = await Promise.race([callPromise, timeoutPromise])
129
+ if (candidateTimer) { clearTimeout(candidateTimer); candidateTimer = null }
128
130
  const text = typeof res === 'string' ? res : (res?.content || res?.text || '')
129
131
 
130
132
  const fallbackUsage = (typeof res === 'object' && res?.usage) ? res.usage : {
@@ -275,6 +277,7 @@ export async function runReferencesParallel(references, messages, options = {},
275
277
  }
276
278
  }
277
279
  } finally {
280
+ if (candidateTimer) clearTimeout(candidateTimer)
278
281
  notifyFinished()
279
282
  }
280
283
  }
@@ -371,7 +371,8 @@ CONSENSUS_REASONING: [one concise sentence explaining your pick]
371
371
 
372
372
  const winningCand = activeCandidates.find((c) => c.index === winner) || activeCandidates[0]
373
373
  const winnerVotes = votes[winner] || 0
374
- const consensusReport = `Multi-judge consensus (${strategy}): Candidate ${winner} (${winningCand.label}) won with ${winnerVotes}/${validResults.length} votes.`
374
+ const winnerLabel = blind ? '' : ` (${winningCand.label})`
375
+ const consensusReport = `Multi-judge consensus (${strategy}): Candidate ${winner}${winnerLabel} won with ${winnerVotes}/${validResults.length} votes.`
375
376
 
376
377
  if (typeof onProgress === 'function') {
377
378
  onProgress(`⚖️ *${consensusReport}*\n`)
@@ -302,6 +302,10 @@ export function buildSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCri
302
302
  }
303
303
 
304
304
  const isBlind = Boolean(options.blindEvaluation)
305
+ let consensusReportText = options.consensusReport
306
+ if (isBlind && consensusReportText) {
307
+ consensusReportText = consensusReportText.replace(/\s*\([^)]+\)/g, '')
308
+ }
305
309
 
306
310
  const joined = referenceOutputs
307
311
  .map((r, i) => {
@@ -344,7 +348,7 @@ ${LANGUAGE_MIRRORING_DIRECTIVE}
344
348
  ${referenceOutputs.some((r) => r.testResult) ? `\n${TEST_GATE_DIRECTIVE}\n` : ''}
345
349
  ${referenceOutputs.some((r) => r.syntaxWarning) ? `\n${SYNTAX_CORRECTION_DIRECTIVE}\n` : ''}
346
350
  ${options.priorTurnBaseline ? `\n### 🔄 Prior Turn Context Baseline:\n${options.priorTurnBaseline}\n` : ''}
347
- ${options.consensusReport ? `\n### ⚖️ Multi-Judge Consensus Advisory:\n${options.consensusReport}\n` : ''}
351
+ ${consensusReportText ? `\n### ⚖️ Multi-Judge Consensus Advisory:\n${consensusReportText}\n` : ''}
348
352
  ${options.compositeMergeDirective ? `\n${options.compositeMergeDirective}\n` : ''}
349
353
  Instructions:
350
354
  Your response MUST be structured into three clear parts: