@goodandready/dsh-moa 0.2.41 → 0.2.42

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/README.md CHANGED
@@ -116,6 +116,13 @@ The flag form is equivalent:
116
116
  /moa --preset=deep-reasoning solve this math problem step by step
117
117
  ```
118
118
 
119
+ Or promote a candidate solution directly (by candidate index or runId + candidate index):
120
+
121
+ ```text
122
+ /moa promote 2
123
+ /moa promote <runId> 2
124
+ ```
125
+
119
126
  ### 2. Adaptive Questionnaire Gate
120
127
  When prompts are open-ended or lack architectural specifications (e.g. *"build a calculator app"*), advisor models detect ambiguities and formulate focused clarifying questions (e.g., UI style, persistence backend, framework choice) before generating code.
121
128
 
package/README.ru.md CHANGED
@@ -116,6 +116,13 @@ graph TD
116
116
  /moa --preset=deep-reasoning реши эту математическую задачу по шагам
117
117
  ```
118
118
 
119
+ Или прямое продвижение решения кандидата (по индексу кандидата или runId + индексу):
120
+
121
+ ```text
122
+ /moa promote 2
123
+ /moa promote <runId> 2
124
+ ```
125
+
119
126
  ### 2. Адаптивный гейт уточнения требований
120
127
  Когда запрос сформулирован слишком обобщенно (например, *"сделай калькулятор"*), советники определяют недостающие архитектурные требования и формулируют целевые вопросы (стиль интерфейса, сохранение состояния, стек технологий) до генерации кода.
121
128
 
package/README.zh.md CHANGED
@@ -116,6 +116,13 @@ graph TD
116
116
  /moa --preset=deep-reasoning 逐步求解这道数学题
