@goodandready/dsh-moa 0.2.42 → 0.2.44

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/client.js CHANGED
@@ -101,6 +101,8 @@ window.__ModuleLoader__.load({
101
101
  'proposers.add_btn': '+ Add Candidate Model',
102
102
  'proposers.temp_label': 'Candidates Temperature',
103
103
  'proposers.temp_hint': 'Recommended: 0.6 – 0.8 for creative, independent solution exploration.',
104
+ 'proposers.temperature_gradient': 'Temperature Gradient Exploration (0.2 → 0.9 across candidates)',
105
+ 'proposers.candidate_temp_tooltip': 'Candidate Temperature (leave empty for default/gradient)',
104
106
  'stats.title': '📈 MoA Analytics & Telemetry',
105
107
  'stats.desc': 'Session telemetry, historical runs, and model win-rate tracking.',
106
108
  'stats.total_runs': 'Total Runs',
@@ -277,6 +279,8 @@ window.__ModuleLoader__.load({
277
279
  'proposers.add_btn': '+ 添加候选模型',
278
280
  'proposers.temp_label': '候选模型采样温度 (Temperature)',
279
281
  'proposers.temp_hint': '推荐:0.6 – 0.8,鼓励创造性、发散性的独立探索。',
282
+ 'proposers.temperature_gradient': '温度梯度探索(候选模型采样温度从 0.2 到 0.9 递增)',
283
+ 'proposers.candidate_temp_tooltip': '候选模型采样温度(留空使用默认/梯度)',
280
284
  'stats.title': '📈 MoA 数据分析与遥测',
281
285
  'stats.desc': '会话遥测、历史运行追踪及模型胜率统计。',
282
286
  'stats.total_runs': '总运行次数',
@@ -1962,7 +1966,7 @@ window.__ModuleLoader__.load({
1962
1966
  onChange: (e) => updateCurrentPreset((p) => ({ ...p, temperature_gradient_enabled: e.target.checked })),
1963
1967
  style: { accentColor: 'var(--dsw-alias-state-brand-primary, var(--dsw-alias-label-primary))', cursor: 'pointer' },
1964
1968
  }),
1965
- 'Temperature Gradient Exploration (0.2 → 0.9 across candidates)'
1969
+ t('proposers.temperature_gradient') || 'Temperature Gradient Exploration (0.2 → 0.9 across candidates)'
1966
1970
  )
1967
1971
  ),
1968
1972
  React.createElement(
@@ -1990,7 +1994,7 @@ window.__ModuleLoader__.load({
1990
1994
  min: '0',
1991
1995
  max: '2',
1992
1996
  placeholder: 'Temp',
1993
- title: 'Candidate Temperature (leave empty for default/gradient)',
1997
+ title: t('proposers.candidate_temp_tooltip') || 'Candidate Temperature (leave empty for default/gradient)',
1994
1998
  className: 'moa-input',
1995
1999
  style: { width: 50, height: 32, fontSize: 12, padding: '0 4px', textAlign: 'center' },
1996
2000
  value: (typeof ref.temperature === 'number' && ref.temperature >= 0) ? ref.temperature : '',
@@ -2571,7 +2575,7 @@ window.__ModuleLoader__.load({
2571
2575
  }
2572
2576
  })
2573
2577
  .catch((err) => {
2574
- setSaveStatus(`Error: ${err.message || err}`)
2578
+ setSaveStatus((t('status.error_saving') || 'Error saving: ') + (err?.message || String(err)))
2575
2579
  })
2576
2580
  }
2577
2581
 
@@ -2612,13 +2616,14 @@ window.__ModuleLoader__.load({
2612
2616
  }
2613
2617
  render() {
2614
2618
  if (this.state.hasError) {
2619
+ const t = typeof this.props?.t === 'function' ? this.props.t : (typeof makeT === 'function' ? makeT(en, null) : ((k) => k))
2615
2620
  return React.createElement(
2616
2621
  'div',
2617
2622
  {
2618
2623
  className: 'moa-alert-err',
2619
2624
  style: { margin: '12px 0', padding: '14px', borderRadius: '8px' },
2620
2625
  },
2621
- React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ MoA UI Error:'),
2626
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, t('error_boundary.title') || '⚠️ MoA UI Error:'),
2622
2627
  React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
2623
2628
  React.createElement(
2624
2629
  'button',
@@ -2628,7 +2633,7 @@ window.__ModuleLoader__.load({
2628
2633
  style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
2629
2634
  onClick: () => this.setState({ hasError: false, error: null }),
2630
2635
  },
2631
- 'Retry'
2636
+ t('error_boundary.retry') || 'Retry'
2632
2637
  )
2633
2638
  )
2634
2639
  }
@@ -2675,12 +2680,12 @@ window.__ModuleLoader__.load({
2675
2680
  'div',
2676
2681
  { className: 'moa-body' },
2677
2682
  state.status === 'loading'
2678
- ? React.createElement('div', { style: { padding: '16px 0', color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } }, 'Loading MoA settings…')
2683
+ ? React.createElement('div', { style: { padding: '16px 0', color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } }, state.t('status.loading') || 'Loading MoA settings…')
2679
2684
  : state.status === 'unavailable'
2680
2685
  ? React.createElement(
2681
2686
  'div',
2682
2687
  { style: { padding: '16px 0', display: 'flex', flexDirection: 'column', gap: 10 } },
2683
- React.createElement('div', { className: 'moa-alert-err' }, 'MoA service temporarily unavailable'),
2688
+ React.createElement('div', { className: 'moa-alert-err' }, state.t('status.unavailable') || 'MoA service temporarily unavailable'),
2684
2689
  React.createElement(
2685
2690
  'button',
2686
2691
  {
@@ -2689,7 +2694,7 @@ window.__ModuleLoader__.load({
2689
2694
  style: { width: 'fit-content' },
2690
2695
  onClick: state.reload,
2691
2696
  },
2692
- state.t('actions.retry')
2697
+ state.t('status.retry') || state.t('actions.retry')
2693
2698
  )
2694
2699
  )
2695
2700
  : React.createElement(MoAEditor, state)
@@ -2738,7 +2743,7 @@ window.__ModuleLoader__.load({
2738
2743
  locale: NS,
2739
2744
  inject: () => ({ ctx }),
2740
2745
  },
2741
- (props) => React.createElement(MoAErrorBoundary, null, React.createElement(MoACard, props))
2746
+ (props) => React.createElement(MoAErrorBoundary, props, React.createElement(MoACard, props))
2742
2747
  )
2743
2748
  } catch (err) {
2744
2749
  console.warn('[dsh-moa] Failed to register ' + slotName + ':', err)
@@ -2761,7 +2766,7 @@ window.__ModuleLoader__.load({
2761
2766
  locale: NS,
2762
2767
  inject: () => ({ ctx }),
2763
2768
  },
2764
- (props) => React.createElement(MoAErrorBoundary, null, React.createElement(MoACard, props))
2769
+ (props) => React.createElement(MoAErrorBoundary, props, React.createElement(MoACard, props))
2765
2770
  )
2766
2771
  } catch (err) {
2767
2772
  console.warn('[dsh-moa] Failed to register plugins.item:', err)
@@ -78,7 +78,7 @@ 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 moaRoot = assertPathContained(baseDir, '.moa')
82
82
  const runRoot = safeRunId ? assertPathContained(moaRoot, safeRunId) : moaRoot
83
83
  return { moaRoot, runRoot, safeRunId, sub, candidateDir: assertPathContained(runRoot, sub) }
84
84
  }
@@ -97,18 +97,16 @@ export function extractFileBlocks(text) {
97
97
  }
98
98
  }
99
99
 
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])
100
+ const patterns = [
101
+ /```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi,
102
+ /(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
103
+ /#{1,4}\s+(?:File|Path):\s*([^\s\n\r]+)[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
104
+ /```[\w-]*[\r\n]+(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+([\s\S]*?)```/gi,
105
+ ]
106
+ for (const regex of patterns) {
107
+ let match
108
+ while ((match = regex.exec(text)) !== null) addFile(match[1].trim(), match[2])
109
+ }
112
110
 
113
111
  if (files.length === 0) {
114
112
  const htmlBlockRegex = /```(?:html)?[\r\n]+([\s\S]*?)```/gi
@@ -211,15 +209,12 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
211
209
  * Checks if a task prompt is an iterative refinement / modification.
212
210
  */
213
211
  export function isRefinementTask(userPrompt = '', projectFiles = null) {
214
- if (Array.isArray(projectFiles) && projectFiles.length === 0) return false
212
+ if ((Array.isArray(projectFiles) && projectFiles.length === 0) || !userPrompt?.trim()) return false
215
213
  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))
214
+ const fresh = ['from scratch', 'new project', 'new app', 'create a new', 'build a new', 'generate a new', '从头开始', '新建项目', '创建项目', '新应用', '新建应用', 'с нуля', 'новый проект', 'новое приложение', 'создай проект', 'создай приложение', 'создай игру', 'создай сервис', 'сделай проект', 'сделай приложение', 'сделай игру']
215
+ if (fresh.some((phrase) => p.includes(phrase))) return false
216
+ const mod = ['add', 'change', 'update', 'fix', 'modify', 'refactor', 'improve', 'enhance', 'adjust', 'style', 'css', 'color', 'theme', 'bug', '添加', '修改', '更新', '修复', '重构', '改进', '调整', '样式', '颜色', '主题', 'добавь', 'измени', 'поменяй', 'исправь', 'обнови', 'переделай', 'доработай', 'удали', 'темн', 'светл', 'цвет', 'кнопк', 'баг']
217
+ return mod.some((kw) => p.includes(kw))
223
218
  }
224
219
 
225
220
  /**
@@ -344,6 +339,9 @@ async function doPromoteCandidateWorkspace(baseDir, winningIndex, options = { ke
344
339
  logger.warn(errMsg)
345
340
  throw new Error(errMsg)
346
341
  }
342
+ if (options.signal?.aborted) {
343
+ throw (options.signal.reason instanceof Error ? options.signal.reason : new Error('Turn aborted'))
344
+ }
347
345
 
348
346
  const promoted = []
349
347
  const baseReal = await fs.realpath(baseDir).catch(() => path.resolve(baseDir))
@@ -392,11 +390,9 @@ export async function cleanMoaWorkspaces(baseDir, options = {}) {
392
390
  const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
393
391
  const moaRoot = path.join(baseDir, '.moa')
394
392
  if (safeRunId) {
395
- const runRoot = path.join(moaRoot, safeRunId)
396
- await bestEffort('cleanMoaWorkspaces runId', () => fs.rm(runRoot, { recursive: true, force: true }))
393
+ await bestEffort('cleanMoaWorkspaces runId', () => fs.rm(path.join(moaRoot, safeRunId), { recursive: true, force: true }))
397
394
  await bestEffort('clean empty moaRoot', async () => {
398
- const remaining = await fs.readdir(moaRoot)
399
- if (remaining.length === 0) await fs.rmdir(moaRoot)
395
+ if ((await fs.readdir(moaRoot)).length === 0) await fs.rmdir(moaRoot)
400
396
  })
401
397
  } else {
402
398
  await bestEffort('cleanMoaWorkspaces', () => fs.rm(moaRoot, { recursive: true, force: true }))
@@ -563,33 +559,24 @@ export function computeLineDiff(oldText = '', newText = '') {
563
559
  const subN = subOld.length, subM = subNew.length
564
560
  const middleItems = []
565
561
 
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) {
562
+ if (subN === 0) for (let j = 0; j < subM; j++) middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
563
+ else if (subM === 0) for (let i = 0; i < subN; i++) middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
564
+ else if (subN > 2000 || subM > 2000) {
571
565
  for (let i = 0; i < subN; i++) middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
572
566
  for (let j = 0; j < subM; j++) middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
573
567
  } else {
574
568
  const dp = Array.from({ length: subN + 1 }, () => new Uint16Array(subM + 1))
575
569
  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
- }
570
+ 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
571
  }
580
-
581
- let i = subN, j = subM
582
- const stack = []
572
+ let i = subN, j = subM; const stack = []
583
573
  while (i > 0 || j > 0) {
584
574
  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--
575
+ stack.push({ type: 'same', line: subOld[--i], oldNum: start + i + 1, newNum: start + j }); j--
587
576
  } 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--
577
+ stack.push({ type: 'added', line: subNew[--j], newNum: start + j + 1 })
578
+ } else if (i > 0) {
579
+ stack.push({ type: 'removed', line: subOld[--i], oldNum: start + i + 1 })
593
580
  }
594
581
  }
595
582
  middleItems.push(...stack.reverse())
package/lib/history.js CHANGED
@@ -358,7 +358,13 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = getDefaultHisto
358
358
  })
359
359
  .filter(Boolean)
360
360
 
361
- 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
362
368
  if (typeof total !== 'number') {
363
369
  total = tailLines.length < needed ? tailLines.length : countFileLinesSync(filePath, stat)
364
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'
@@ -19,6 +20,7 @@ import { resolvePresetForPrompt } from './moa-router.js'
19
20
  import { createLiveCanvasClient } from './live-canvas.js'
20
21
  import { registerPluginUpdater } from './updater.js'
21
22
  import { bestEffort } from './best-effort.js'
23
+ import { getMoaHistory } from './history.js'
22
24
 
23
25
  export const name = '@goodandready/dsh-moa'
24
26
  export { collectProjectContext, formatProjectContext } from './file-workspace.js'
@@ -39,12 +41,24 @@ export function apply(ctx, config) {
39
41
  refreshCatalogInBackground()
40
42
 
41
43
  let localOverrides = null
44
+ let baseAtOverride = {}
42
45
  const getSourceConfig = () => {
43
46
  let liveCandidate = ctx.fiber?.config
44
47
  if (!liveCandidate && ('config' in ctx)) {
45
48
  bestEffort('ctx.config probe', () => { liveCandidate = ctx.config }, ctx.logger)
46
49
  }
47
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
+ }
48
62
  return localOverrides ? { ...liveSource, ...localOverrides } : liveSource
49
63
  }
50
64
  const live = () => {
@@ -86,6 +100,15 @@ export function apply(ctx, config) {
86
100
  } else if (settingsService && typeof settingsService.update === 'function') {
87
101
  await settingsService.update(NS, plain)
88
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
+ }
89
112
  localOverrides = { ...(localOverrides || {}), ...plain }
90
113
  return live()
91
114
  }
@@ -176,10 +199,15 @@ export function apply(ctx, config) {
176
199
  const effectiveDefault = cfg.default_preset || 'default'
177
200
  const parsed = parseMoACommand(userText, cfg.presets || [], effectiveDefault) || { prompt: userText, presetName: effectiveDefault, isExplicit: false }
178
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
+ }
179
207
  const sessCwd = event.cwd || event.session?.cwd || (event.session?.path ? path.dirname(event.session.path) : undefined) || process.cwd()
180
208
  const turnContext = {
181
209
  isPromoteTurn: true,
182
- runId: parsed.runId,
210
+ runId: promoteRunId,
183
211
  candidateIndex: parsed.candidateIndex,
184
212
  cwd: sessCwd,
185
213
  webServerPort: ctx.webServer?.port,
@@ -192,11 +220,14 @@ export function apply(ctx, config) {
192
220
  const isExplicitPreset = Boolean(parsed.isExplicit || userText.includes('--preset') || userText.includes('-p '))
193
221
  if (!isExplicitPreset && (cfg.smart_routing_enabled || cfg.presets?.some((p) => p.smart_routing_enabled))) {
194
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
195
226
  const routed = await resolvePresetForPrompt({
196
227
  prompt: parsed.prompt || userText,
197
228
  presets: cfg.presets || [],
198
229
  defaultPreset: parsed.presetName || effectiveDefault,
199
- routingModel: cfg.smart_routing_model,
230
+ routingModel,
200
231
  callLlm,
201
232
  enabled: true,
202
233
  })
@@ -216,8 +247,10 @@ export function apply(ctx, config) {
216
247
  const aggProvider = targetPreset?.aggregator?.provider || ''
217
248
  const aggModel = targetPreset?.aggregator?.model || ''
218
249
  const sessCwd = event.cwd || event.session?.cwd || (event.session?.path ? path.dirname(event.session.path) : undefined) || process.cwd()
250
+ const runId = crypto.randomUUID()
219
251
 
220
252
  const turnContext = {
253
+ runId,
221
254
  targetPreset,
222
255
  userPrompt: parsed.prompt || userText,
223
256
  messages: messages.slice(0, -1),
package/lib/moa-budget.js CHANGED
@@ -40,6 +40,7 @@ export function applyBudgetGuardrails({
40
40
  action = 'trim', // 'trim' | 'abort'
41
41
  prices = {},
42
42
  promptLength = 1000,
43
+ projectContext = '',
43
44
  messages = [],
44
45
  maxTokens = 2048,
45
46
  peerCritiqueEnabled = false,
@@ -57,6 +58,9 @@ export function applyBudgetGuardrails({
57
58
 
58
59
  // Calculate full input tokens from userPrompt length + messages context
59
60
  let contextChars = Number(promptLength) || 1000
61
+ if (typeof projectContext === 'string' && projectContext.length > 0 && !String(promptLength).includes(String(projectContext.length))) {
62
+ contextChars += projectContext.length
63
+ }
60
64
  if (Array.isArray(messages) && messages.length > 0) {
61
65
  for (const m of messages) {
62
66
  if (typeof m?.content === 'string') {
@@ -74,25 +78,29 @@ export function applyBudgetGuardrails({
74
78
  // Base candidate cost per slot (Round 1)
75
79
  const { totalEstimatedUsd, slotEstimates } = estimateCandidateRunCost(references, prices, promptTokens, expectedOutputTokens)
76
80
 
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
81
+ // Estimate cost for downstream phases (Aggregator / Judge panel)
82
+ function calcDownstreamUsd(numCandidates) {
83
+ let downstream = 0
84
+ if (aggregator?.provider && aggregator?.model) {
85
+ const aggInput = promptTokens + expectedOutputTokens * numCandidates
86
+ const aggCost = estimateTokenCost(aggregator, { inputTokens: aggInput, outputTokens: expectedOutputTokens }, prices)
87
+ downstream += aggCost?.costUsd || 0
88
+ }
89
+ if (Array.isArray(judgeModels) && judgeModels.length > 0) {
90
+ for (const jm of judgeModels) {
91
+ if (jm?.provider && jm?.model) {
92
+ const jInput = promptTokens + expectedOutputTokens * numCandidates
93
+ const jCost = estimateTokenCost(jm, { inputTokens: jInput, outputTokens: expectedOutputTokens }, prices)
94
+ downstream += jCost?.costUsd || 0
95
+ }
90
96
  }
91
97
  }
98
+ return downstream
92
99
  }
93
100
 
94
101
  // Multiplier if peer critique (Round 2) is enabled: each candidate runs another turn with input from all peers
95
102
  const roundMultiplier = peerCritiqueEnabled ? 2 : 1
103
+ const downstreamUsd = calcDownstreamUsd(references.length)
96
104
  const fullEstimatedUsd = Number(((totalEstimatedUsd * roundMultiplier) + downstreamUsd).toFixed(6))
97
105
 
98
106
  if (fullEstimatedUsd <= maxBudgetUsd) {
@@ -116,38 +124,43 @@ export function applyBudgetGuardrails({
116
124
  }
117
125
 
118
126
  // Action is 'trim': Sort by estimated cost ascending (cheapest first)
127
+ // Factor downstream synthesis cost into candidate selection (#158)
119
128
  const sorted = [...slotEstimates].sort((a, b) => a.costUsd - b.costUsd)
120
129
  const kept = []
121
- let cumulative = 0
130
+ let cumulativeCandidateCost = 0
122
131
 
123
132
  for (const item of sorted) {
124
133
  const candidateCost = item.costUsd * roundMultiplier
125
- if (cumulative + candidateCost <= maxBudgetUsd) {
134
+ const nextDownstream = calcDownstreamUsd(kept.length + 1)
135
+ if (cumulativeCandidateCost + candidateCost + nextDownstream <= maxBudgetUsd) {
126
136
  kept.push(item.slot)
127
- cumulative += candidateCost
137
+ cumulativeCandidateCost += candidateCost
128
138
  }
129
139
  }
130
140
 
131
141
  if (kept.length === 0) {
142
+ const minCandidateCost = (sorted[0]?.costUsd || 0) * roundMultiplier
143
+ const minTotal = minCandidateCost + calcDownstreamUsd(1)
132
144
  return {
133
145
  allowed: false,
134
146
  references: [],
135
147
  action: 'trim',
136
148
  originalCount: references.length,
137
149
  trimmedCount: 0,
138
- estimatedCostUsd: Number((sorted[0]?.costUsd * roundMultiplier || 0).toFixed(6)),
150
+ estimatedCostUsd: Number(minTotal.toFixed(6)),
139
151
  maxBudgetUsd,
140
- reason: `Cannot fit any candidate model within budget ($${maxBudgetUsd.toFixed(4)}). Cheapest candidate requires $${((sorted[0]?.costUsd || 0) * roundMultiplier).toFixed(4)}.`,
152
+ reason: `Cannot fit any candidate model within budget ($${maxBudgetUsd.toFixed(4)}). Cheapest candidate requires $${minTotal.toFixed(4)}.`,
141
153
  }
142
154
  }
143
155
 
156
+ const finalEstimatedUsd = Number((cumulativeCandidateCost + calcDownstreamUsd(kept.length)).toFixed(6))
144
157
  return {
145
158
  allowed: true,
146
159
  references: kept,
147
160
  action: 'trim',
148
161
  originalCount: references.length,
149
162
  trimmedCount: kept.length,
150
- estimatedCostUsd: Number(cumulative.toFixed(6)),
163
+ estimatedCostUsd: finalEstimatedUsd,
151
164
  maxBudgetUsd,
152
165
  reason: `Trimmed candidate pool from ${references.length} to ${kept.length} models to stay within budget ($${maxBudgetUsd.toFixed(4)}).`,
153
166
  }
@@ -125,6 +125,7 @@ export async function runReferencesParallel(references, messages, options = {},
125
125
  })
126
126
 
127
127
  const res = await Promise.race([callPromise, timeoutPromise])
128
+ if (candidateTimer) { clearTimeout(candidateTimer); candidateTimer = null }
128
129
  const text = typeof res === 'string' ? res : (res?.content || res?.text || '')
129
130
 
130
131
  const fallbackUsage = (typeof res === 'object' && res?.usage) ? res.usage : {
@@ -275,6 +276,7 @@ export async function runReferencesParallel(references, messages, options = {},
275
276
  }
276
277
  }
277
278
  } finally {
279
+ if (candidateTimer) clearTimeout(candidateTimer)
278
280
  notifyFinished()
279
281
  }
280
282
  }
package/lib/moa-llm.js CHANGED
@@ -139,3 +139,14 @@ export function createLlmCaller(ctx) {
139
139
  }
140
140
  }
141
141
  }
142
+
143
+
144
+ export function callConfigEquals(a, b) {
145
+ if (!a || !b) return false
146
+ if (a.provider !== b.provider) return false
147
+ if (a.model !== b.model) return false
148
+ if (a.temperature !== b.temperature) return false
149
+ if (a.maxTokens !== b.maxTokens) return false
150
+ if (a.reasoningEffort !== b.reasoningEffort) return false
151
+ return true
152
+ }
@@ -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:
package/lib/moa-runner.js CHANGED
@@ -38,7 +38,8 @@ export async function runMoAPipeline({
38
38
  webServerPort = null, timeMachineEngine = null, timeMachineUrl = null,
39
39
  force = isTestEnvironment() ? true : false, checkpoint = true, checkpointFn = null, runId: customRunId = null,
40
40
  }) {
41
- const promoteOpts = { webServerPort, timeMachineEngine, timeMachineUrl, force: Boolean(force), checkpoint, checkpointFn }
41
+ const runId = (customRunId && String(customRunId).trim()) || crypto.randomUUID()
42
+ const promoteOpts = { webServerPort, timeMachineEngine, timeMachineUrl, force: Boolean(force), checkpoint, checkpointFn, signal }
42
43
  if (signal?.aborted) return { error: new Error('Turn aborted') }
43
44
  const startTime = Date.now()
44
45
  // 1. Resolve configurations
@@ -58,15 +59,34 @@ export async function runMoAPipeline({
58
59
  const candidateRetries = 1, refTimeoutSec = typeof preset?.reference_timeout_sec === 'number' ? preset.reference_timeout_sec : 60
59
60
  const aggTimeoutSec = typeof preset?.aggregator_timeout_sec === 'number' ? preset.aggregator_timeout_sec : 180
60
61
  const isBlindEvaluation = Boolean(preset?.blind_evaluation), isPeerCritiqueEnabled = Boolean(preset?.peer_critique_enabled)
61
- const allowCandidateOverride = Boolean(preset?.allow_candidate_override), runId = customRunId || null
62
- // Feature 6: Budget Guardrail check
62
+ const allowCandidateOverride = Boolean(preset?.allow_candidate_override)
63
+ // 1. Collect project context for refinement tasks
64
+ const collectedCtx = await collectProjectContext(cwd, 16000)
65
+ const isRefinement = Boolean(collectedCtx?.files?.length > 0 && isRefinementTask(userPrompt, collectedCtx.files))
66
+ let projectContext = ''
67
+ if (collectedCtx?.skippedFiles > 0 && typeof onProgress === 'function') {
68
+ const preview = collectedCtx.skippedList.slice(0, 3).join(', ')
69
+ const more = collectedCtx.skippedList.length > 3 ? ` (+${collectedCtx.skippedList.length - 3})` : ''
70
+ onProgress(`⚠️ *Workspace scan note: ${collectedCtx.skippedFiles} file(s) skipped (${preview}${more})*\n`)
71
+ }
72
+ if (isRefinement) {
73
+ if (typeof onProgress === 'function') {
74
+ onProgress('🔍 *Reading project files for refinement context...*\n')
75
+ }
76
+ projectContext = formatProjectContext(collectedCtx.files, {
77
+ skippedFiles: collectedCtx.skippedFiles,
78
+ skippedList: collectedCtx.skippedList,
79
+ })
80
+ }
81
+ // 2. Budget Guardrail check
63
82
  const budgetCheck = applyBudgetGuardrails({
64
83
  references: referenceModels,
65
84
  enabled: preset?.budget_guard_enabled,
66
85
  maxBudgetUsd: preset?.max_budget_usd,
67
86
  action: preset?.budget_action || 'trim',
68
87
  prices,
69
- promptLength: userPrompt?.length || 1000,
88
+ promptLength: (userPrompt?.length || 1000) + (projectContext ? projectContext.length : 0),
89
+ projectContext,
70
90
  messages,
71
91
  maxTokens,
72
92
  peerCritiqueEnabled: isPeerCritiqueEnabled,
@@ -74,20 +94,12 @@ export async function runMoAPipeline({
74
94
  judgeModels: preset?.multi_judge_enabled ? preset?.judge_models : [],
75
95
  })
76
96
  if (!budgetCheck.allowed) {
77
- return {
78
- kind: 'failure',
79
- content: `🛑 Budget Guardrail Abort: ${budgetCheck.reason}`,
80
- aggregator: slotLabel(primaryJudge),
81
- references: [],
82
- presetName: preset?.name || 'default',
83
- isRefinement: false,
84
- isFastMode,
85
- winningIndex: 0,
86
- winnerModel: 'none',
87
- promotedFiles: [],
97
+ return { kind: 'failure', content: `🛑 Budget Guardrail Abort: ${budgetCheck.reason}`,
98
+ aggregator: slotLabel(primaryJudge), references: [], presetName: preset?.name || 'default',
99
+ isRefinement, isFastMode, winningIndex: 0, winnerModel: 'none', promotedFiles: [],
88
100
  usage: { totalTokens: 0, totalCostUsd: 0, candidates: [], aggregator: { totalTokens: 0, costUsd: 0 } },
89
- skippedFiles: 0,
90
- skippedList: [],
101
+ skippedFiles: collectedCtx?.skippedFiles || 0,
102
+ skippedList: collectedCtx?.skippedList || [],
91
103
  durationMs: Date.now() - startTime,
92
104
  }
93
105
  }
@@ -95,24 +107,6 @@ export async function runMoAPipeline({
95
107
  if (budgetCheck.action === 'trim' && typeof onProgress === 'function') {
96
108
  onProgress(`✂️ *${budgetCheck.reason}*\n`)
97
109
  }
98
- // 2. Collect project context for refinement tasks
99
- const collectedCtx = await collectProjectContext(cwd, 16000)
100
- const isRefinement = Boolean(collectedCtx?.files?.length > 0 && isRefinementTask(userPrompt, collectedCtx.files))
101
- let projectContext = ''
102
- if (collectedCtx?.skippedFiles > 0 && typeof onProgress === 'function') {
103
- const preview = collectedCtx.skippedList.slice(0, 3).join(', ')
104
- const more = collectedCtx.skippedList.length > 3 ? ` (+${collectedCtx.skippedList.length - 3})` : ''
105
- onProgress(`⚠️ *Workspace scan note: ${collectedCtx.skippedFiles} file(s) skipped (${preview}${more})*\n`)
106
- }
107
- if (isRefinement) {
108
- if (typeof onProgress === 'function') {
109
- onProgress('🔍 *Reading project files for refinement context...*\n')
110
- }
111
- projectContext = formatProjectContext(collectedCtx.files, {
112
- skippedFiles: collectedCtx.skippedFiles,
113
- skippedList: collectedCtx.skippedList,
114
- })
115
- }
116
110
  // 3. Build prompts & Feature 8 multi-turn context
117
111
  const priorTurnBaseline = preset?.multi_turn_enabled !== false ? extractPriorTurnBaseline(messages) : null
118
112
  const prunedHistory = preset?.multi_turn_enabled !== false ? pruneMultiTurnMessages(messages) : messages
@@ -275,12 +269,30 @@ export async function runMoAPipeline({
275
269
  return { error: new Error('Turn aborted') }
276
270
  }
277
271
  const single = successfulRefs[0]
278
- let promotedFiles = []
272
+ let promotedFiles = [], promoteErr = null
279
273
  if (single.files?.length > 0) {
280
- promotedFiles = await promoteCandidateWorkspace(cwd, single.index, { ...promoteOpts, runId })
281
- } else {
274
+ try {
275
+ promotedFiles = await promoteCandidateWorkspace(cwd, single.index, { ...promoteOpts, runId, keepMoa: allowCandidateOverride })
276
+ } catch (e) { promoteErr = e }
277
+ } else if (!allowCandidateOverride) {
282
278
  await cleanMoaWorkspaces(cwd, { runId })
283
279
  }
280
+ if (promoteErr) {
281
+ if (!allowCandidateOverride) await cleanMoaWorkspaces(cwd, { runId })
282
+ if (signal?.aborted || promoteErr?.message?.includes('aborted') || promoteErr?.name === 'AbortError' || promoteErr?.message === 'Turn aborted') {
283
+ return { error: new Error('Turn aborted') }
284
+ }
285
+ return { kind: 'failure', error: promoteErr,
286
+ content: '🛑 Checkpoint or Promotion Failure: ' + (promoteErr?.message || String(promoteErr)),
287
+ aggregator: 'Fast Mode (Direct)', references: referenceOutputs,
288
+ presetName: preset?.name || 'default', isRefinement, isFastMode: true,
289
+ winningIndex: single.index, winnerModel: single.label, promotedFiles: [], runId,
290
+ usage: { totalTokens: 0, totalCostUsd: 0, candidates: [], aggregator: { totalTokens: 0, costUsd: 0 } },
291
+ skippedFiles: collectedCtx?.skippedFiles || 0,
292
+ skippedList: collectedCtx?.skippedList || [],
293
+ durationMs: Date.now() - startTime,
294
+ }
295
+ }
284
296
 
285
297
  const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs, { winningIndex: single.index })
286
298
  const totalTokens = single.usage?.totalTokens || 0
@@ -289,44 +301,27 @@ export async function runMoAPipeline({
289
301
 
290
302
  try {
291
303
  await recordMoaRunAsync({
292
- cwd,
293
- prompt: userPrompt,
294
- preset: preset?.name || 'default',
295
- isRefinement,
296
- isFastMode: true,
297
- candidates: candidatesForHistory(referenceOutputs),
298
- aggregator: null,
299
- winnerIndex: single.index,
300
- winnerModel: single.label,
301
- promotedFiles,
302
- totalTokens,
303
- totalCostUsd,
304
- durationMs,
304
+ id: runId, cwd, prompt: userPrompt, preset: preset?.name || 'default',
305
+ isRefinement, isFastMode: true, candidates: candidatesForHistory(referenceOutputs),
306
+ aggregator: null, winnerIndex: single.index, winnerModel: single.label,
307
+ promotedFiles, totalTokens, totalCostUsd, durationMs,
305
308
  }, historyFilePath || undefined)
306
309
  } catch (histErr) {
307
310
  logger.warn('[dsh-moa] Failed to record fast synthesis run in history:', histErr?.message || histErr)
308
311
  }
309
312
 
310
313
  return {
311
- kind: 'synthesis',
312
- content: single.text,
313
- aggregator: 'Fast Mode (Direct)',
314
- references: referenceOutputs,
315
- presetName: preset?.name || 'default',
316
- isRefinement,
317
- isFastMode: true,
318
- winningIndex: single.index,
319
- winnerModel: single.label,
320
- promotedFiles,
314
+ kind: 'synthesis', content: single.text, aggregator: 'Fast Mode (Direct)',
315
+ references: referenceOutputs, presetName: preset?.name || 'default',
316
+ isRefinement, isFastMode: true, winningIndex: single.index,
317
+ winnerModel: single.label, promotedFiles, runId,
321
318
  ...(livePreview ? { liveCanvas: livePreview } : {}),
322
319
  usage: {
323
- totalTokens,
324
- totalCostUsd,
320
+ totalTokens, totalCostUsd,
325
321
  candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
326
322
  aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
327
323
  },
328
- skippedFiles: collectedCtx?.skippedFiles || 0,
329
- skippedList: collectedCtx?.skippedList || [],
324
+ skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [],
330
325
  durationMs,
331
326
  }
332
327
  }
@@ -502,16 +497,26 @@ export async function runMoAPipeline({
502
497
  let promotedFiles = []
503
498
 
504
499
  const keepWorkspaces = allowCandidateOverride
505
- if (synthesizedFiles.length > 0) {
506
- await writeCandidateWorkspace(cwd, 'curator-synthesis', synthesizedFiles, { runId })
507
- promotedFiles = await promoteCandidateWorkspace(cwd, 'curator-synthesis', { ...promoteOpts, runId, keepMoa: keepWorkspaces })
508
- } else if (referenceOutputs[winningIndex - 1]?.files?.length > 0) {
509
- promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex, { ...promoteOpts, runId, keepMoa: keepWorkspaces })
510
- } else if (successfulRefs[0]?.files?.length > 0) {
511
- const fallbackIdx = successfulRefs[0].index
512
- promotedFiles = await promoteCandidateWorkspace(cwd, fallbackIdx, { ...promoteOpts, runId, keepMoa: keepWorkspaces })
513
- } else if (!keepWorkspaces) {
514
- await cleanMoaWorkspaces(cwd, { runId })
500
+ if (signal?.aborted) {
501
+ if (!keepWorkspaces) await cleanMoaWorkspaces(cwd, { runId })
502
+ return { error: new Error('Turn aborted') }
503
+ }
504
+ try {
505
+ if (synthesizedFiles.length > 0) {
506
+ await writeCandidateWorkspace(cwd, 'curator-synthesis', synthesizedFiles, { runId })
507
+ promotedFiles = await promoteCandidateWorkspace(cwd, 'curator-synthesis', { ...promoteOpts, runId, keepMoa: keepWorkspaces })
508
+ } else if (referenceOutputs[winningIndex - 1]?.files?.length > 0) {
509
+ promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex, { ...promoteOpts, runId, keepMoa: keepWorkspaces })
510
+ } else if (successfulRefs[0]?.files?.length > 0) {
511
+ const fallbackIdx = successfulRefs[0].index
512
+ promotedFiles = await promoteCandidateWorkspace(cwd, fallbackIdx, { ...promoteOpts, runId, keepMoa: keepWorkspaces })
513
+ } else if (!keepWorkspaces) {
514
+ await cleanMoaWorkspaces(cwd, { runId })
515
+ }
516
+ } catch (promoteErr) {
517
+ if (!keepWorkspaces) await cleanMoaWorkspaces(cwd, { runId })
518
+ if (signal?.aborted || promoteErr?.message === 'Turn aborted') return { error: new Error('Turn aborted') }
519
+ throw promoteErr
515
520
  }
516
521
 
517
522
  const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs, { winningIndex, synthesizedFiles })
@@ -582,8 +587,7 @@ export async function runMoAPipeline({
582
587
  consensus: multiJudgeResult,
583
588
  ...(livePreview ? { liveCanvas: livePreview } : {}),
584
589
  usage: usageSummary,
585
- skippedFiles: collectedCtx?.skippedFiles || 0,
586
- skippedList: collectedCtx?.skippedList || [],
590
+ skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [],
587
591
  durationMs,
588
592
  }
589
593
  }
package/lib/moa-stream.js CHANGED
@@ -4,7 +4,7 @@ import crypto from 'node:crypto'
4
4
  */
5
5
 
6
6
  import { cleanMoaWorkspaces, promoteCandidateWorkspace } from './file-workspace.js'
7
- import { isTestEnvironment } from './history.js'
7
+ import { isTestEnvironment, getMoaHistory } from './history.js'
8
8
  import { formatMoAResponse } from './moa-parser.js'
9
9
  import { runMoAPipeline } from './moa-runner.js'
10
10
 
@@ -57,6 +57,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
57
57
  timeMachineEngine,
58
58
  force,
59
59
  signal: combinedSignal,
60
+ runId,
60
61
  })
61
62
  .catch((err) => ({ error: err }))
62
63
  .finally(() => {
@@ -152,10 +153,11 @@ export async function* streamPromoteTurn({ runId, candidateIndex, cwd, webServer
152
153
  }
153
154
 
154
155
  try {
155
- yield { type: 'text-delta', index: 0, text: `🔄 *Promoting candidate ${candidateIndex}${runId ? ` for run \`${runId}\`` : ''}...*\n\n` }
156
+ const effectiveRunId = runId || getMoaHistory(1, 0).runs[0]?.id || null
157
+ yield { type: 'text-delta', index: 0, text: `🔄 *Promoting candidate ${candidateIndex}${effectiveRunId ? ` for run \`${effectiveRunId}\`` : ''}...*\n\n` }
156
158
 
157
159
  const promotedFiles = await promoteCandidateWorkspace(cwd, candidateIndex, {
158
- runId,
160
+ runId: effectiveRunId,
159
161
  keepMoa: true,
160
162
  port: webServerPort,
161
163
  timeMachineEngine,
@@ -163,7 +165,7 @@ export async function* streamPromoteTurn({ runId, candidateIndex, cwd, webServer
163
165
  })
164
166
 
165
167
  if (!promotedFiles || promotedFiles.length === 0) {
166
- const noFilesText = `⚠️ **MoA Promote Notice**: No candidate files found for candidate ${candidateIndex}${runId ? ` in run \`${runId}\`` : ''}. Workspace may have expired or run does not exist.`
168
+ const noFilesText = `⚠️ **MoA Promote Notice**: No candidate files found for candidate ${candidateIndex}${effectiveRunId ? ` in run \`${effectiveRunId}\`` : ''}. Workspace may have expired or run does not exist.`
167
169
  yield { type: 'text-delta', index: 0, text: noFilesText }
168
170
  yield { type: 'block-end', index: 0, block: { type: 'text', text: noFilesText } }
169
171
  yield { type: 'finish', reason: { kind: 'stop' } }
@@ -115,14 +115,50 @@ export async function runCandidateTestGate({
115
115
  stderr = customRes?.stderr || ''
116
116
  } else {
117
117
  const nodePath = path.join(cwd, 'node_modules')
118
+ const allowedReads = [
119
+ executionDir,
120
+ cwd,
121
+ ...(candidateDir && candidateDir !== executionDir ? [candidateDir] : []),
122
+ ...(process.platform === 'win32'
123
+ ? [process.env.SYSTEMROOT || 'C:\\Windows', path.dirname(process.execPath)]
124
+ : ['/usr', '/lib', '/lib64', '/etc', path.resolve(path.dirname(process.execPath), '..')]),
125
+ ].filter(Boolean)
126
+ const permOpts = `--permission ${allowedReads.map((p) => `--allow-fs-read=${p}`).join(' ')} --allow-fs-write=${executionDir}`
127
+
118
128
  const env = getScrubbedEnv({
119
129
  MOA_CANDIDATE_INDEX: String(candidateIndex),
120
130
  MOA_CANDIDATE_DIR: candidateDir,
121
131
  MOA_BASE_DIR: cwd,
122
132
  ...(runId ? { MOA_RUN_ID: String(runId) } : {}),
123
133
  NODE_PATH: process.env.NODE_PATH ? `${nodePath}:${process.env.NODE_PATH}` : nodePath,
134
+ NODE_OPTIONS: process.env.NODE_OPTIONS ? `${process.env.NODE_OPTIONS} ${permOpts}` : permOpts,
124
135
  })
125
136
 
137
+ const allowedRoots = [executionDir, cwd, ...(candidateDir && candidateDir !== executionDir ? [candidateDir] : [])]
138
+ const cmdTokens = resolvedCmd.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []
139
+ for (const rawToken of cmdTokens) {
140
+ const token = rawToken.replace(/^["']|["']$/g, '')
141
+ if (path.isAbsolute(token)) {
142
+ if (/^\/(usr\/(bin|lib|include)|bin|lib|lib64|etc)/.test(token) && !/\.(txt|json|env|npmrc|key|ssh|md)/i.test(token)) {
143
+ continue
144
+ }
145
+ const real = path.resolve(token)
146
+ const allowed = allowedRoots.some((d) => d && (real === d || real.startsWith(d + path.sep)))
147
+ if (!allowed) {
148
+ const durationMs = Date.now() - startTime
149
+ return {
150
+ candidateIndex,
151
+ passed: false,
152
+ exitCode: 1,
153
+ durationMs,
154
+ command: resolvedCmd,
155
+ output: 'Access denied: test command specifies path outside workspace sandbox',
156
+ summary: `FAIL (code 1, ${durationMs}ms)`,
157
+ }
158
+ }
159
+ }
160
+ }
161
+
126
162
  const runPromise = new Promise((resolve) => {
127
163
  let child
128
164
  let timeoutHandle = null
package/lib/routes.js CHANGED
@@ -2,12 +2,7 @@
2
2
  * DeepSeek Harness Mixture of Agents (MoA) — HTTP Route Handlers
3
3
  */
4
4
 
5
- import {
6
- getMoaHistory,
7
- getMoaLeaderboard,
8
- getMoaRunById,
9
- exportMoaHistory,
10
- } from './history.js'
5
+ import { getMoaHistory, getMoaLeaderboard, getMoaRunById, exportMoaHistory } from './history.js'
11
6
  import { promoteCandidateWorkspace, readCandidateFiles, computeLineDiff, isSafeRelativePath } from './file-workspace.js'
12
7
  import { isSafeWriteRequest } from './updater.js'
13
8
  import { runMoAPipeline } from './moa-runner.js'
@@ -18,10 +13,7 @@ let _modelsCache = { timestamp: 0, models: [] }
18
13
  const MODELS_CACHE_TTL = 60 * 1000
19
14
 
20
15
  function writeJson(res, status, body) {
21
- res.writeHead(status, {
22
- 'Content-Type': 'application/json; charset=utf-8',
23
- 'Cache-Control': 'no-store',
24
- })
16
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
25
17
  res.end(JSON.stringify(body))
26
18
  }
27
19
 
@@ -30,10 +22,7 @@ function readBody(req) {
30
22
  let acc = ''
31
23
  req.on('data', (chunk) => {
32
24
  acc += chunk
33
- if (acc.length > 5 * 1024 * 1024) {
34
- req.destroy()
35
- reject(new Error('Payload Too Large'))
36
- }
25
+ if (acc.length > 5 * 1024 * 1024) { req.destroy(); reject(new Error('Payload Too Large')) }
37
26
  })
38
27
  req.on('end', () => resolve(acc))
39
28
  req.on('error', reject)
@@ -356,9 +345,11 @@ ctx.effect(() => {
356
345
  }
357
346
  try {
358
347
  const u = new URL(req.url, 'http://localhost')
359
- const runId = u.searchParams.get('runId') || undefined
348
+ const rawRunId = u.searchParams.get('runId')
349
+ const latestRun = (!rawRunId || rawRunId === 'latest' || rawRunId === 'active') ? getMoaHistory(1, 0).runs[0] : null
350
+ const runId = (rawRunId && rawRunId !== 'latest' && rawRunId !== 'active') ? rawRunId : latestRun?.id
360
351
  const run = runId ? getMoaRunById(runId) : null
361
- const cwd = run?.cwd || u.searchParams.get('cwd') || process.cwd()
352
+ const cwd = run?.cwd || latestRun?.cwd || u.searchParams.get('cwd') || process.cwd()
362
353
  const sanitizeTarget = (val, def) => {
363
354
  const clean = String(val || '').trim().replace(/[^a-zA-Z0-9_-]/g, '')
364
355
  return clean || def
@@ -511,11 +502,14 @@ ctx.effect(() => {
511
502
  const raw = await readBody(req)
512
503
  const body = JSON.parse(raw || '{}')
513
504
  const prompt = body?.prompt || ''
505
+ const activePreset = (cfg.presets || []).find((p) => p.name === (cfg.default_preset || 'default')) || cfg.presets?.[0]
506
+ const routingModel = (activePreset?.smart_routing_model?.model && activePreset.smart_routing_model)
507
+ || (cfg.smart_routing_model?.model && cfg.smart_routing_model) || activePreset?.smart_routing_model || cfg.smart_routing_model
514
508
  const result = await resolvePresetForPrompt({
515
509
  prompt,
516
510
  presets: cfg.presets || [],
517
511
  defaultPreset: cfg.default_preset || 'default',
518
- routingModel: cfg.smart_routing_model,
512
+ routingModel,
519
513
  callLlm,
520
514
  enabled: cfg.enabled !== false,
521
515
  })
@@ -575,12 +569,18 @@ ctx.effect(() => {
575
569
  timeMachineEngine: timeMachineEngine || ctx.reflect?.get?.('timeMachineEngine', false),
576
570
  timeMachineUrl: payload.timeMachineUrl, force: payload.force,
577
571
  checkpoint: payload.checkpoint, checkpointFn: payload.checkpointFn,
572
+ runId: payload.runId,
578
573
  })
579
574
 
580
575
  if (result?.skippedFiles > 0) {
581
576
  ctx.logger?.warn?.(`[dsh-moa] Workspace scan skipped ${result.skippedFiles} file(s): ${result.skippedList.join(', ')}`)
582
577
  }
583
578
 
579
+ if (result?.error) {
580
+ writeJson(res, 500, { ok: false, error: result.error?.message || String(result.error) })
581
+ return
582
+ }
583
+
584
584
  writeJson(res, 200, { ok: true, ...result })
585
585
  } catch (err) {
586
586
  writeJson(res, 500, { ok: false, error: err?.message || String(err) })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.42",
3
+ "version": "0.2.44",
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",