117
117
  ```
118
118
 
119
+ 或直接提升候选者方案(支持指定 runId 与候选序号):
120
+
121
+ ```text
122
+ /moa promote 2
123
+ /moa promote <runId> 2
124
+ ```
125
+
119
126
  ### 2. 自适应澄清问卷
120
127
  当需求过于抽象(例如 *"制作一个计算器"*)时,系统会在生成代码前主动询问 UI 风格、数据持久化方式或框架偏好。
121
128
 
@@ -128,6 +135,7 @@ graph TD
128
135
  * 各提案模型在 `.moa/candidate-1/`、`.moa/candidate-2/` 等独立目录生成工程代码。
129
136
  * 裁判模型对比各版本实现,通过 `WINNER_CANDIDATE_INDEX: N` 指定最优方案。
130
137
  * 胜出方案自动提升至项目根目录,临时沙箱随后自动清理。
138
+ * 支持随时通过命令 `/moa promote [runId] <candidateIndex>` 或 UI 界面一键手动提升指定候选者的方案。
131
139
 
132
140
  ### 5. 原生设置卡片与预设管理
133
141
  在 `设置 → 插件 → Mixture of Agents` 中可视化配置模型:
@@ -234,6 +242,9 @@ dsh-moa:
234
242
  | `/dsh-moa/leaderboard` | `GET` | 计算模型胜率排行榜与平均执行成本 |
235
243
  | `/dsh-moa/runs/<id>` | `GET` | 按 id 返回单条运行记录 |
236
244
  | `/dsh-moa/run` | `POST` | 通过 HTTP 运行完整 MoA 管线(`enabled: false` 时返回 400) |
245
+ | `/dsh-moa/diff` | `GET` | 计算候选运行结果或综合输出的行级别差异(支持按 runId 解析工作区) |
246
+ | `/dsh-moa/promote` | `POST` | 手动将候选工作区文件提升到项目根目录(带时间机器快照) |
247
+ | `/api/dsh-moa/update` | `POST` | 从 npm 一键更新插件,带安全写入源校验 |
237
248
 
238
249
  ---
239
250
 
package/lib/client.js CHANGED
@@ -142,6 +142,64 @@ window.__ModuleLoader__.load({
142
142
  'diff.curator': 'Curator Synthesis',
143
143
  'diff.candidate': 'Candidate {num}',
144
144
  'diff.empty': 'Selected files are identical (zero diff).',
145
+ // Multi-Judge
146
+ 'aggregator.multi_judge_title': 'Multi-Judge Panel & Consensus Voting',
147
+ 'aggregator.multi_judge_desc': 'Deploy multiple judge models to score candidates and determine winner via consensus voting.',
148
+ 'aggregator.voting_strategy_label': 'Voting Strategy:',
149
+ 'aggregator.voting_majority': 'Majority Vote',
150
+ 'aggregator.voting_highest_score': 'Highest Score',
151
+ 'aggregator.voting_unanimous': 'Unanimous',
152
+ 'aggregator.judge_models_title': 'Judge Panel Models',
153
+ 'aggregator.judge_models_desc': 'Specify 2 or more judge models for panel consensus.',
154
+ 'aggregator.add_judge_btn': '+ Add Judge Model',
155
+ 'aggregator.judge_badge': 'Judge #{num}',
156
+ 'aggregator.judge_models_empty': 'At least 2 judge models are required for consensus voting.',
157
+
158
+ // Composite
159
+ 'aggregator.composite_title': 'Composite Hybrid Synthesis (AST / Block Merge)',
160
+ 'aggregator.composite_desc': 'Synthesize modular code blocks, types, and algorithms from multiple candidates into a unified composite.',
161
+
162
+ // Budget
163
+ 'aggregator.budget_title': 'Cost Budget Guardrails & Auto-Fallback',
164
+ 'aggregator.budget_desc': 'Cap maximum run cost; auto-trim candidate pool or abort to prevent accidental token spend.',
165
+ 'aggregator.budget_max_usd': 'Max USD ($):',
166
+ 'aggregator.budget_action': 'Action:',
167
+ 'aggregator.budget_action_trim': 'Trim Pool',
168
+ 'aggregator.budget_action_abort': 'Abort',
169
+
170
+ // Reports
171
+ 'aggregator.report_title': 'Automated Benchmark & Post-Mortem Report',
172
+ 'aggregator.report_desc': 'Generate downloadable Markdown & JSON reports detailing candidate latency, agreement, test gate, and cost breakdown.',
173
+
174
+ // Local Fallback
175
+ 'aggregator.local_fallback_title': 'Graceful Degradation & Local Fallback',
176
+ 'aggregator.local_fallback_desc': 'Automatically fall back to local Ollama / MiniPC models when online candidate APIs fail.',
177
+ 'aggregator.local_models_title': 'Local Fallback Models',
178
+ 'aggregator.local_models_desc': 'Local offline models used when cloud candidate APIs fail.',
179
+ 'aggregator.add_local_btn': '+ Add Local Model',
180
+ 'aggregator.local_badge': 'Local #{num}',
181
+ 'aggregator.local_models_empty': 'Configure at least one local fallback model (e.g. Ollama).',
182
+
183
+ // Smart Routing Model
184
+ 'config.smart_routing_title': 'Smart Prompt Routing',
185
+ 'config.smart_routing_desc': 'Analyze task complexity and automatically select the optimal preset.',
186
+ 'config.smart_routing_model': 'Routing Model:',
187
+
188
+ // Multi-Turn Context
189
+ 'aggregator.multi_turn_title': 'Multi-Turn Context & Diff Pruning',
190
+ 'aggregator.multi_turn_desc': 'Retain conversation context across turns while pruning duplicate code blocks.',
191
+
192
+ // UI States & Errors
193
+ 'status.loading': 'Loading MoA settings…',
194
+ 'status.unavailable': 'MoA service temporarily unavailable',
195
+ 'status.retry': 'Retry',
196
+ 'status.error_saving': 'Error saving: ',
197
+ 'error_boundary.title': '⚠️ MoA UI Error:',
198
+ 'error_boundary.retry': 'Retry',
199
+
200
+ // Diff Viewer
201
+ 'diff.select_run': 'Run:',
202
+ 'diff.latest_run': 'Latest / Active Workspace',
145
203
  'updater.success': 'Successfully updated to v{version}. Restart DSH to apply.',
146
204
  }
147
205
 
@@ -260,6 +318,64 @@ window.__ModuleLoader__.load({
260
318
  'diff.curator': '裁判合成 (Curator Synthesis)',
261
319
  'diff.candidate': '候选方案 {num}',
262
320
  'diff.empty': '选中的文件完全一致(无差异)。',
321
+ // Multi-Judge
322
+ 'aggregator.multi_judge_title': '多裁判委员会与共识投票',
323
+ 'aggregator.multi_judge_desc': '部署多个裁判模型对候选方案打分并通过共识投票评定获胜者。',
324
+ 'aggregator.voting_strategy_label': '投票策略:',
325
+ 'aggregator.voting_majority': '多数决',
326
+ 'aggregator.voting_highest_score': '最高分',
327
+ 'aggregator.voting_unanimous': '全票通过',
328
+ 'aggregator.judge_models_title': '裁判模型列表',
329
+ 'aggregator.judge_models_desc': '指定 2 个或更多裁判模型以实现委员会共识。',
330
+ 'aggregator.add_judge_btn': '+ 添加裁判模型',
331
+ 'aggregator.judge_badge': '裁判 #{num}',
332
+ 'aggregator.judge_models_empty': '共识投票至少需要配置 2 个裁判模型。',
333
+
334
+ // Composite
335
+ 'aggregator.composite_title': '混合复合综合 (AST / 代码块合并)',
336
+ 'aggregator.composite_desc': '从多个候选方案中提取模块化代码块、类型定义与算法并综合为统一方案。',
337
+
338
+ // Budget
339
+ 'aggregator.budget_title': '成本预算护栏与自动分流',
340
+ 'aggregator.budget_desc': '限制单次最高花费;自动削减候选模型或中止以防止超额消耗。',
341
+ 'aggregator.budget_max_usd': '最高金额 ($):',
342
+ 'aggregator.budget_action': '处置方式:',
343
+ 'aggregator.budget_action_trim': '削减候选',
344
+ 'aggregator.budget_action_abort': '中止运行',
345
+
346
+ // Reports
347
+ 'aggregator.report_title': '自动化基准与复盘报告',
348
+ 'aggregator.report_desc': '生成可下载的 Markdown 与 JSON 报告,详细记录候选方案耗时、一致性、测试门禁与成本分布。',
349
+
350
+ // Local Fallback
351
+ 'aggregator.local_fallback_title': '优雅降级与本地备用模型',
352
+ 'aggregator.local_fallback_desc': '在线候选模型 API 故障时自动降级到本地 Ollama / MiniPC 模型。',
353
+ 'aggregator.local_models_title': '本地备用模型列表',
354
+ 'aggregator.local_models_desc': '云端候选 API 异常时调用的本地离线模型。',
355
+ 'aggregator.add_local_btn': '+ 添加本地模型',
356
+ 'aggregator.local_badge': '本地 #{num}',
357
+ 'aggregator.local_models_empty': '请至少配置一个本地备用模型(例如 Ollama)。',
358
+
359
+ // Smart Routing Model
360
+ 'config.smart_routing_title': '智能提示词分流',
361
+ 'config.smart_routing_desc': '分析任务复杂度并自动匹配最佳预设。',
362
+ 'config.smart_routing_model': '分流决策模型:',
363
+
364
+ // Multi-Turn Context
365
+ 'aggregator.multi_turn_title': '多轮对话上下文与差异剪枝',
366
+ 'aggregator.multi_turn_desc': '跨轮次保留对话上下文并剪枝重复代码块。',
367
+
368
+ // UI States & Errors
369
+ 'status.loading': '正在加载 MoA 设置…',
370
+ 'status.unavailable': 'MoA 服务暂不可用',
371
+ 'status.retry': '重试',
372
+ 'status.error_saving': '保存失败:',
373
+ 'error_boundary.title': '⚠️ MoA 界面错误:',
374
+ 'error_boundary.retry': '重试',
375
+
376
+ // Diff Viewer
377
+ 'diff.select_run': '运行记录:',
378
+ 'diff.latest_run': '最新 / 当前工作区',
263
379
  'updater.success': '成功更新至 v{version},请重启 DSH 使其生效。',
264
380
  }
265
381
 
@@ -307,6 +423,55 @@ window.__ModuleLoader__.load({
307
423
  Chevron = FallbackChevron
308
424
  }
309
425
 
426
+
427
+ function renderSlotEditorList({ items, badgePrefix, onUpdate, onAdd, onRemove, addLabel, emptyWarning, availableModels, t }) {
428
+ return React.createElement(
429
+ 'div',
430
+ { style: { display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 } },
431
+ (Array.isArray(items) ? items : []).map((item, idx) =>
432
+ React.createElement(
433
+ 'div',
434
+ { key: idx, style: { display: 'flex', alignItems: 'center', gap: 8 } },
435
+ React.createElement('span', { className: 'moa-cand-badge' }, `${badgePrefix} #${idx + 1}`),
436
+ React.createElement(
437
+ 'div',
438
+ { style: { flex: 1 } },
439
+ React.createElement(SearchableModelPicker, {
440
+ provider: (item && item.provider) || '',
441
+ model: (item && item.model) || '',
442
+ onChange: (prov, mod) => onUpdate(idx, prov, mod),
443
+ availableModels,
444
+ t,
445
+ })
446
+ ),
447
+ React.createElement(
448
+ 'button',
449
+ {
450
+ type: 'button',
451
+ className: 'moa-btn moa-btn-danger moa-btn-sub',
452
+ onClick: () => onRemove(idx),
453
+ },
454
+ '✕'
455
+ )
456
+ )
457
+ ),
458
+ React.createElement(
459
+ 'button',
460
+ {
461
+ type: 'button',
462
+ className: 'moa-btn moa-btn-sub',
463
+ style: { marginTop: 4, alignSelf: 'flex-start' },
464
+ onClick: onAdd,
465
+ },
466
+ addLabel
467
+ ),
468
+ emptyWarning ? React.createElement(
469
+ 'div',
470
+ { style: { fontSize: 11, color: 'var(--dsw-alias-state-warning-primary, #e6a23c)', marginTop: 2 } },
471
+ emptyWarning
472
+ ) : null
473
+ )
474
+ }
310
475
  const STYLES = `
311
476
  /* dsh-moa modern clinebot-aligned styling */
312
477
  .moa-page{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:960px}
@@ -635,8 +800,25 @@ window.__ModuleLoader__.load({
635
800
  const [loading, setLoading] = React.useState(false)
636
801
  const [error, setError] = React.useState(null)
637
802
  const [hasFetched, setHasFetched] = React.useState(false)
803
+ const [historyRuns, setHistoryRuns] = React.useState([])
804
+ const [selectedRunId, setSelectedRunId] = React.useState('')
805
+
806
+ React.useEffect(() => {
807
+ fetch('/dsh-moa/history?limit=30', { cache: 'no-store' })
808
+ .then((r) => r.json())
809
+ .then((data) => {
810
+ if (data && data.ok && Array.isArray(data.runs)) {
811
+ setHistoryRuns(data.runs)
812
+ }
813
+ })
814
+ .catch(() => {})
815
+ }, [])
816
+
817
+ const selectedRun = historyRuns.find((r) => r.id === selectedRunId)
818
+ const candidateCount = selectedRun && Array.isArray(selectedRun.candidates) && selectedRun.candidates.length > 0
819
+ ? selectedRun.candidates.length
820
+ : Math.max(currentRefs.length || 0, 2)
638
821
 
639
- const candidateCount = Math.max(currentRefs.length || 0, 2)
640
822
  const candidateOptions = []
641
823
  for (let i = 1; i <= candidateCount; i++) {
642
824
  candidateOptions.push({
@@ -649,6 +831,7 @@ window.__ModuleLoader__.load({
649
831
  setLoading(true)
650
832
  setError(null)
651
833
  const params = new URLSearchParams()
834
+ if (selectedRunId) params.set('runId', selectedRunId)
652
835
  if (fromCandidate) params.set('from', fromCandidate)
653
836
  if (toCandidate) params.set('to', toCandidate)
654
837
  if (targetFile) params.set('file', targetFile)
@@ -679,11 +862,11 @@ window.__ModuleLoader__.load({
679
862
  setHasFetched(true)
680
863
  setError(err.message || String(err))
681
864
  })
682
- }, [fromCandidate, toCandidate, selectedFile])
865
+ }, [fromCandidate, toCandidate, selectedFile, selectedRunId])
683
866
 
684
867
  React.useEffect(() => {
685
868
  loadDiff()
686
- }, [fromCandidate, toCandidate])
869
+ }, [fromCandidate, toCandidate, selectedRunId])
687
870
 
688
871
  const handleFileChange = (e) => {
689
872
  const nextFile = e.target.value
@@ -707,6 +890,25 @@ window.__ModuleLoader__.load({
707
890
  React.createElement(
708
891
  'div',
709
892
  { className: 'moa-diff-controls' },
893
+ /* Run selector dropdown (#179) */
894
+ React.createElement(
895
+ 'label',
896
+ { style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
897
+ t('diff.select_run') || 'Run:',
898
+ React.createElement(
899
+ 'select',
900
+ {
901
+ className: 'moa-diff-select',
902
+ value: selectedRunId,
903
+ onChange: (e) => setSelectedRunId(e.target.value),
904
+ },
905
+ React.createElement('option', { value: '' }, t('diff.latest_run') || 'Latest / Active Workspace'),
906
+ historyRuns.map((r) => {
907
+ const label = `${r.preset || 'default'} [${(r.id || '').slice(0, 8)}] ${(r.prompt || '').slice(0, 24)}...`
908
+ return React.createElement('option', { key: r.id, value: r.id }, label)
909
+ })
910
+ )
911
+ ),
710
912
  React.createElement(
711
913
  'label',
712
914
  { style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
@@ -1331,7 +1533,7 @@ window.__ModuleLoader__.load({
1331
1533
  })
1332
1534
  )
1333
1535
  ),
1334
- /* Multi-Judge Panel & Consensus Voting */
1536
+ /* Multi-Judge Panel & Consensus Voting (#175, #176) */
1335
1537
  React.createElement(
1336
1538
  'div',
1337
1539
  { style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '8px 12px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2)', marginTop: 8 } },
@@ -1344,29 +1546,62 @@ window.__ModuleLoader__.load({
1344
1546
  onChange: (e) => updateCurrentPreset((p) => ({ ...p, multi_judge_enabled: e.target.checked })),
1345
1547
  style: { accentColor: 'var(--dsw-alias-state-brand-primary, var(--dsw-alias-label-primary))', cursor: 'pointer' },
1346
1548
  }),
1347
- 'Multi-Judge Panel & Consensus Voting'
1549
+ t('aggregator.multi_judge_title') || 'Multi-Judge Panel & Consensus Voting'
1348
1550
  ),
1349
1551
  React.createElement(
1350
1552
  'div',
1351
1553
  { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginLeft: 22 } },
1352
- 'Deploy multiple judge models to score candidates and determine winner via consensus voting.'
1554
+ t('aggregator.multi_judge_desc') || 'Deploy multiple judge models to score candidates and determine winner via consensus voting.'
1353
1555
  ),
1354
1556
  currentPreset.multi_judge_enabled && React.createElement(
1355
1557
  'div',
1356
- { style: { marginLeft: 22, display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 } },
1357
- React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Voting Strategy:'),
1558
+ { style: { marginLeft: 22, display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 } },
1358
1559
  React.createElement(
1359
- 'select',
1360
- {
1361
- className: 'moa-select',
1362
- style: { width: 140, height: 28, fontSize: 12, padding: '0 6px' },
1363
- value: currentPreset.judge_voting_strategy || 'majority',
1364
- onChange: (e) => updateCurrentPreset((p) => ({ ...p, judge_voting_strategy: e.target.value })),
1560
+ 'div',
1561
+ { style: { display: 'flex', alignItems: 'center', gap: 8 } },
1562
+ React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, t('aggregator.voting_strategy_label') || 'Voting Strategy:'),
1563
+ React.createElement(
1564
+ 'select',
1565
+ {
1566
+ className: 'moa-select',
1567
+ style: { width: 140, height: 28, fontSize: 12, padding: '0 6px' },
1568
+ value: currentPreset.judge_voting_strategy || 'majority',
1569
+ onChange: (e) => updateCurrentPreset((p) => ({ ...p, judge_voting_strategy: e.target.value })),
1570
+ },
1571
+ React.createElement('option', { value: 'majority' }, t('aggregator.voting_majority') || 'Majority Vote'),
1572
+ React.createElement('option', { value: 'highest_score' }, t('aggregator.voting_highest_score') || 'Highest Score'),
1573
+ React.createElement('option', { value: 'unanimous' }, t('aggregator.voting_unanimous') || 'Unanimous')
1574
+ )
1575
+ ),
1576
+ renderSlotEditorList({
1577
+ items: currentPreset.judge_models,
1578
+ badgePrefix: 'Judge',
1579
+ onUpdate: (idx, prov, mod) => {
1580
+ updateCurrentPreset((p) => {
1581
+ const list = [...(p.judge_models || [])]
1582
+ list[idx] = { provider: prov, model: mod }
1583
+ return { ...p, judge_models: list }
1584
+ })
1365
1585
  },
1366
- React.createElement('option', { value: 'majority' }, 'Majority Vote'),
1367
- React.createElement('option', { value: 'highest_score' }, 'Highest Score'),
1368
- React.createElement('option', { value: 'unanimous' }, 'Unanimous')
1369
- )
1586
+ onRemove: (idx) => {
1587
+ updateCurrentPreset((p) => ({
1588
+ ...p,
1589
+ judge_models: (p.judge_models || []).filter((_, i) => i !== idx),
1590
+ }))
1591
+ },
1592
+ onAdd: () => {
1593
+ updateCurrentPreset((p) => ({
1594
+ ...p,
1595
+ judge_models: [...(p.judge_models || []), { provider: (availableModels[0] && availableModels[0].provider) || '', model: (availableModels[0] && availableModels[0].model) || '' }],
1596
+ }))
1597
+ },
1598
+ addLabel: t('aggregator.add_judge_btn') || '+ Add Judge Model',
1599
+ emptyWarning: (!currentPreset.judge_models || currentPreset.judge_models.length < 2)
1600
+ ? (t('aggregator.judge_models_empty') || 'At least 2 judge models are required for consensus voting.')
1601
+ : null,
1602
+ availableModels,
1603
+ t,
1604
+ })
1370
1605
  )
1371
1606
  ),
1372
1607
 
@@ -1383,12 +1618,12 @@ window.__ModuleLoader__.load({
1383
1618
  onChange: (e) => updateCurrentPreset((p) => ({ ...p, composite_merge_enabled: e.target.checked })),
1384
1619
  style: { accentColor: 'var(--dsw-alias-state-brand-primary, var(--dsw-alias-label-primary))', cursor: 'pointer' },
1385
1620
  }),
1386
- 'Composite Hybrid Synthesis (AST / Block Merge)'
1621
+ t('aggregator.composite_title') || 'Composite Hybrid Synthesis (AST / Block Merge)'
1387
1622
  ),
1388
1623
  React.createElement(
1389
1624
  'div',
1390
1625
  { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginLeft: 22 } },
1391
- 'Synthesize modular code blocks, types, and algorithms from multiple candidates into a unified composite.'
1626
+ t('aggregator.composite_desc') || 'Synthesize modular code blocks, types, and algorithms from multiple candidates into a unified composite.'
1392
1627
  )
1393
1628
  ),
1394
1629
 
@@ -1405,12 +1640,12 @@ window.__ModuleLoader__.load({
1405
1640
  onChange: (e) => updateCurrentPreset((p) => ({ ...p, budget_guard_enabled: e.target.checked })),
1406
1641
  style: { accentColor: 'var(--dsw-alias-state-brand-primary, var(--dsw-alias-label-primary))', cursor: 'pointer' },
1407
1642
  }),
1408
- 'Cost Budget Guardrails & Auto-Fallback'
1643
+ t('aggregator.budget_title') || 'Cost Budget Guardrails & Auto-Fallback'
1409
1644
  ),
1410
1645
  React.createElement(
1411
1646
  'div',
1412
1647
  { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginLeft: 22 } },
1413
- 'Cap maximum run cost; auto-trim candidate pool or abort to prevent accidental token spend.'
1648
+ t('aggregator.budget_desc') || 'Cap maximum run cost; auto-trim candidate pool or abort to prevent accidental token spend.'
1414
1649
  ),
1415
1650
  currentPreset.budget_guard_enabled && React.createElement(
1416
1651
  'div',
@@ -1418,7 +1653,7 @@ window.__ModuleLoader__.load({
1418
1653
  React.createElement(
1419
1654
  'div',
1420
1655
  { style: { display: 'flex', alignItems: 'center', gap: 6 } },
1421
- React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Max USD ($):'),
1656
+ React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, t('aggregator.budget_max_usd') || 'Max USD ($):'),
1422
1657
  React.createElement('input', {
1423
1658
  type: 'number',
1424
1659
  step: '0.01',
@@ -1434,7 +1669,7 @@ window.__ModuleLoader__.load({
1434
1669
  React.createElement(
1435
1670
  'div',
1436
1671
  { style: { display: 'flex', alignItems: 'center', gap: 6 } },
1437
- React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Action:'),
1672
+ React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, t('aggregator.budget_action') || 'Action:'),
1438
1673
  React.createElement(
1439
1674
  'select',
1440
1675
  {
@@ -1443,8 +1678,8 @@ window.__ModuleLoader__.load({
1443
1678
  value: currentPreset.budget_action || 'trim',
1444
1679
  onChange: (e) => updateCurrentPreset((p) => ({ ...p, budget_action: e.target.value })),
1445
1680
  },
1446
- React.createElement('option', { value: 'trim' }, 'Trim Pool'),
1447
- React.createElement('option', { value: 'abort' }, 'Abort')
1681
+ React.createElement('option', { value: 'trim' }, t('aggregator.budget_action_trim') || 'Trim Pool'),
1682
+ React.createElement('option', { value: 'abort' }, t('aggregator.budget_action_abort') || 'Abort')
1448
1683
  )
1449
1684
  )
1450
1685
  )
@@ -1463,16 +1698,16 @@ window.__ModuleLoader__.load({
1463
1698
  onChange: (e) => updateCurrentPreset((p) => ({ ...p, report_generation_enabled: e.target.checked })),
1464
1699
  style: { accentColor: 'var(--dsw-alias-state-brand-primary, var(--dsw-alias-label-primary))', cursor: 'pointer' },
1465
1700
  }),
1466
- 'Automated Benchmark & Post-Mortem Report'
1701
+ t('aggregator.report_title') || 'Automated Benchmark & Post-Mortem Report'
1467
1702
  ),
1468
1703
  React.createElement(
1469
1704
  'div',
1470
1705
  { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginLeft: 22 } },
1471
- 'Generate downloadable Markdown & JSON reports detailing candidate latency, agreement, test gate, and cost breakdown.'
1706
+ t('aggregator.report_desc') || 'Generate downloadable Markdown & JSON reports detailing candidate latency, agreement, test gate, and cost breakdown.'
1472
1707
  )
1473
1708
  ),
1474
1709
 
1475
- /* Graceful Degradation & Local Fallback */
1710
+ /* Graceful Degradation & Local Fallback (#175, #176) */
1476
1711
  React.createElement(
1477
1712
  'div',
1478
1713
  { style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '8px 12px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2)', marginTop: 8 } },
@@ -1485,86 +1720,154 @@ window.__ModuleLoader__.load({
1485
1720
  onChange: (e) => updateCurrentPreset((p) => ({ ...p, local_fallback_enabled: e.target.checked })),
1486
1721
  style: { accentColor: 'var(--dsw-alias-state-brand-primary, var(--dsw-alias-label-primary))', cursor: 'pointer' },
1487
1722
  }),
1488
- 'Graceful Degradation & Local Fallback'
1723
+ t('aggregator.local_fallback_title') || 'Graceful Degradation & Local Fallback'
1489
1724
  ),
1490
1725
  React.createElement(
1491
1726
  'div',
1492
1727
  { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginLeft: 22 } },
1493
- 'Automatically fall back to local Ollama / MiniPC models when online candidate APIs fail.'
1728
+ t('aggregator.local_fallback_desc') || 'Automatically fall back to local Ollama / MiniPC models when online candidate APIs fail.'
1729
+ ),
1730
+ currentPreset.local_fallback_enabled && React.createElement(
1731
+ 'div',
1732
+ { style: { marginLeft: 22, marginTop: 4 } },
1733
+ renderSlotEditorList({
1734
+ items: currentPreset.local_fallback_models,
1735
+ badgePrefix: 'Local',
1736
+ onUpdate: (idx, prov, mod) => {
1737
+ updateCurrentPreset((p) => {
1738
+ const list = [...(p.local_fallback_models || [])]
1739
+ list[idx] = { provider: prov, model: mod }
1740
+ return { ...p, local_fallback_models: list }
1741
+ })
1742
+ },
1743
+ onRemove: (idx) => {
1744
+ updateCurrentPreset((p) => ({
1745
+ ...p,
1746
+ local_fallback_models: (p.local_fallback_models || []).filter((_, i) => i !== idx),
1747
+ }))
1748
+ },
1749
+ onAdd: () => {
1750
+ updateCurrentPreset((p) => ({
1751
+ ...p,
1752
+ local_fallback_models: [...(p.local_fallback_models || []), { provider: 'ollama', model: '' }],
1753
+ }))
1754
+ },
1755
+ addLabel: t('aggregator.add_local_btn') || '+ Add Local Model',
1756
+ emptyWarning: (!currentPreset.local_fallback_models || currentPreset.local_fallback_models.length === 0)
1757
+ ? (t('aggregator.local_models_empty') || 'Configure at least one local fallback model (e.g. Ollama).')
1758
+ : null,
1759
+ availableModels,
1760
+ t,
1761
+ })
1494
1762
  )
1495
1763
  ),
1496
1764
 
1497
- /* Fallback Judges Chain */
1765
+ /* Smart Prompt Routing (#176) */
1498
1766
  React.createElement(
1499
1767
  'div',
1500
- { style: { marginTop: 10, display: 'flex', flexDirection: 'column', gap: 6 } },
1768
+ { style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '8px 12px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2)', marginTop: 8 } },
1501
1769
  React.createElement(
1502
- 'div',
1503
- { style: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } },
1504
- t('aggregator.fallbacks_title')
1770
+ 'label',
1771
+ { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13, fontWeight: 500 } },
1772
+ React.createElement('input', {
1773
+ type: 'checkbox',
1774
+ checked: Boolean(currentPreset.smart_routing_enabled),
1775
+ onChange: (e) => updateCurrentPreset((p) => ({ ...p, smart_routing_enabled: e.target.checked })),
1776
+ style: { accentColor: 'var(--dsw-alias-state-brand-primary, var(--dsw-alias-label-primary))', cursor: 'pointer' },
1777
+ }),
1778
+ t('config.smart_routing_title') || 'Smart Prompt Routing'
1505
1779
  ),
1506
1780
  React.createElement(
1507
1781
  'div',
1508
- { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginTop: -4 } },
1509
- t('aggregator.fallbacks_desc')
1510
- ),
1511
- (Array.isArray(currentPreset.aggregator_fallbacks) ? currentPreset.aggregator_fallbacks : []).map((fb, fbIdx) =>
1512
- React.createElement(
1513
- 'div',
1514
- { key: fbIdx, style: { display: 'flex', alignItems: 'center', gap: 8 } },
1515
- React.createElement('span', { className: 'moa-cand-badge' }, `Fallback #${fbIdx + 1}`),
1516
- React.createElement(
1517
- 'div',
1518
- { style: { flex: 1 } },
1519
- React.createElement(SearchableModelPicker, {
1520
- provider: (fb && fb.provider) || '',
1521
- model: (fb && fb.model) || '',
1522
- onChange: (prov, mod) => {
1523
- updateCurrentPreset((p) => {
1524
- const list = [...(p.aggregator_fallbacks || [])]
1525
- list[fbIdx] = { provider: prov, model: mod }
1526
- return { ...p, aggregator_fallbacks: list }
1527
- })
1528
- },
1529
- availableModels,
1530
- t,
1531
- })
1532
- ),
1533
- React.createElement(
1534
- 'button',
1535
- {
1536
- type: 'button',
1537
- className: 'moa-btn moa-btn-danger moa-btn-sub',
1538
- onClick: () => {
1539
- updateCurrentPreset((p) => ({
1540
- ...p,
1541
- aggregator_fallbacks: (p.aggregator_fallbacks || []).filter((_, i) => i !== fbIdx),
1542
- }))
1543
- },
1544
- },
1545
- '✕'
1546
- )
1547
- )
1782
+ { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginLeft: 22 } },
1783
+ t('config.smart_routing_desc') || 'Analyze task complexity and automatically select the optimal preset.'
1548
1784
  ),
1549
- React.createElement(
1785
+ currentPreset.smart_routing_enabled && React.createElement(
1550
1786
  'div',
1551
- null,
1787
+ { style: { marginLeft: 22, marginTop: 4, display: 'flex', alignItems: 'center', gap: 8 } },
1788
+ React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, t('config.smart_routing_model') || 'Routing Model:'),
1552
1789
  React.createElement(
1553
- 'button',
1554
- {
1555
- type: 'button',
1556
- className: 'moa-btn moa-btn-sub',
1557
- style: { marginTop: 4 },
1558
- onClick: () => {
1790
+ 'div',
1791
+ { style: { flex: 1, maxWidth: 320 } },
1792
+ React.createElement(SearchableModelPicker, {
1793
+ provider: currentPreset.smart_routing_model?.provider || '',
1794
+ model: currentPreset.smart_routing_model?.model || '',
1795
+ onChange: (prov, mod) => {
1559
1796
  updateCurrentPreset((p) => ({
1560
1797
  ...p,
1561
- aggregator_fallbacks: [...(p.aggregator_fallbacks || []), { provider: (availableModels[0] && availableModels[0].provider) || '', model: (availableModels[0] && availableModels[0].model) || '' }],
1798
+ smart_routing_model: { provider: prov, model: mod },
1562
1799
  }))
1563
1800
  },
1564
- },
1565
- t('aggregator.add_fallback_btn')
1801
+ availableModels,
1802
+ t,
1803
+ })
1566
1804
  )
1567
1805
  )
1806
+ ),
1807
+
1808
+ /* Multi-Turn Context & Diff Pruning (#176) */
1809
+ React.createElement(
1810
+ 'div',
1811
+ { style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '8px 12px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2)', marginTop: 8 } },
1812
+ React.createElement(
1813
+ 'label',
1814
+ { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13, fontWeight: 500 } },
1815
+ React.createElement('input', {
1816
+ type: 'checkbox',
1817
+ checked: currentPreset.multi_turn_enabled !== false,
1818
+ onChange: (e) => updateCurrentPreset((p) => ({ ...p, multi_turn_enabled: e.target.checked })),
1819
+ style: { accentColor: 'var(--dsw-alias-state-brand-primary, var(--dsw-alias-label-primary))', cursor: 'pointer' },
1820
+ }),
1821
+ t('aggregator.multi_turn_title') || 'Multi-Turn Context & Diff Pruning'
1822
+ ),
1823
+ React.createElement(
1824
+ 'div',
1825
+ { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginLeft: 22 } },
1826
+ t('aggregator.multi_turn_desc') || 'Retain conversation context across turns while pruning duplicate code blocks.'
1827
+ )
1828
+ ),
1829
+
1830
+ /* Fallback Judges Chain */
1831
+ React.createElement(
1832
+ 'div',
1833
+ { style: { marginTop: 10, display: 'flex', flexDirection: 'column', gap: 6 } },
1834
+ React.createElement(
1835
+ 'div',
1836
+ { style: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } },
1837
+ t('aggregator.fallbacks_title')
1838
+ ),
1839
+ React.createElement(
1840
+ 'div',
1841
+ { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginTop: -4 } },
1842
+ t('aggregator.fallbacks_desc')
1843
+ ),
1844
+ renderSlotEditorList({
1845
+ items: currentPreset.aggregator_fallbacks,
1846
+ badgePrefix: 'Fallback',
1847
+ onUpdate: (idx, prov, mod) => {
1848
+ updateCurrentPreset((p) => {
1849
+ const list = [...(p.aggregator_fallbacks || [])]
1850
+ list[idx] = { provider: prov, model: mod }
1851
+ return { ...p, aggregator_fallbacks: list }
1852
+ })
1853
+ },
1854
+ onRemove: (idx) => {
1855
+ updateCurrentPreset((p) => ({
1856
+ ...p,
1857
+ aggregator_fallbacks: (p.aggregator_fallbacks || []).filter((_, i) => i !== idx),
1858
+ }))
1859
+ },
1860
+ onAdd: () => {
1861
+ updateCurrentPreset((p) => ({
1862
+ ...p,
1863
+ aggregator_fallbacks: [...(p.aggregator_fallbacks || []), { provider: (availableModels[0] && availableModels[0].provider) || '', model: (availableModels[0] && availableModels[0].model) || '' }],
1864
+ }))
1865
+ },
1866
+ addLabel: t('aggregator.add_fallback_btn'),
1867
+ emptyWarning: null,
1868
+ availableModels,
1869
+ t,
1870
+ })
1568
1871
  )
1569
1872
  )
1570
1873
  )
@@ -2021,6 +2324,9 @@ window.__ModuleLoader__.load({
2021
2324
  )
2022
2325
  }
2023
2326
 
2327
+ const UNAVAILABLE_SNAPSHOT = Object.freeze({ status: 'unavailable' })
2328
+ const LOADING_SNAPSHOT = Object.freeze({ status: 'loading' })
2329
+
2024
2330
  function useMoASettings(props) {
2025
2331
  const effectiveCtx = (props && props.ctx) || rootCtx
2026
2332
  // English is canonical; prefer the core-provided translator for the
@@ -2033,12 +2339,12 @@ window.__ModuleLoader__.load({
2033
2339
  return s && typeof s.get === 'function' ? s.get(NS) : undefined
2034
2340
  }, [effectiveCtx])
2035
2341
 
2342
+ const getSnapshot = React.useCallback(() => (scope ? scope.getSnapshot() : UNAVAILABLE_SNAPSHOT), [scope])
2343
+ const getServerSnapshot = React.useCallback(() => LOADING_SNAPSHOT, [])
2344
+ const subscribe = React.useCallback((cb) => (scope ? scope.subscribe(cb) : () => {}), [scope])
2345
+
2036
2346
  const snapshot = React.useSyncExternalStore
2037
- ? React.useSyncExternalStore(
2038
- React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]),
2039
- React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'unavailable' }), [scope]),
2040
- React.useCallback(() => ({ status: 'loading' }), [])
2041
- )
2347
+ ? React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
2042
2348
  : null
2043
2349
 
2044
2350
  const [status, setStatus] = React.useState('loading')
@@ -2052,6 +2358,7 @@ window.__ModuleLoader__.load({
2052
2358
  currentVersion: '0.2.16',
2053
2359
  latestVersion: undefined,
2054
2360
  updateAvailable: false,
2361
+ canAutoUpdate: true,
2055
2362
  notice: null,
2056
2363
  error: null,
2057
2364
  })
@@ -2068,10 +2375,11 @@ window.__ModuleLoader__.load({
2068
2375
  currentVersion: data.currentVersion,
2069
2376
  latestVersion: data.latestVersion,
2070
2377
  updateAvailable: Boolean(data.updateAvailable),
2378
+ canAutoUpdate: data.canAutoUpdate !== undefined ? Boolean(data.canAutoUpdate) : prev.canAutoUpdate,
2071
2379
  error: data.latestCheckFailed ? (t('updater.checkFailed') || 'Registry check failed') : null,
2072
2380
  }))
2073
2381
  } else {
2074
- setUpdateState((prev) => ({ ...prev, checking: false, error: data.error || 'Check failed' }))
2382
+ setUpdateState((prev) => ({ ...prev, checking: false, error: data?.error || 'Check failed' }))
2075
2383
  }
2076
2384
  } catch (err) {
2077
2385
  setUpdateState((prev) => ({ ...prev, checking: false, error: String(err?.message || err) }))
@@ -2086,19 +2394,20 @@ window.__ModuleLoader__.load({
2086
2394
  headers: { 'Content-Type': 'application/json', 'x-dsh-plugin-update': '1' },
2087
2395
  })
2088
2396
  const data = await res.json()
2089
- if (data && data.updated) {
2397
+ if (data && (data.updated || data.updatedVersion || data.restartRequired || data.ok)) {
2398
+ const nextVer = data.updatedVersion || data.latestVersion || data.currentVersion
2090
2399
  setUpdateState((prev) => ({
2091
2400
  ...prev,
2092
2401
  updating: false,
2093
2402
  updateAvailable: false,
2094
- currentVersion: data.updatedVersion || data.latestVersion,
2095
- notice: (t('updater.success') || 'Successfully updated to v{version}. Restart DSH to apply.').replace('{version}', data.updatedVersion || data.latestVersion),
2403
+ currentVersion: nextVer,
2404
+ notice: (t('updater.success') || 'Successfully updated to v{version}. Restart DSH to apply.').replace('{version}', nextVer),
2096
2405
  }))
2097
2406
  } else {
2098
2407
  setUpdateState((prev) => ({
2099
2408
  ...prev,
2100
2409
  updating: false,
2101
- error: data.error || data.message || 'Update failed',
2410
+ error: data?.error || data?.message || 'Update failed',
2102
2411
  }))
2103
2412
  }
2104
2413
  } catch (err) {
@@ -2258,7 +2567,7 @@ window.__ModuleLoader__.load({
2258
2567
  if (res.defaultPreset) setDefaultPreset(res.defaultPreset)
2259
2568
  setTimeout(() => setSaveStatus(''), 3000)
2260
2569
  } else {
2261
- setSaveStatus('Error saving: ' + (res?.error || 'failed'))
2570
+ setSaveStatus((t('status.error_saving') || 'Error saving: ') + (res?.error || 'failed'))
2262
2571
  }
2263
2572
  })
2264
2573
  .catch((err) => {
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),
package/lib/index.js CHANGED
@@ -12,6 +12,7 @@ import { createLlmCaller } from './moa-llm.js'
12
12
  import {
13
13
  parseMoACommand,
14
14
  streamMoATurn,
15
+ streamPromoteTurn,
15
16
  } from './moa-runner.js'
16
17
  import { registerMoaRoutes } from './routes.js'
17
18
  import { resolvePresetForPrompt } from './moa-router.js'
@@ -174,6 +175,20 @@ export function apply(ctx, config) {
174
175
  if (cfg.enabled !== false) {
175
176
  const effectiveDefault = cfg.default_preset || 'default'
176
177
  const parsed = parseMoACommand(userText, cfg.presets || [], effectiveDefault) || { prompt: userText, presetName: effectiveDefault, isExplicit: false }
178
+ if (parsed.isPromote) {
179
+ const sessCwd = event.cwd || event.session?.cwd || (event.session?.path ? path.dirname(event.session.path) : undefined) || process.cwd()
180
+ const turnContext = {
181
+ isPromoteTurn: true,
182
+ runId: parsed.runId,
183
+ candidateIndex: parsed.candidateIndex,
184
+ cwd: sessCwd,
185
+ webServerPort: ctx.webServer?.port,
186
+ timeMachineEngine: ctx.reflect?.get?.('timeMachineEngine', false),
187
+ }
188
+ if (event.signal) moaPendingTurns.set(event.signal, turnContext)
189
+ if (event.session?.id) setSessionTurn(event.session.id, turnContext)
190
+ return next()
191
+ }
177
192
  const isExplicitPreset = Boolean(parsed.isExplicit || userText.includes('--preset') || userText.includes('-p '))
178
193
  if (!isExplicitPreset && (cfg.smart_routing_enabled || cfg.presets?.some((p) => p.smart_routing_enabled))) {
179
194
  try {
@@ -234,11 +249,11 @@ export function apply(ctx, config) {
234
249
  const turnContext = (payload.signal ? moaPendingTurns.get(payload.signal) : null)
235
250
  || (payload.session?.id ? getSessionTurn(payload.session.id) : null)
236
251
 
237
- if (turnContext) {
252
+ if (turnContext && !turnContext.isPromoteTurn) {
238
253
  return {
239
254
  ...resolved,
240
- provider: turnContext.aggregator.provider,
241
- model: turnContext.aggregator.model,
255
+ provider: turnContext.aggregator?.provider || resolved.provider,
256
+ model: turnContext.aggregator?.model || resolved.model,
242
257
  }
243
258
  }
244
259
  return resolved
@@ -254,6 +269,9 @@ export function apply(ctx, config) {
254
269
  if (options.signal) moaPendingTurns.delete(options.signal)
255
270
  if (options.session?.id) consumeSessionTurn(options.session.id)
256
271
 
272
+ if (turnContext.isPromoteTurn) {
273
+ return streamPromoteTurn(turnContext, options)
274
+ }
257
275
  return streamMoATurn(turnContext, options)
258
276
  }
259
277
 
@@ -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-runner.js CHANGED
@@ -18,7 +18,7 @@ export { parseWinnerIndex, parseRecommendedAssembler, parseMoACommand, stripOrSu
18
18
  export { estimateTokenCost, summarizeMoAUsage } from './pricing.js'
19
19
  export { REFERENCE_SYSTEM_PROMPT, callWithTransientRetry, runReferencesParallel } from './moa-candidates.js'
20
20
  export { executeTestGateForCandidates, runCandidateTestGate } from './moa-test-gate.js'
21
- export { streamMoATurn } from './moa-stream.js'
21
+ export { streamMoATurn, streamPromoteTurn } from './moa-stream.js'
22
22
  /**
23
23
  * Executes the full Mixture of Agents pipeline.
24
24
  */
@@ -229,6 +229,7 @@ export async function runMoAPipeline({
229
229
  const durationMs = Date.now() - startTime
230
230
  try {
231
231
  await recordMoaRunAsync({
232
+ cwd,
232
233
  prompt: userPrompt,
233
234
  preset: preset?.name || 'default',
234
235
  isRefinement,
@@ -281,13 +282,14 @@ export async function runMoAPipeline({
281
282
  await cleanMoaWorkspaces(cwd, { runId })
282
283
  }
283
284
 
284
- const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
285
+ const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs, { winningIndex: single.index })
285
286
  const totalTokens = single.usage?.totalTokens || 0
286
287
  const totalCostUsd = Number((single.costUsd || 0).toFixed(5))
287
288
  const durationMs = Date.now() - startTime
288
289
 
289
290
  try {
290
291
  await recordMoaRunAsync({
292
+ cwd,
291
293
  prompt: userPrompt,
292
294
  preset: preset?.name || 'default',
293
295
  isRefinement,
@@ -512,7 +514,7 @@ export async function runMoAPipeline({
512
514
  await cleanMoaWorkspaces(cwd, { runId })
513
515
  }
514
516
 
515
- const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
517
+ const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs, { winningIndex, synthesizedFiles })
516
518
  const durationMs = Date.now() - startTime
517
519
  const usageSummary = summarizeMoAUsage(referenceOutputs, aggUsage, multiJudgeResult?.totalUsage)
518
520
  const winningRef = referenceOutputs[winningIndex - 1]
@@ -542,6 +544,7 @@ export async function runMoAPipeline({
542
544
  try {
543
545
  await recordMoaRunAsync({
544
546
  id: runId,
547
+ cwd,
545
548
  prompt: userPrompt,
546
549
  preset: preset?.name || 'default',
547
550
  isRefinement,
package/lib/moa-stream.js CHANGED
@@ -3,7 +3,8 @@ import crypto from 'node:crypto'
3
3
  * Zero-latency Live Streaming Generator for MoA Turns
4
4
  */
5
5
 
6
- import { cleanMoaWorkspaces } from './file-workspace.js'
6
+ import { cleanMoaWorkspaces, promoteCandidateWorkspace } from './file-workspace.js'
7
+ import { isTestEnvironment } from './history.js'
7
8
  import { formatMoAResponse } from './moa-parser.js'
8
9
  import { runMoAPipeline } from './moa-runner.js'
9
10
 
@@ -132,3 +133,58 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
132
133
  }
133
134
  }
134
135
  }
136
+
137
+ /**
138
+ * Streams a /moa promote candidate turn directly to chat with zero LLM calls (#177).
139
+ */
140
+ export async function* streamPromoteTurn({ runId, candidateIndex, cwd, webServerPort, timeMachineEngine, force = isTestEnvironment() }, options = {}) {
141
+ const signal = options?.signal
142
+ if (signal?.aborted) return
143
+
144
+ yield { type: 'block-start', index: 0, blockType: 'text' }
145
+
146
+ if (typeof candidateIndex !== 'number' || isNaN(candidateIndex) || candidateIndex < 1) {
147
+ const errText = '⚠️ **MoA Promote Error**: Valid candidate index required (>= 1).'
148
+ yield { type: 'text-delta', index: 0, text: errText }
149
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
150
+ yield { type: 'finish', reason: { kind: 'stop' } }
151
+ return
152
+ }
153
+
154
+ try {
155
+ yield { type: 'text-delta', index: 0, text: `🔄 *Promoting candidate ${candidateIndex}${runId ? ` for run \`${runId}\`` : ''}...*\n\n` }
156
+
157
+ const promotedFiles = await promoteCandidateWorkspace(cwd, candidateIndex, {
158
+ runId,
159
+ keepMoa: true,
160
+ port: webServerPort,
161
+ timeMachineEngine,
162
+ force: Boolean(force || options?.force),
163
+ })
164
+
165
+ 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.`
167
+ yield { type: 'text-delta', index: 0, text: noFilesText }
168
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: noFilesText } }
169
+ yield { type: 'finish', reason: { kind: 'stop' } }
170
+ return
171
+ }
172
+
173
+ const fileList = promotedFiles.map((f) => `\`${f}\``).join(', ')
174
+ const checkpointId = promotedFiles.checkpoint || null
175
+ let successMsg = `✅ **MoA Candidate ${candidateIndex} promoted successfully!**\n\n`
176
+ successMsg += `> 📦 **Files applied to workspace**: ${fileList}\n`
177
+ if (checkpointId) {
178
+ successMsg += `> 🛡️ **Time Machine Checkpoint**: \`${checkpointId}\`\n`
179
+ }
180
+
181
+ yield { type: 'text-delta', index: 0, text: successMsg }
182
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: successMsg } }
183
+ yield { type: 'finish', reason: { kind: 'stop' } }
184
+ } catch (err) {
185
+ const errText = `⚠️ **MoA Promote Failed**: ${err?.message || String(err)}`
186
+ yield { type: 'text-delta', index: 0, text: errText }
187
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
188
+ yield { type: 'finish', reason: { kind: 'stop' } }
189
+ }
190
+ }
package/lib/routes.js CHANGED
@@ -356,7 +356,9 @@ ctx.effect(() => {
356
356
  }
357
357
  try {
358
358
  const u = new URL(req.url, 'http://localhost')
359
- const cwd = u.searchParams.get('cwd') || process.cwd()
359
+ const runId = u.searchParams.get('runId') || undefined
360
+ const run = runId ? getMoaRunById(runId) : null
361
+ const cwd = run?.cwd || u.searchParams.get('cwd') || process.cwd()
360
362
  const sanitizeTarget = (val, def) => {
361
363
  const clean = String(val || '').trim().replace(/[^a-zA-Z0-9_-]/g, '')
362
364
  return clean || def
@@ -366,7 +368,6 @@ ctx.effect(() => {
366
368
  const rawFile = u.searchParams.get('file') || ''
367
369
  const file = (rawFile && isSafeRelativePath(rawFile)) ? rawFile : ''
368
370
 
369
- const runId = u.searchParams.get('runId') || undefined
370
371
  const fromFiles = await readCandidateFiles(cwd, from, { runId })
371
372
  const toFiles = await readCandidateFiles(cwd, to, { runId })
372
373
 
package/lib/updater.js CHANGED
@@ -43,18 +43,39 @@ export function isTrustedUpdateRequest(request) {
43
43
 
44
44
  /** General safe write check for plugin write endpoints (CSRF / Cross-Site protection) */
45
45
  export function isSafeWriteRequest(request) {
46
+ const remote = request?.socket?.remoteAddress || request?.connection?.remoteAddress;
47
+ const isLoop = !remote || isLoopback(remote);
48
+
46
49
  const site = header(request, 'sec-fetch-site');
47
50
  if (site === 'cross-site') return false;
51
+
48
52
  const origin = header(request, 'origin');
49
53
  const host = header(request, 'host');
54
+
55
+ // If non-loopback caller, require valid same-origin browser proof
56
+ if (!isLoop) {
57
+ if (!origin || !host) return false;
58
+ if (site && site !== 'same-origin' && site !== 'none') return false;
59
+ }
60
+
61
+ // Same-site without matching origin is rejected regardless of remote
62
+ if (site === 'same-site') {
63
+ if (!origin || !host) return false;
64
+ }
65
+
50
66
  if (origin) {
67
+ if (origin === 'null') return false;
51
68
  try {
52
69
  const url = new URL(origin);
53
70
  if (host && url.host !== host) return false;
71
+ if (!isLoop) {
72
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
73
+ }
54
74
  } catch {
55
75
  return false;
56
76
  }
57
77
  }
78
+
58
79
  return true;
59
80
  }
60
81
 
@@ -353,6 +374,8 @@ export function registerPluginUpdater(ctx, options) {
353
374
  }
354
375
  await installExact(target, `${options.packageName}@${before.latestVersion}`, options);
355
376
  json(response, 200, {
377
+ ok: true,
378
+ updated: true,
356
379
  ...before,
357
380
  updatedVersion: before.latestVersion,
358
381
  restartRequired: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.41",
3
+ "version": "0.2.42",
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",