@goodandready/dsh-moa 0.2.19 → 0.2.21
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 +9 -0
- package/README.ru.md +9 -0
- package/README.zh.md +6 -0
- package/lib/client.js +249 -3
- package/lib/file-workspace.js +252 -98
- package/lib/history.js +120 -10
- package/lib/moa-candidates.js +11 -3
- package/lib/moa-prompts.js +11 -5
- package/lib/moa-runner.js +5 -1
- package/lib/routes.js +83 -12
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -136,6 +136,12 @@ Configure your models in `Settings → Plugins → Mixture of Agents`:
|
|
|
136
136
|
* Configure named presets (`default`, `fast`, `deep-reasoning`), judge criteria and temperatures.
|
|
137
137
|
* Enable or disable MoA and see the real host status chip; the telemetry grid shows total runs and average run cost.
|
|
138
138
|
|
|
139
|
+
### 7. Candidate Diff Viewer
|
|
140
|
+
Inspect line-by-line differences between candidate proposals and the curator's synthesized deliverable directly in the UI. Features file selection, delta line highlights (added, removed, same), and unified diff rendering via a zero-dependency in-memory LCS algorithm.
|
|
141
|
+
|
|
142
|
+
### 8. Pre-Promotion Git Checkpoints (`dsh-time-machine`)
|
|
143
|
+
Before promoting any winning candidate files over the workspace root, `dsh-moa` invokes the local `dsh-time-machine` service to create a shadow Git checkpoint (`moa-pre-promotion: candidate-N`). If `dsh-time-machine` is absent or unreachable, file promotion proceeds seamlessly via best-effort fallback.
|
|
144
|
+
|
|
139
145
|
### 6. Live Canvas 1-Click Preview (optional)
|
|
140
146
|
If `@goodandready/dsh-live-canvas` is installed in the same profile, `dsh-moa` pushes the promoted HTML file to the Live Canvas REST contract (`POST /dsh-live-canvas/api/preview`, served by the same harness webServer) and appends a one-click preview link (`/dsh-live-canvas/sandbox/<id>`) to the answer. Without the plugin the step is skipped silently — no errors in the log, no dead links.
|
|
141
147
|
|
|
@@ -269,6 +275,9 @@ dsh-moa:
|
|
|
269
275
|
| `/dsh-moa/leaderboard` | `GET` | Computes model win-rate leaderboard and average execution costs |
|
|
270
276
|
| `/dsh-moa/runs/<id>` | `GET` | Returns a single recorded run by id |
|
|
271
277
|
| `/dsh-moa/run` | `POST` | Runs the full MoA pipeline over HTTP (400 when `enabled: false`) |
|
|
278
|
+
| `/dsh-moa/diff` | `GET` | Computes line-by-line diff between candidate runs or curator synthesis |
|
|
279
|
+
| `/dsh-moa/promote` | `POST` | Manually promotes candidate workspace files to project root |
|
|
280
|
+
| `/api/dsh-moa/update` | `POST` | One-click plugin updater from npm with safe-write verification |
|
|
272
281
|
|
|
273
282
|
---
|
|
274
283
|
|
package/README.ru.md
CHANGED
|
@@ -136,6 +136,12 @@ graph TD
|
|
|
136
136
|
* Конфигурация именованных пресетов (`default`, `fast`, `deep-reasoning`), критериев судьи и температур.
|
|
137
137
|
* Включение/отключение MoA и фактический статус-бейдж хоста; сетка телеметрии показывает общее число запусков и среднюю стоимость.
|
|
138
138
|
|
|
139
|
+
### 7. Интерактивный просмотр различий (Candidate Diff Viewer)
|
|
140
|
+
Позволяет визуально сопоставлять построчные различия между решениями моделей-советников и итоговым синтезом судьи прямо в интерфейсе. Поддерживает выбор конкретного файла, подсветку добавленных/удаленных строк и вычисление дельт с помощью легковесного алгоритма LCS без внешних зависимостей.
|
|
141
|
+
|
|
142
|
+
### 8. Теневые Git-чекпоинты (`dsh-time-machine`)
|
|
143
|
+
Перед промоушном файлов победителя в корень рабочей области `dsh-moa` автоматически обращается к сервису `dsh-time-machine` для создания снимка (`moa-pre-promotion: candidate-N`). Если плагин недоступен, промоушн штатно продолжается по схеме best-effort.
|
|
144
|
+
|
|
139
145
|
### 6. Live Canvas 1-клик предпросмотр (опционально)
|
|
140
146
|
Если в профиле установлен `@goodandready/dsh-live-canvas`, `dsh-moa` отправляет промоученный HTML-файл в REST-контракт Live Canvas (`POST /dsh-live-canvas/api/preview`, тот же webServer харнесса) и добавляет к ответу ссылку на предпросмотр (`/dsh-live-canvas/sandbox/<id>`). Без плагина шаг пропускается тихо — без ошибок в журнале и без битых ссылок.
|
|
141
147
|
|
|
@@ -269,6 +275,9 @@ dsh-moa:
|
|
|
269
275
|
| `/dsh-moa/leaderboard` | `GET` | Лидерборд побед моделей и средняя стоимость генерации |
|
|
270
276
|
| `/dsh-moa/runs/<id>` | `GET` | Возвращает один записанный запуск по id |
|
|
271
277
|
| `/dsh-moa/run` | `POST` | Запускает полный пайплайн MoA по HTTP (400 при `enabled: false`) |
|
|
278
|
+
| `/dsh-moa/diff` | `GET` | Вычисляет построчный diff между решениями кандидатов или синтезом куратора |
|
|
279
|
+
| `/dsh-moa/promote` | `POST` | Ручной промоушн файлов кандидата в корень проекта с созданием снимка |
|
|
280
|
+
| `/api/dsh-moa/update` | `POST` | One-click обновление плагина из npm с проверкой безопасности источника |
|
|
272
281
|
|
|
273
282
|
---
|
|
274
283
|
|
package/README.zh.md
CHANGED
|
@@ -136,6 +136,12 @@ graph TD
|
|
|
136
136
|
* 自定义命名预设 (`default`, `fast`, `deep-reasoning`)、裁判评审准则与温度。
|
|
137
137
|
* 启用/停用 MoA 开关并查看真实的主机状态徽章;遥测网格展示总运行次数与平均运行成本。
|
|
138
138
|
|
|
139
|
+
### 7. 候选方案差异对比器 (Candidate Diff Viewer)
|
|
140
|
+
直接在界面中可视化对比顾问方案之间以及顾问与裁判综合方案之间的逐行差异。支持文件筛选、增删改高亮,基于纯 JavaScript LCS 差异算法,零第三方依赖。
|
|
141
|
+
|
|
142
|
+
### 8. 提升前 Git 快照检查点 (`dsh-time-machine`)
|
|
143
|
+
在将胜出候选者的文件提升并覆盖至项目根目录前,`dsh-moa` 自动调用本地 `dsh-time-machine` 建立影子 Git 检查点 (`moa-pre-promotion: candidate-N`)。如未安装则静默回退,不阻断提升流程。
|
|
144
|
+
|
|
139
145
|
### 6. Live Canvas 一键预览(可选)
|
|
140
146
|
若同一 profile 中安装了 `@goodandready/dsh-live-canvas`,`dsh-moa` 会将提升后的 HTML 文件推送到 Live Canvas 的 REST 契约(`POST /dsh-live-canvas/api/preview`,由同一 harness webServer 提供服务),并在回答中附上一键预览链接(`/dsh-live-canvas/sandbox/<id>`)。未安装该插件时此步骤静默跳过——日志无报错,也不会出现死链接。
|
|
141
147
|
|
package/lib/client.js
CHANGED
|
@@ -127,6 +127,17 @@ window.__ModuleLoader__.load({
|
|
|
127
127
|
'updater.available': 'Update available: v{version}',
|
|
128
128
|
'updater.upToDate': 'Up to date',
|
|
129
129
|
'updater.checkFailed': 'Registry check failed',
|
|
130
|
+
'diff.title': 'Candidate Diff Viewer',
|
|
131
|
+
'diff.desc': 'Visually inspect differences between candidate solutions and judge synthesis in the project workspace.',
|
|
132
|
+
'diff.select_from': 'Base:',
|
|
133
|
+
'diff.select_to': 'Compare with:',
|
|
134
|
+
'diff.select_file': 'File:',
|
|
135
|
+
'diff.no_files': 'No files found in candidate workspaces (.moa). Run a task with /moa to generate candidate code.',
|
|
136
|
+
'diff.refresh': 'Refresh Diff',
|
|
137
|
+
'diff.loading': 'Loading differences...',
|
|
138
|
+
'diff.curator': 'Curator Synthesis',
|
|
139
|
+
'diff.candidate': 'Candidate {num}',
|
|
140
|
+
'diff.empty': 'Selected files are identical (zero diff).',
|
|
130
141
|
'updater.success': 'Successfully updated to v{version}. Restart DSH to apply.',
|
|
131
142
|
}
|
|
132
143
|
|
|
@@ -230,6 +241,17 @@ window.__ModuleLoader__.load({
|
|
|
230
241
|
'updater.available': '发现新版本:v{version}',
|
|
231
242
|
'updater.upToDate': '已是最新版本',
|
|
232
243
|
'updater.checkFailed': '检查版本失败',
|
|
244
|
+
'diff.title': '候选方案差异查看器 (Diff Viewer)',
|
|
245
|
+
'diff.desc': '在项目工作区中可视化检查候选方案与裁判合成方案之间的文件差异。',
|
|
246
|
+
'diff.select_from': '基准方案:',
|
|
247
|
+
'diff.select_to': '对比目标:',
|
|
248
|
+
'diff.select_file': '文件:',
|
|
249
|
+
'diff.no_files': '在候选工作区 (.moa) 中未发现文件。请先运行 /moa 生成候选代码。',
|
|
250
|
+
'diff.refresh': '刷新差异',
|
|
251
|
+
'diff.loading': '正在加载差异...',
|
|
252
|
+
'diff.curator': '裁判合成 (Curator Synthesis)',
|
|
253
|
+
'diff.candidate': '候选方案 {num}',
|
|
254
|
+
'diff.empty': '选中的文件完全一致(无差异)。',
|
|
233
255
|
'updater.success': '成功更新至 v{version},请重启 DSH 使其生效。',
|
|
234
256
|
}
|
|
235
257
|
|
|
@@ -360,6 +382,20 @@ window.__ModuleLoader__.load({
|
|
|
360
382
|
.moa-chev{transition:transform .2s ease;color:var(--dsw-alias-label-secondary)}
|
|
361
383
|
.moa-chev-open{transform:rotate(180deg)}
|
|
362
384
|
.moa-body{padding:0 18px 20px;border-top:1px solid var(--dsw-alias-border-l2)}
|
|
385
|
+
/* Candidate Diff Viewer */
|
|
386
|
+
.moa-diff-container{display:flex;flex-direction:column;gap:10px;margin-top:10px}
|
|
387
|
+
.moa-diff-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
388
|
+
.moa-diff-select{height:32px;padding:0 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;outline:none}
|
|
389
|
+
.moa-diff-box{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 0;font-family:monospace, ui-monospace, Menlo, Consolas, monospace;font-size:12px;line-height:1.5;max-height:400px;overflow-y:auto;overflow-x:auto}
|
|
390
|
+
.moa-diff-line{display:flex;padding:1px 10px;white-space:pre}
|
|
391
|
+
.moa-diff-line-same{color:var(--dsw-alias-label-secondary)}
|
|
392
|
+
.moa-diff-line-add{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent);color:var(--dsw-alias-state-success-primary)}
|
|
393
|
+
.moa-diff-line-del{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent);color:var(--dsw-alias-state-error-primary)}
|
|
394
|
+
.moa-diff-prefix{width:20px;user-select:none;flex-shrink:0;font-weight:bold}
|
|
395
|
+
.moa-diff-content{flex:1}
|
|
396
|
+
.moa-diff-stat-add{color:var(--dsw-alias-state-success-primary);font-weight:600}
|
|
397
|
+
.moa-diff-stat-del{color:var(--dsw-alias-state-error-primary);font-weight:600}
|
|
398
|
+
|
|
363
399
|
`
|
|
364
400
|
|
|
365
401
|
let stylesInjected = false
|
|
@@ -581,6 +617,197 @@ window.__ModuleLoader__.load({
|
|
|
581
617
|
)
|
|
582
618
|
}
|
|
583
619
|
|
|
620
|
+
function CandidateDiffSection({ t, currentRefs = [] }) {
|
|
621
|
+
const [fromCandidate, setFromCandidate] = React.useState('1')
|
|
622
|
+
const [toCandidate, setToCandidate] = React.useState('curator-synthesis')
|
|
623
|
+
const [selectedFile, setSelectedFile] = React.useState('')
|
|
624
|
+
const [files, setFiles] = React.useState([])
|
|
625
|
+
const [diff, setDiff] = React.useState([])
|
|
626
|
+
const [fromSize, setFromSize] = React.useState(0)
|
|
627
|
+
const [toSize, setToSize] = React.useState(0)
|
|
628
|
+
const [loading, setLoading] = React.useState(false)
|
|
629
|
+
const [error, setError] = React.useState(null)
|
|
630
|
+
const [hasFetched, setHasFetched] = React.useState(false)
|
|
631
|
+
|
|
632
|
+
const candidateCount = Math.max(currentRefs.length || 0, 2)
|
|
633
|
+
const candidateOptions = []
|
|
634
|
+
for (let i = 1; i <= candidateCount; i++) {
|
|
635
|
+
candidateOptions.push({
|
|
636
|
+
value: String(i),
|
|
637
|
+
label: (t('diff.candidate') || 'Candidate {num}').replace('{num}', String(i)),
|
|
638
|
+
})
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const loadDiff = React.useCallback((targetFile = selectedFile) => {
|
|
642
|
+
setLoading(true)
|
|
643
|
+
setError(null)
|
|
644
|
+
const params = new URLSearchParams()
|
|
645
|
+
if (fromCandidate) params.set('from', fromCandidate)
|
|
646
|
+
if (toCandidate) params.set('to', toCandidate)
|
|
647
|
+
if (targetFile) params.set('file', targetFile)
|
|
648
|
+
|
|
649
|
+
fetch('/dsh-moa/diff?' + params.toString(), { cache: 'no-store' })
|
|
650
|
+
.then((r) => {
|
|
651
|
+
if (!r.ok) throw new Error('HTTP ' + r.status)
|
|
652
|
+
return r.json()
|
|
653
|
+
})
|
|
654
|
+
.then((data) => {
|
|
655
|
+
setLoading(false)
|
|
656
|
+
setHasFetched(true)
|
|
657
|
+
if (data && data.ok) {
|
|
658
|
+
const fileList = Array.isArray(data.files) ? data.files : []
|
|
659
|
+
setFiles(fileList)
|
|
660
|
+
setDiff(Array.isArray(data.diff) ? data.diff : [])
|
|
661
|
+
setFromSize(data.fromSize || 0)
|
|
662
|
+
setToSize(data.toSize || 0)
|
|
663
|
+
if (data.selectedFile && data.selectedFile !== targetFile) {
|
|
664
|
+
setSelectedFile(data.selectedFile)
|
|
665
|
+
}
|
|
666
|
+
} else {
|
|
667
|
+
setError(data?.error || 'Failed to load diff')
|
|
668
|
+
}
|
|
669
|
+
})
|
|
670
|
+
.catch((err) => {
|
|
671
|
+
setLoading(false)
|
|
672
|
+
setHasFetched(true)
|
|
673
|
+
setError(err.message || String(err))
|
|
674
|
+
})
|
|
675
|
+
}, [fromCandidate, toCandidate, selectedFile])
|
|
676
|
+
|
|
677
|
+
React.useEffect(() => {
|
|
678
|
+
loadDiff()
|
|
679
|
+
}, [fromCandidate, toCandidate])
|
|
680
|
+
|
|
681
|
+
const handleFileChange = (e) => {
|
|
682
|
+
const nextFile = e.target.value
|
|
683
|
+
setSelectedFile(nextFile)
|
|
684
|
+
loadDiff(nextFile)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const adds = diff.filter((d) => d.type === 'added').length
|
|
688
|
+
const dels = diff.filter((d) => d.type === 'removed').length
|
|
689
|
+
|
|
690
|
+
return React.createElement(
|
|
691
|
+
'div',
|
|
692
|
+
{ className: 'moa-section-card' },
|
|
693
|
+
React.createElement('div', { className: 'moa-section-title' }, t('diff.title') || '🔍 Candidate Diff Viewer'),
|
|
694
|
+
React.createElement('div', { className: 'moa-section-desc' }, t('diff.desc') || 'Visually inspect differences between candidate solutions and judge synthesis in the project workspace.'),
|
|
695
|
+
|
|
696
|
+
React.createElement(
|
|
697
|
+
'div',
|
|
698
|
+
{ className: 'moa-diff-container' },
|
|
699
|
+
/* Controls bar */
|
|
700
|
+
React.createElement(
|
|
701
|
+
'div',
|
|
702
|
+
{ className: 'moa-diff-controls' },
|
|
703
|
+
React.createElement(
|
|
704
|
+
'label',
|
|
705
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
|
|
706
|
+
t('diff.select_from') || 'Base:',
|
|
707
|
+
React.createElement(
|
|
708
|
+
'select',
|
|
709
|
+
{
|
|
710
|
+
className: 'moa-diff-select',
|
|
711
|
+
value: fromCandidate,
|
|
712
|
+
onChange: (e) => setFromCandidate(e.target.value),
|
|
713
|
+
},
|
|
714
|
+
candidateOptions.map((opt) => React.createElement('option', { key: opt.value, value: opt.value }, opt.label)),
|
|
715
|
+
React.createElement('option', { value: 'curator-synthesis' }, t('diff.curator') || 'Curator Synthesis')
|
|
716
|
+
)
|
|
717
|
+
),
|
|
718
|
+
React.createElement(
|
|
719
|
+
'label',
|
|
720
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
|
|
721
|
+
t('diff.select_to') || 'Compare with:',
|
|
722
|
+
React.createElement(
|
|
723
|
+
'select',
|
|
724
|
+
{
|
|
725
|
+
className: 'moa-diff-select',
|
|
726
|
+
value: toCandidate,
|
|
727
|
+
onChange: (e) => setToCandidate(e.target.value),
|
|
728
|
+
},
|
|
729
|
+
React.createElement('option', { value: 'curator-synthesis' }, t('diff.curator') || 'Curator Synthesis'),
|
|
730
|
+
candidateOptions.map((opt) => React.createElement('option', { key: opt.value, value: opt.value }, opt.label))
|
|
731
|
+
)
|
|
732
|
+
),
|
|
733
|
+
files.length > 0 && React.createElement(
|
|
734
|
+
'label',
|
|
735
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
|
|
736
|
+
t('diff.select_file') || 'File:',
|
|
737
|
+
React.createElement(
|
|
738
|
+
'select',
|
|
739
|
+
{
|
|
740
|
+
className: 'moa-diff-select',
|
|
741
|
+
value: selectedFile || files[0] || '',
|
|
742
|
+
onChange: handleFileChange,
|
|
743
|
+
},
|
|
744
|
+
files.map((f) => React.createElement('option', { key: f, value: f }, f))
|
|
745
|
+
)
|
|
746
|
+
),
|
|
747
|
+
React.createElement(
|
|
748
|
+
'button',
|
|
749
|
+
{
|
|
750
|
+
type: 'button',
|
|
751
|
+
className: 'moa-btn moa-btn-sub',
|
|
752
|
+
onClick: () => loadDiff(),
|
|
753
|
+
disabled: loading,
|
|
754
|
+
},
|
|
755
|
+
loading ? (t('diff.loading') || 'Loading...') : (t('diff.refresh') || 'Refresh Diff')
|
|
756
|
+
),
|
|
757
|
+
diff.length > 0 && React.createElement(
|
|
758
|
+
'div',
|
|
759
|
+
{ style: { fontSize: 12, display: 'flex', gap: 8, alignItems: 'center', marginLeft: 'auto' } },
|
|
760
|
+
adds > 0 && React.createElement('span', { className: 'moa-diff-stat-add' }, `+${adds}`),
|
|
761
|
+
dels > 0 && React.createElement('span', { className: 'moa-diff-stat-del' }, `-${dels}`),
|
|
762
|
+
fromSize > 0 && toSize > 0 && React.createElement(
|
|
763
|
+
'span',
|
|
764
|
+
{ style: { color: 'var(--dsw-alias-label-secondary)', fontSize: 11 } },
|
|
765
|
+
`(${fromSize}B → ${toSize}B)`
|
|
766
|
+
)
|
|
767
|
+
)
|
|
768
|
+
),
|
|
769
|
+
|
|
770
|
+
/* Error state */
|
|
771
|
+
error && React.createElement(
|
|
772
|
+
'div',
|
|
773
|
+
{ className: 'moa-alert-err', style: { fontSize: 12, padding: '8px 12px' } },
|
|
774
|
+
error
|
|
775
|
+
),
|
|
776
|
+
|
|
777
|
+
/* Empty files state */
|
|
778
|
+
!error && !loading && hasFetched && files.length === 0 && React.createElement(
|
|
779
|
+
'div',
|
|
780
|
+
{ style: { padding: '16px 12px', textAlign: 'center', color: 'var(--dsw-alias-label-secondary)', fontSize: 13, background: 'var(--dsw-alias-bg-layer-2)', borderRadius: 8 } },
|
|
781
|
+
t('diff.no_files') || 'No files found in candidate workspaces (.moa). Run a task with /moa to generate candidate code.'
|
|
782
|
+
),
|
|
783
|
+
|
|
784
|
+
/* Identical content state */
|
|
785
|
+
!error && !loading && files.length > 0 && diff.length > 0 && adds === 0 && dels === 0 && React.createElement(
|
|
786
|
+
'div',
|
|
787
|
+
{ style: { padding: '12px', textAlign: 'center', color: 'var(--dsw-alias-state-success-primary)', fontSize: 12, background: 'var(--dsw-alias-bg-layer-2)', borderRadius: 8 } },
|
|
788
|
+
t('diff.empty') || 'Selected files are identical (zero diff).'
|
|
789
|
+
),
|
|
790
|
+
|
|
791
|
+
/* Diff lines display */
|
|
792
|
+
!error && files.length > 0 && diff.length > 0 && (adds > 0 || dels > 0) && React.createElement(
|
|
793
|
+
'div',
|
|
794
|
+
{ className: 'moa-diff-box' },
|
|
795
|
+
diff.map((item, idx) => {
|
|
796
|
+
const isAdd = item.type === 'added'
|
|
797
|
+
const isDel = item.type === 'removed'
|
|
798
|
+
const lineClass = isAdd ? 'moa-diff-line moa-diff-line-add' : isDel ? 'moa-diff-line moa-diff-line-del' : 'moa-diff-line moa-diff-line-same'
|
|
799
|
+
const prefix = isAdd ? '+' : isDel ? '-' : ' '
|
|
800
|
+
return React.createElement(
|
|
801
|
+
'div',
|
|
802
|
+
{ key: idx, className: lineClass },
|
|
803
|
+
React.createElement('span', { className: 'moa-diff-prefix' }, prefix),
|
|
804
|
+
React.createElement('span', { className: 'moa-diff-content' }, item.line)
|
|
805
|
+
)
|
|
806
|
+
})
|
|
807
|
+
)
|
|
808
|
+
)
|
|
809
|
+
)
|
|
810
|
+
}
|
|
584
811
|
function MoAEditor({
|
|
585
812
|
t,
|
|
586
813
|
presets,
|
|
@@ -692,7 +919,7 @@ window.__ModuleLoader__.load({
|
|
|
692
919
|
provider: 'opencode-go',
|
|
693
920
|
model: 'deepseek-v4-flash',
|
|
694
921
|
}
|
|
695
|
-
refs.push({ provider: fallback.provider, model: fallback.model })
|
|
922
|
+
refs.push({ provider: fallback.provider, model: fallback.model, role_persona: 'general' })
|
|
696
923
|
return { ...p, reference_models: refs }
|
|
697
924
|
})
|
|
698
925
|
}
|
|
@@ -711,8 +938,8 @@ window.__ModuleLoader__.load({
|
|
|
711
938
|
enabled: true,
|
|
712
939
|
ask_clarifying_questions: true,
|
|
713
940
|
reference_models: currentPreset?.reference_models?.length ? structuredClone(currentPreset.reference_models) : [
|
|
714
|
-
{ provider: 'opencode-go', model: 'deepseek-v4-flash' },
|
|
715
|
-
{ provider: 'codex', model: 'gpt-5.6-sol' },
|
|
941
|
+
{ provider: 'opencode-go', model: 'deepseek-v4-flash', role_persona: 'general' },
|
|
942
|
+
{ provider: 'codex', model: 'gpt-5.6-sol', role_persona: 'general' },
|
|
716
943
|
],
|
|
717
944
|
aggregator: currentPreset?.aggregator?.provider ? structuredClone(currentPreset.aggregator) : {
|
|
718
945
|
provider: 'codex',
|
|
@@ -1410,6 +1637,11 @@ window.__ModuleLoader__.load({
|
|
|
1410
1637
|
)
|
|
1411
1638
|
),
|
|
1412
1639
|
|
|
1640
|
+
/* ── SECTION 4b: CANDIDATE DIFF VIEWER ── */
|
|
1641
|
+
React.createElement(CandidateDiffSection, {
|
|
1642
|
+
t,
|
|
1643
|
+
currentRefs,
|
|
1644
|
+
}),
|
|
1413
1645
|
/* ── PLUGIN UPDATES ── */
|
|
1414
1646
|
React.createElement(
|
|
1415
1647
|
'div',
|
|
@@ -1722,6 +1954,20 @@ window.__ModuleLoader__.load({
|
|
|
1722
1954
|
reload()
|
|
1723
1955
|
}, [reload])
|
|
1724
1956
|
|
|
1957
|
+
React.useEffect(() => {
|
|
1958
|
+
const lbUrl = leaderboardPresetFilter !== 'all'
|
|
1959
|
+
? `/dsh-moa/leaderboard?preset=${encodeURIComponent(leaderboardPresetFilter)}`
|
|
1960
|
+
: '/dsh-moa/leaderboard'
|
|
1961
|
+
fetch(lbUrl, { cache: 'no-store' })
|
|
1962
|
+
.then((r) => r.json())
|
|
1963
|
+
.then((data) => {
|
|
1964
|
+
if (data && data.ok && Array.isArray(data.models)) {
|
|
1965
|
+
setLeaderboard(data.models.slice(0, 10))
|
|
1966
|
+
}
|
|
1967
|
+
})
|
|
1968
|
+
.catch(() => {})
|
|
1969
|
+
}, [leaderboardPresetFilter])
|
|
1970
|
+
|
|
1725
1971
|
const handleSave = () => {
|
|
1726
1972
|
setSaveStatus(t('actions.saving'))
|
|
1727
1973
|
|
package/lib/file-workspace.js
CHANGED
|
@@ -3,15 +3,31 @@ import vm from 'node:vm'
|
|
|
3
3
|
import fs from 'node:fs/promises'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Validates that an untrusted relative path is safe and does not traverse upwards.
|
|
8
|
+
*/
|
|
9
|
+
export function isSafeRelativePath(relPath) {
|
|
10
|
+
if (!relPath || typeof relPath !== 'string') return false
|
|
11
|
+
const trimmed = relPath.trim()
|
|
12
|
+
if (!trimmed || path.isAbsolute(trimmed) || trimmed.startsWith('/') || trimmed.startsWith('\\')) return false
|
|
13
|
+
const normalized = path.normalize(trimmed)
|
|
14
|
+
return Boolean(normalized && !normalized.startsWith('..') && !path.isAbsolute(normalized))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Asserts that a resolved path is strictly contained within baseDir.
|
|
19
|
+
*/
|
|
20
|
+
export function assertPathContained(baseDir, subPath) {
|
|
21
|
+
const resolvedBase = path.resolve(baseDir)
|
|
22
|
+
const resolvedTarget = path.resolve(resolvedBase, subPath)
|
|
23
|
+
if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(resolvedBase + path.sep)) {
|
|
24
|
+
throw new Error(`Path traversal violation: "${subPath}" escapes base directory "${baseDir}"`)
|
|
25
|
+
}
|
|
26
|
+
return resolvedTarget
|
|
27
|
+
}
|
|
28
|
+
|
|
6
29
|
/**
|
|
7
30
|
* Parses markdown code blocks that specify file paths.
|
|
8
|
-
* Supports patterns like:
|
|
9
|
-
* ```html file="index.html"
|
|
10
|
-
* ```javascript filepath=script.js
|
|
11
|
-
* // File: index.html
|
|
12
|
-
* <!-- File: styles.css -->
|
|
13
|
-
* ### File: index.html
|
|
14
|
-
* ```html
|
|
15
31
|
*/
|
|
16
32
|
export function extractFileBlocks(text) {
|
|
17
33
|
if (!text || typeof text !== 'string') return []
|
|
@@ -19,81 +35,48 @@ export function extractFileBlocks(text) {
|
|
|
19
35
|
const files = []
|
|
20
36
|
const seenPaths = new Set()
|
|
21
37
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const fenceRegex = /```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi
|
|
25
|
-
let match
|
|
26
|
-
while ((match = fenceRegex.exec(text)) !== null) {
|
|
27
|
-
const rawPath = match[1].trim()
|
|
28
|
-
const content = match[2]
|
|
29
|
-
if (rawPath && !seenPaths.has(rawPath)) {
|
|
38
|
+
const addFile = (rawPath, fileContent) => {
|
|
39
|
+
if (rawPath && isSafeRelativePath(rawPath) && !seenPaths.has(rawPath)) {
|
|
30
40
|
seenPaths.add(rawPath)
|
|
31
|
-
files.push({ relativePath:
|
|
41
|
+
files.push({ relativePath: rawPath, content: fileContent.trimEnd() + '\n' })
|
|
32
42
|
}
|
|
33
43
|
}
|
|
34
44
|
|
|
35
|
-
// Pattern
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
while ((match =
|
|
39
|
-
const rawPath = match[1].trim()
|
|
40
|
-
const content = match[2]
|
|
41
|
-
if (rawPath && !seenPaths.has(rawPath)) {
|
|
42
|
-
seenPaths.add(rawPath)
|
|
43
|
-
files.push({ relativePath: sanitizePath(rawPath), content })
|
|
44
|
-
}
|
|
45
|
-
}
|
|
45
|
+
// Pattern 1: fenced blocks with file="..." or path="..." in info string
|
|
46
|
+
const fenceRegex = /```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi
|
|
47
|
+
let match
|
|
48
|
+
while ((match = fenceRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
|
|
46
49
|
|
|
47
|
-
// Pattern
|
|
48
|
-
const commentRegex =
|
|
49
|
-
while ((match = commentRegex.exec(text)) !== null)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
// Pattern 2: comment header: // File: path/to/file.ext or <!-- File: ... -->
|
|
51
|
+
const commentRegex = /(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi
|
|
52
|
+
while ((match = commentRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
|
|
53
|
+
|
|
54
|
+
// Pattern 3: markdown header: ### File: path/to/file.ext
|
|
55
|
+
const headerRegex = /#{1,4}\s+(?:File|Path):\s*([^\s\n\r]+)[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi
|
|
56
|
+
while ((match = headerRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
|
|
57
|
+
|
|
58
|
+
// Pattern 4: first line inside code block: // File: path/to/file.ext
|
|
59
|
+
const inlineRegex = /```[\w-]*[\r\n]+(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+([\s\S]*?)```/gi
|
|
60
|
+
while ((match = inlineRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
|
|
57
61
|
|
|
58
|
-
// Fallback:
|
|
62
|
+
// Fallback: single anonymous code block if it contains complete HTML
|
|
59
63
|
if (files.length === 0) {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
while ((
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
html: 'index.html',
|
|
68
|
-
javascript: 'index.js',
|
|
69
|
-
js: 'index.js',
|
|
70
|
-
css: 'style.css',
|
|
71
|
-
python: 'main.py',
|
|
72
|
-
py: 'main.py',
|
|
73
|
-
json: 'data.json',
|
|
74
|
-
bash: 'run.sh',
|
|
75
|
-
sh: 'run.sh',
|
|
64
|
+
const htmlBlockRegex = /```(?:html)?[\r\n]+([\s\S]*?)```/gi
|
|
65
|
+
let htmlMatch
|
|
66
|
+
while ((htmlMatch = htmlBlockRegex.exec(text)) !== null) {
|
|
67
|
+
const candidateHtml = htmlMatch[1]
|
|
68
|
+
if (/<(!DOCTYPE\s+)?html/i.test(candidateHtml) || /<body/i.test(candidateHtml)) {
|
|
69
|
+
files.push({ relativePath: 'index.html', content: candidateHtml.trimEnd() + '\n' })
|
|
70
|
+
break
|
|
76
71
|
}
|
|
77
|
-
const defaultName = extMap[singleMatches[0].lang] || 'output.txt'
|
|
78
|
-
files.push({ relativePath: defaultName, content: singleMatches[0].content })
|
|
79
72
|
}
|
|
80
73
|
}
|
|
81
74
|
|
|
82
75
|
return files
|
|
83
76
|
}
|
|
84
77
|
|
|
85
|
-
function sanitizePath(filePath) {
|
|
86
|
-
const normalized = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '')
|
|
87
|
-
return normalized.replace(/^[\\\/]+/, '')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
78
|
/**
|
|
91
|
-
*
|
|
92
|
-
* Ignores node_modules, .git, .moa, binary files, large bundles.
|
|
93
|
-
*/
|
|
94
|
-
/**
|
|
95
|
-
* Formats collected project files into structured Markdown code blocks
|
|
96
|
-
* suitable for inclusion in candidate system prompts.
|
|
79
|
+
* Formats collected project files into a structured markdown context string.
|
|
97
80
|
*/
|
|
98
81
|
export function formatProjectContext(projectFiles = [], options = {}) {
|
|
99
82
|
if (!Array.isArray(projectFiles) || projectFiles.length === 0) return ''
|
|
@@ -123,7 +106,6 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
|
|
|
123
106
|
|
|
124
107
|
const collected = []
|
|
125
108
|
let totalChars = 0
|
|
126
|
-
|
|
127
109
|
let skippedFiles = 0
|
|
128
110
|
const skippedList = []
|
|
129
111
|
|
|
@@ -132,17 +114,14 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
|
|
|
132
114
|
let list
|
|
133
115
|
try {
|
|
134
116
|
list = await fs.readdir(currentDir, { withFileTypes: true })
|
|
135
|
-
} catch
|
|
117
|
+
} catch {
|
|
136
118
|
skippedFiles++
|
|
137
119
|
skippedList.push(relPrefix || '.')
|
|
138
120
|
return
|
|
139
121
|
}
|
|
140
122
|
|
|
141
123
|
for (const item of list) {
|
|
142
|
-
// Skip all dotfiles, including .env and .env.* variants: their
|
|
143
|
-
// contents must never be shipped to third-party model providers.
|
|
144
124
|
if (item.name.startsWith('.')) continue
|
|
145
|
-
|
|
146
125
|
const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
|
|
147
126
|
const full = path.join(currentDir, item.name)
|
|
148
127
|
|
|
@@ -155,9 +134,7 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
|
|
|
155
134
|
}
|
|
156
135
|
|
|
157
136
|
if (item.isDirectory()) {
|
|
158
|
-
if (!ignoreDirs.has(item.name))
|
|
159
|
-
await scan(full, rel)
|
|
160
|
-
}
|
|
137
|
+
if (!ignoreDirs.has(item.name)) await scan(full, rel)
|
|
161
138
|
} else if (item.isFile()) {
|
|
162
139
|
const ext = path.extname(item.name).toLowerCase()
|
|
163
140
|
if (codeExts.has(ext)) {
|
|
@@ -173,7 +150,7 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
|
|
|
173
150
|
const truncated = content.length > budgetRemaining ? content.slice(0, budgetRemaining) + '\n... [truncated]' : content
|
|
174
151
|
totalChars += truncated.length
|
|
175
152
|
collected.push({ relativePath: rel, content: truncated, size: stat.size })
|
|
176
|
-
} catch
|
|
153
|
+
} catch {
|
|
177
154
|
skippedFiles++
|
|
178
155
|
skippedList.push(rel)
|
|
179
156
|
}
|
|
@@ -187,8 +164,7 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
|
|
|
187
164
|
}
|
|
188
165
|
|
|
189
166
|
/**
|
|
190
|
-
* Checks if a task prompt is an iterative refinement / modification
|
|
191
|
-
* of an existing project rather than a greenfield project creation (#4).
|
|
167
|
+
* Checks if a task prompt is an iterative refinement / modification.
|
|
192
168
|
*/
|
|
193
169
|
export function isRefinementTask(userPrompt = '', projectFiles = null) {
|
|
194
170
|
if (Array.isArray(projectFiles) && projectFiles.length === 0) return false
|
|
@@ -225,12 +201,17 @@ export function isRefinementTask(userPrompt = '', projectFiles = null) {
|
|
|
225
201
|
export async function writeCandidateWorkspace(baseDir, candidateIndex, files = []) {
|
|
226
202
|
if (!baseDir || !Array.isArray(files) || files.length === 0) return []
|
|
227
203
|
|
|
228
|
-
const
|
|
204
|
+
const targetStr = String(candidateIndex).trim()
|
|
205
|
+
const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
206
|
+
const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
|
|
207
|
+
const moaRoot = path.join(baseDir, '.moa')
|
|
208
|
+
const candidateDir = assertPathContained(moaRoot, sub)
|
|
229
209
|
await fs.mkdir(candidateDir, { recursive: true })
|
|
230
210
|
|
|
231
211
|
const written = []
|
|
232
212
|
for (const file of files) {
|
|
233
|
-
|
|
213
|
+
if (!isSafeRelativePath(file.relativePath)) continue
|
|
214
|
+
const fullPath = assertPathContained(candidateDir, file.relativePath)
|
|
234
215
|
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
|
235
216
|
await fs.writeFile(fullPath, file.content, 'utf8')
|
|
236
217
|
written.push({
|
|
@@ -244,14 +225,44 @@ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [
|
|
|
244
225
|
}
|
|
245
226
|
|
|
246
227
|
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
228
|
+
* Best-effort integration with dsh-time-machine to create a shadow Git checkpoint
|
|
229
|
+
* before candidate promotion overwrites files in the project workspace.
|
|
249
230
|
*/
|
|
231
|
+
export async function createPrePromotionCheckpoint(baseDir, candidateTarget, options = {}) {
|
|
232
|
+
if (!baseDir || options.checkpoint === false) return null
|
|
233
|
+
if (typeof options.checkpointFn === 'function') {
|
|
234
|
+
const res = await bestEffort('custom checkpointFn', () => options.checkpointFn(baseDir, candidateTarget))
|
|
235
|
+
return res || null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const label = options.checkpointLabel || `moa-pre-promotion: candidate-${candidateTarget}`
|
|
239
|
+
const port = options.port || process.env.DSH_PORT || process.env.PORT || 3000
|
|
240
|
+
const endpoint = options.timeMachineUrl || `http://127.0.0.1:${port}/dsh-time-machine/create`
|
|
241
|
+
|
|
242
|
+
const snap = await bestEffort('dsh-time-machine pre-promotion checkpoint', async () => {
|
|
243
|
+
const res = await fetch(endpoint, {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers: { 'Content-Type': 'application/json', 'x-dsh-trusted': '1' },
|
|
246
|
+
body: JSON.stringify({ label, cwd: baseDir }),
|
|
247
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 1500),
|
|
248
|
+
})
|
|
249
|
+
if (res.ok) {
|
|
250
|
+
const data = await res.json()
|
|
251
|
+
return data?.snapshot || data || null
|
|
252
|
+
}
|
|
253
|
+
return null
|
|
254
|
+
})
|
|
255
|
+
return snap || null
|
|
256
|
+
}
|
|
257
|
+
|
|
250
258
|
export async function promoteCandidateWorkspace(baseDir, winningIndex, options = { keepMoa: false }) {
|
|
251
259
|
if (!baseDir || winningIndex === null || winningIndex === undefined) return []
|
|
252
260
|
|
|
253
|
-
const
|
|
261
|
+
const targetStr = String(winningIndex).trim()
|
|
262
|
+
const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
263
|
+
const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
|
|
254
264
|
const moaRoot = path.join(baseDir, '.moa')
|
|
265
|
+
const winningDir = assertPathContained(moaRoot, sub)
|
|
255
266
|
|
|
256
267
|
try {
|
|
257
268
|
const entries = await getFilesRecursively(winningDir)
|
|
@@ -262,20 +273,21 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
|
|
|
262
273
|
return []
|
|
263
274
|
}
|
|
264
275
|
|
|
276
|
+
const checkpoint = await createPrePromotionCheckpoint(baseDir, winningIndex, options)
|
|
277
|
+
|
|
265
278
|
const promoted = []
|
|
266
279
|
for (const rel of entries) {
|
|
267
|
-
|
|
268
|
-
const
|
|
280
|
+
if (!isSafeRelativePath(rel)) continue
|
|
281
|
+
const srcPath = assertPathContained(winningDir, rel)
|
|
282
|
+
const destPath = assertPathContained(baseDir, rel)
|
|
269
283
|
|
|
270
284
|
await fs.mkdir(path.dirname(destPath), { recursive: true })
|
|
271
285
|
await fs.copyFile(srcPath, destPath)
|
|
272
286
|
promoted.push(rel)
|
|
273
287
|
}
|
|
274
288
|
|
|
275
|
-
if (
|
|
276
|
-
|
|
277
|
-
await fs.rm(moaRoot, { recursive: true, force: true })
|
|
278
|
-
}
|
|
289
|
+
if (checkpoint) promoted.checkpoint = checkpoint
|
|
290
|
+
if (!options.keepMoa) await fs.rm(moaRoot, { recursive: true, force: true })
|
|
279
291
|
|
|
280
292
|
return promoted
|
|
281
293
|
} catch (err) {
|
|
@@ -300,7 +312,7 @@ async function getFilesRecursively(dir) {
|
|
|
300
312
|
try {
|
|
301
313
|
const stat = await fs.stat(dir)
|
|
302
314
|
if (!stat.isDirectory()) return []
|
|
303
|
-
} catch
|
|
315
|
+
} catch {
|
|
304
316
|
return []
|
|
305
317
|
}
|
|
306
318
|
|
|
@@ -311,13 +323,10 @@ async function getFilesRecursively(dir) {
|
|
|
311
323
|
for (const item of list) {
|
|
312
324
|
const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
|
|
313
325
|
const full = path.join(current, item.name)
|
|
314
|
-
if (item.isDirectory())
|
|
315
|
-
|
|
316
|
-
} else if (item.isFile()) {
|
|
317
|
-
results.push(rel)
|
|
318
|
-
}
|
|
326
|
+
if (item.isDirectory()) await scan(full, rel)
|
|
327
|
+
else if (item.isFile()) results.push(rel)
|
|
319
328
|
}
|
|
320
|
-
} catch
|
|
329
|
+
} catch {
|
|
321
330
|
return
|
|
322
331
|
}
|
|
323
332
|
}
|
|
@@ -325,10 +334,8 @@ async function getFilesRecursively(dir) {
|
|
|
325
334
|
return results
|
|
326
335
|
}
|
|
327
336
|
|
|
328
|
-
|
|
329
337
|
/**
|
|
330
338
|
* Verifies the syntactic validity of generated code files.
|
|
331
|
-
* Uses Node.js in-memory vm.Script for JS (with ES module export/import transformation) and JSON.parse for JSON.
|
|
332
339
|
*/
|
|
333
340
|
export function verifyFileSyntax(files = []) {
|
|
334
341
|
if (!Array.isArray(files)) return []
|
|
@@ -369,3 +376,150 @@ export function verifyFileSyntax(files = []) {
|
|
|
369
376
|
|
|
370
377
|
return warnings
|
|
371
378
|
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Reads all files from a candidate workspace (.moa/candidate-N or .moa/curator-synthesis)
|
|
382
|
+
*/
|
|
383
|
+
export async function readCandidateFiles(baseDir, candidateTarget) {
|
|
384
|
+
if (!baseDir || candidateTarget === undefined || candidateTarget === null) return []
|
|
385
|
+
const targetStr = String(candidateTarget).trim()
|
|
386
|
+
const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
387
|
+
const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
|
|
388
|
+
const moaRoot = path.join(baseDir, '.moa')
|
|
389
|
+
|
|
390
|
+
let targetDir
|
|
391
|
+
try {
|
|
392
|
+
targetDir = assertPathContained(moaRoot, sub)
|
|
393
|
+
} catch {
|
|
394
|
+
return []
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
const relFiles = await getFilesRecursively(targetDir)
|
|
399
|
+
const result = []
|
|
400
|
+
for (const rel of relFiles) {
|
|
401
|
+
if (!isSafeRelativePath(rel)) continue
|
|
402
|
+
try {
|
|
403
|
+
const fullPath = assertPathContained(targetDir, rel)
|
|
404
|
+
const content = await fs.readFile(fullPath, 'utf8')
|
|
405
|
+
result.push({
|
|
406
|
+
relativePath: rel,
|
|
407
|
+
content,
|
|
408
|
+
size: Buffer.byteLength(content, 'utf8'),
|
|
409
|
+
})
|
|
410
|
+
} catch {
|
|
411
|
+
// file read error best-effort
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return result
|
|
415
|
+
} catch {
|
|
416
|
+
return []
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Computes a line-by-line diff between two text strings using LCS with
|
|
422
|
+
* common prefix and suffix trimming for optimal O(delta) performance.
|
|
423
|
+
* Returns an array of diff items: { type: 'same' | 'added' | 'removed', line: string, oldNum?: number, newNum?: number }
|
|
424
|
+
*/
|
|
425
|
+
export function computeLineDiff(oldText = '', newText = '') {
|
|
426
|
+
const oldLines = typeof oldText === 'string' ? oldText.split(/\r?\n/) : []
|
|
427
|
+
const newLines = typeof newText === 'string' ? newText.split(/\r?\n/) : []
|
|
428
|
+
|
|
429
|
+
const n = oldLines.length
|
|
430
|
+
const m = newLines.length
|
|
431
|
+
|
|
432
|
+
if (n === 0 && m === 0) return []
|
|
433
|
+
if (oldText === newText) {
|
|
434
|
+
return oldLines.map((line, idx) => ({
|
|
435
|
+
type: 'same',
|
|
436
|
+
line,
|
|
437
|
+
oldNum: idx + 1,
|
|
438
|
+
newNum: idx + 1,
|
|
439
|
+
}))
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 1. Trim common prefix
|
|
443
|
+
let start = 0
|
|
444
|
+
while (start < n && start < m && oldLines[start] === newLines[start]) {
|
|
445
|
+
start++
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// 2. Trim common suffix
|
|
449
|
+
let oldEnd = n - 1
|
|
450
|
+
let newEnd = m - 1
|
|
451
|
+
while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) {
|
|
452
|
+
oldEnd--
|
|
453
|
+
newEnd--
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const prefixItems = []
|
|
457
|
+
for (let k = 0; k < start; k++) {
|
|
458
|
+
prefixItems.push({ type: 'same', line: oldLines[k], oldNum: k + 1, newNum: k + 1 })
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const suffixItems = []
|
|
462
|
+
const suffixLen = (n - 1) - oldEnd
|
|
463
|
+
for (let k = 0; k < suffixLen; k++) {
|
|
464
|
+
const oNum = oldEnd + 1 + k + 1
|
|
465
|
+
const nNum = newEnd + 1 + k + 1
|
|
466
|
+
suffixItems.push({ type: 'same', line: oldLines[oldEnd + 1 + k], oldNum: oNum, newNum: nNum })
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const subOld = oldLines.slice(start, oldEnd + 1)
|
|
470
|
+
const subNew = newLines.slice(start, newEnd + 1)
|
|
471
|
+
const subN = subOld.length
|
|
472
|
+
const subM = subNew.length
|
|
473
|
+
|
|
474
|
+
const middleItems = []
|
|
475
|
+
|
|
476
|
+
if (subN === 0) {
|
|
477
|
+
for (let j = 0; j < subM; j++) {
|
|
478
|
+
middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
|
|
479
|
+
}
|
|
480
|
+
} else if (subM === 0) {
|
|
481
|
+
for (let i = 0; i < subN; i++) {
|
|
482
|
+
middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
|
|
483
|
+
}
|
|
484
|
+
} else if (subN > 2000 || subM > 2000) {
|
|
485
|
+
for (let i = 0; i < subN; i++) {
|
|
486
|
+
middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
|
|
487
|
+
}
|
|
488
|
+
for (let j = 0; j < subM; j++) {
|
|
489
|
+
middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
const dp = Array.from({ length: subN + 1 }, () => new Uint16Array(subM + 1))
|
|
493
|
+
for (let i = 0; i < subN; i++) {
|
|
494
|
+
for (let j = 0; j < subM; j++) {
|
|
495
|
+
if (subOld[i] === subNew[j]) {
|
|
496
|
+
dp[i + 1][j + 1] = dp[i][j] + 1
|
|
497
|
+
} else {
|
|
498
|
+
dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1])
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
let i = subN
|
|
504
|
+
let j = subM
|
|
505
|
+
const stack = []
|
|
506
|
+
|
|
507
|
+
while (i > 0 || j > 0) {
|
|
508
|
+
if (i > 0 && j > 0 && subOld[i - 1] === subNew[j - 1]) {
|
|
509
|
+
stack.push({ type: 'same', line: subOld[i - 1], oldNum: start + i, newNum: start + j })
|
|
510
|
+
i--
|
|
511
|
+
j--
|
|
512
|
+
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
513
|
+
stack.push({ type: 'added', line: subNew[j - 1], newNum: start + j })
|
|
514
|
+
j--
|
|
515
|
+
} else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
|
|
516
|
+
stack.push({ type: 'removed', line: subOld[i - 1], oldNum: start + i })
|
|
517
|
+
i--
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
middleItems.push(...stack.reverse())
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return [...prefixItems, ...middleItems, ...suffixItems]
|
|
525
|
+
}
|
package/lib/history.js
CHANGED
|
@@ -15,6 +15,13 @@ const DEFAULT_HISTORY_FILE = path.join(DEFAULT_HISTORY_DIR, 'moa-history.jsonl')
|
|
|
15
15
|
/** Max history file size before rotation (10 MB) */
|
|
16
16
|
export const MAX_HISTORY_BYTES = 10 * 1024 * 1024
|
|
17
17
|
|
|
18
|
+
/** In-memory cache for leaderboard and parsed analytics */
|
|
19
|
+
let _historyCache = null
|
|
20
|
+
|
|
21
|
+
export function invalidateHistoryCache() {
|
|
22
|
+
_historyCache = null
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
function getHistoryFilePath(customDir) {
|
|
19
26
|
if (customDir) {
|
|
20
27
|
return path.join(customDir, '.moa-history.jsonl')
|
|
@@ -36,6 +43,7 @@ export function rotateHistoryFileIfNeeded(filePath = DEFAULT_HISTORY_FILE, maxBy
|
|
|
36
43
|
try { fs.unlinkSync(backupPath) } catch (unlinkErr) { /* backup unlink best-effort */ }
|
|
37
44
|
}
|
|
38
45
|
fs.renameSync(filePath, backupPath)
|
|
46
|
+
invalidateHistoryCache()
|
|
39
47
|
return true
|
|
40
48
|
}
|
|
41
49
|
} catch (err) {
|
|
@@ -97,6 +105,7 @@ export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
|
|
|
97
105
|
const entry = normalizeRunRecord(record)
|
|
98
106
|
const line = JSON.stringify(entry) + '\n'
|
|
99
107
|
fs.appendFileSync(filePath, line, 'utf8')
|
|
108
|
+
invalidateHistoryCache()
|
|
100
109
|
return entry
|
|
101
110
|
} catch (err) {
|
|
102
111
|
console.warn('[dsh-moa] Failed to record run to history:', err)
|
|
@@ -117,6 +126,7 @@ export async function recordMoaRunAsync(record, filePath = DEFAULT_HISTORY_FILE)
|
|
|
117
126
|
const entry = normalizeRunRecord(record)
|
|
118
127
|
const line = JSON.stringify(entry) + '\n'
|
|
119
128
|
await fs.promises.appendFile(filePath, line, 'utf8')
|
|
129
|
+
invalidateHistoryCache()
|
|
120
130
|
return entry
|
|
121
131
|
} catch (err) {
|
|
122
132
|
console.warn('[dsh-moa] Failed to async-record run to history:', err)
|
|
@@ -124,6 +134,49 @@ export async function recordMoaRunAsync(record, filePath = DEFAULT_HISTORY_FILE)
|
|
|
124
134
|
}
|
|
125
135
|
}
|
|
126
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Reads lines from the end of a file in reverse order in chunks.
|
|
139
|
+
* Avoids loading multi-megabyte JSONL files into V8 heap strings.
|
|
140
|
+
*/
|
|
141
|
+
export function readTailLinesSync(filePath, countNeeded = 50) {
|
|
142
|
+
const CHUNK_SIZE = 64 * 1024
|
|
143
|
+
const fd = fs.openSync(filePath, 'r')
|
|
144
|
+
try {
|
|
145
|
+
const stat = fs.fstatSync(fd)
|
|
146
|
+
let pos = stat.size
|
|
147
|
+
let remainder = ''
|
|
148
|
+
const collectedLines = []
|
|
149
|
+
|
|
150
|
+
while (pos > 0 && collectedLines.length < countNeeded) {
|
|
151
|
+
const bytesToRead = Math.min(CHUNK_SIZE, pos)
|
|
152
|
+
pos -= bytesToRead
|
|
153
|
+
const buf = Buffer.alloc(bytesToRead)
|
|
154
|
+
fs.readSync(fd, buf, 0, bytesToRead, pos)
|
|
155
|
+
const chunkStr = buf.toString('utf8') + remainder
|
|
156
|
+
const lines = chunkStr.split('\n')
|
|
157
|
+
if (pos > 0) {
|
|
158
|
+
remainder = lines.shift()
|
|
159
|
+
} else {
|
|
160
|
+
remainder = ''
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
164
|
+
const trimmed = lines[i].trim()
|
|
165
|
+
if (trimmed) {
|
|
166
|
+
collectedLines.push(trimmed)
|
|
167
|
+
if (collectedLines.length >= countNeeded) break
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (remainder && remainder.trim() && collectedLines.length < countNeeded) {
|
|
172
|
+
collectedLines.push(remainder.trim())
|
|
173
|
+
}
|
|
174
|
+
return collectedLines
|
|
175
|
+
} finally {
|
|
176
|
+
fs.closeSync(fd)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
127
180
|
/**
|
|
128
181
|
* Reads history runs with pagination (latest first).
|
|
129
182
|
*/
|
|
@@ -133,13 +186,34 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = DEFAULT_HISTORY
|
|
|
133
186
|
return { total: 0, runs: [] }
|
|
134
187
|
}
|
|
135
188
|
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
189
|
+
const stat = fs.statSync(filePath)
|
|
190
|
+
const needed = offset + limit
|
|
191
|
+
|
|
192
|
+
// For files under 64KB, straightforward full-read is fast and keeps exact total
|
|
193
|
+
if (stat.size <= 65536) {
|
|
194
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
195
|
+
const lines = raw.trim().split('\n').filter(Boolean)
|
|
196
|
+
const total = lines.length
|
|
197
|
+
|
|
198
|
+
const sliced = lines
|
|
199
|
+
.slice(Math.max(0, total - offset - limit), total - offset)
|
|
200
|
+
.reverse()
|
|
201
|
+
.map((line) => {
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(line)
|
|
204
|
+
} catch {
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
.filter(Boolean)
|
|
209
|
+
|
|
210
|
+
return { total, runs: sliced }
|
|
211
|
+
}
|
|
139
212
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
213
|
+
// For larger files: read only tail lines backwards to avoid splitting 10MB in memory
|
|
214
|
+
const tailLines = readTailLinesSync(filePath, needed)
|
|
215
|
+
const pageLines = tailLines.slice(offset, offset + limit)
|
|
216
|
+
const runs = pageLines
|
|
143
217
|
.map((line) => {
|
|
144
218
|
try {
|
|
145
219
|
return JSON.parse(line)
|
|
@@ -149,7 +223,12 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = DEFAULT_HISTORY
|
|
|
149
223
|
})
|
|
150
224
|
.filter(Boolean)
|
|
151
225
|
|
|
152
|
-
|
|
226
|
+
let total = _historyCache?.totalCount
|
|
227
|
+
if (typeof total !== 'number') {
|
|
228
|
+
total = tailLines.length >= needed ? Math.max(tailLines.length, Math.round(stat.size / 250)) : tailLines.length
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { total, runs }
|
|
153
232
|
} catch (err) {
|
|
154
233
|
console.warn('[dsh-moa] Failed to read history:', err)
|
|
155
234
|
return { total: 0, runs: [] }
|
|
@@ -158,6 +237,7 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = DEFAULT_HISTORY
|
|
|
158
237
|
|
|
159
238
|
/**
|
|
160
239
|
* Calculates win-rate leaderboard and model performance statistics across history.
|
|
240
|
+
* Employs in-memory caching keyed by file mtime and size for O(1) repeated queries.
|
|
161
241
|
*/
|
|
162
242
|
export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE, options = {}) {
|
|
163
243
|
try {
|
|
@@ -165,13 +245,35 @@ export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE, options = {})
|
|
|
165
245
|
return { totalRuns: 0, models: [] }
|
|
166
246
|
}
|
|
167
247
|
|
|
248
|
+
const stat = fs.statSync(filePath)
|
|
249
|
+
const presetFilter = typeof options === 'string' ? options : (options?.presetFilter || options?.preset || null)
|
|
250
|
+
const filterKey = presetFilter || 'all'
|
|
251
|
+
|
|
252
|
+
if (
|
|
253
|
+
_historyCache &&
|
|
254
|
+
_historyCache.filePath === filePath &&
|
|
255
|
+
_historyCache.mtimeMs === stat.mtimeMs &&
|
|
256
|
+
_historyCache.size === stat.size
|
|
257
|
+
) {
|
|
258
|
+
if (_historyCache.leaderboards.has(filterKey)) {
|
|
259
|
+
return _historyCache.leaderboards.get(filterKey)
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
_historyCache = {
|
|
263
|
+
filePath,
|
|
264
|
+
mtimeMs: stat.mtimeMs,
|
|
265
|
+
size: stat.size,
|
|
266
|
+
leaderboards: new Map(),
|
|
267
|
+
totalCount: null,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
168
271
|
const raw = fs.readFileSync(filePath, 'utf8')
|
|
169
272
|
const lines = raw.trim().split('\n').filter(Boolean)
|
|
170
273
|
const totalRuns = lines.length
|
|
274
|
+
_historyCache.totalCount = totalRuns
|
|
171
275
|
|
|
172
276
|
const modelStats = new Map()
|
|
173
|
-
const presetFilter = typeof options === 'string' ? options : (options?.presetFilter || options?.preset || null)
|
|
174
|
-
|
|
175
277
|
let filteredRuns = 0
|
|
176
278
|
for (const line of lines) {
|
|
177
279
|
try {
|
|
@@ -198,6 +300,7 @@ export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE, options = {})
|
|
|
198
300
|
}
|
|
199
301
|
const st = modelStats.get(key)
|
|
200
302
|
st.runs += 1
|
|
303
|
+
|
|
201
304
|
const inTok = cand.usage?.inputTokens || 0
|
|
202
305
|
const outTok = cand.usage?.outputTokens || 0
|
|
203
306
|
st.totalTokens += inTok + outTok
|
|
@@ -222,7 +325,13 @@ export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE, options = {})
|
|
|
222
325
|
}))
|
|
223
326
|
.sort((a, b) => b.wins - a.wins || b.winRate - a.winRate)
|
|
224
327
|
|
|
225
|
-
|
|
328
|
+
const result = {
|
|
329
|
+
totalRuns: presetFilter && presetFilter !== 'all' ? filteredRuns : totalRuns,
|
|
330
|
+
models,
|
|
331
|
+
preset: presetFilter,
|
|
332
|
+
}
|
|
333
|
+
_historyCache.leaderboards.set(filterKey, result)
|
|
334
|
+
return result
|
|
226
335
|
} catch (err) {
|
|
227
336
|
console.warn('[dsh-moa] Failed to calculate leaderboard:', err)
|
|
228
337
|
return { totalRuns: 0, models: [] }
|
|
@@ -303,6 +412,7 @@ export function candidatesForHistory(referenceOutputs) {
|
|
|
303
412
|
return (referenceOutputs || []).map((r) => ({
|
|
304
413
|
provider: r.slot?.provider || "",
|
|
305
414
|
model: r.slot?.model || "",
|
|
415
|
+
role: r.role_persona || r.slot?.role_persona || r.slot?.role || "general",
|
|
306
416
|
files: (r.files || []).map((f) => f.relativePath),
|
|
307
417
|
usage: r.usage || { inputTokens: 0, outputTokens: 0 },
|
|
308
418
|
costUsd: r.costUsd || 0,
|
package/lib/moa-candidates.js
CHANGED
|
@@ -81,8 +81,9 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
81
81
|
|
|
82
82
|
references.forEach((slot, i) => {
|
|
83
83
|
const label = slotLabel(slot)
|
|
84
|
-
const
|
|
85
|
-
|
|
84
|
+
const role = slot?.role_persona || slot?.role || 'general'
|
|
85
|
+
const persona = (role && ROLE_PERSONA_PROMPTS[role])
|
|
86
|
+
? `\n\n${ROLE_PERSONA_PROMPTS[role]}`
|
|
86
87
|
: ''
|
|
87
88
|
const slotMessages = [{ role: 'system', content: systemPrompt + persona }, ...advisoryMessages]
|
|
88
89
|
|
|
@@ -103,7 +104,12 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
103
104
|
)
|
|
104
105
|
|
|
105
106
|
const timeoutPromise = new Promise((_, reject) => {
|
|
106
|
-
const timer = setTimeout(() =>
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
bestEffort('candidate timeout abort', () => {
|
|
109
|
+
abortControllers[i].abort(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s`))
|
|
110
|
+
})
|
|
111
|
+
reject(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s`))
|
|
112
|
+
}, timeoutMs)
|
|
107
113
|
timer.unref?.()
|
|
108
114
|
abortControllers[i].signal.addEventListener('abort', () => clearTimeout(timer), { once: true })
|
|
109
115
|
})
|
|
@@ -127,6 +133,7 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
127
133
|
index: i + 1,
|
|
128
134
|
slot,
|
|
129
135
|
label,
|
|
136
|
+
role_persona: role,
|
|
130
137
|
text,
|
|
131
138
|
usage: costInfo,
|
|
132
139
|
costUsd: costInfo.costUsd,
|
|
@@ -143,6 +150,7 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
143
150
|
index: i + 1,
|
|
144
151
|
slot,
|
|
145
152
|
label,
|
|
153
|
+
role_persona: role,
|
|
146
154
|
text: `[Model ${label} error: ${errMsg}]`,
|
|
147
155
|
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
148
156
|
costUsd: 0,
|
package/lib/moa-prompts.js
CHANGED
|
@@ -190,7 +190,9 @@ At the end, add a note that the user can answer briefly (e.g.: "1, 2, dark theme
|
|
|
190
190
|
export function buildPeerCritiquePrompt(userPrompt, myProposal, otherProposals = [], isBlind = true) {
|
|
191
191
|
const othersText = otherProposals
|
|
192
192
|
.map((p, idx) => {
|
|
193
|
-
const
|
|
193
|
+
const role = p.role_persona || p.slot?.role_persona || p.role || p.slot?.role
|
|
194
|
+
const roleNote = role && role !== 'general' ? ` [Focus: ${role}]` : ''
|
|
195
|
+
const label = isBlind ? `Candidate ${idx + 1}${roleNote}` : `${p.label || `Candidate ${idx + 1}`}${roleNote}`
|
|
194
196
|
let text = p.text || ''
|
|
195
197
|
if (text.length > 3000) {
|
|
196
198
|
text = stripOrSummarizeCode(text)
|
|
@@ -230,10 +232,12 @@ export function buildCuratorSynthesisPrompt(userPrompt, referenceOutputs = [], j
|
|
|
230
232
|
if (textContent.length > 3000) {
|
|
231
233
|
textContent = stripOrSummarizeCode(textContent)
|
|
232
234
|
}
|
|
235
|
+
const role = r.role_persona || r.slot?.role_persona || r.role || r.slot?.role
|
|
236
|
+
const roleNote = role && role !== 'general' ? ` [Focus: ${role}]` : ''
|
|
233
237
|
const syntaxNote = r.syntaxWarning ? ` [⚠️ Syntax Warning: ${r.syntaxWarning}]` : ''
|
|
234
238
|
const header = isBlind
|
|
235
|
-
? `Candidate ${i + 1}:${syntaxNote}${fileSummary}`
|
|
236
|
-
: `Candidate ${i + 1} — ${r.label}:${syntaxNote}${fileSummary}`
|
|
239
|
+
? `Candidate ${i + 1}${roleNote}:${syntaxNote}${fileSummary}`
|
|
240
|
+
: `Candidate ${i + 1} — ${r.label}${roleNote}:${syntaxNote}${fileSummary}`
|
|
237
241
|
return `${header}\n${textContent}`
|
|
238
242
|
})
|
|
239
243
|
.join('\n\n')
|
|
@@ -297,10 +301,12 @@ export function buildSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCri
|
|
|
297
301
|
if (textContent.length > 3000) {
|
|
298
302
|
textContent = stripOrSummarizeCode(textContent)
|
|
299
303
|
}
|
|
304
|
+
const role = r.role_persona || r.slot?.role_persona || r.role || r.slot?.role
|
|
305
|
+
const roleNote = role && role !== 'general' ? ` [Focus: ${role}]` : ''
|
|
300
306
|
const syntaxNote = r.syntaxWarning ? ` [⚠️ Syntax Warning: ${r.syntaxWarning}]` : ''
|
|
301
307
|
const header = isBlind
|
|
302
|
-
? `Reference ${i + 1}:${syntaxNote}${fileSummary}`
|
|
303
|
-
: `Reference ${i + 1} — ${r.label}:${syntaxNote}${fileSummary}`
|
|
308
|
+
? `Reference ${i + 1}${roleNote}:${syntaxNote}${fileSummary}`
|
|
309
|
+
: `Reference ${i + 1} — ${r.label}${roleNote}:${syntaxNote}${fileSummary}`
|
|
304
310
|
return `${header}\n${textContent}`
|
|
305
311
|
})
|
|
306
312
|
.join('\n\n')
|
package/lib/moa-runner.js
CHANGED
|
@@ -307,7 +307,11 @@ export async function runMoAPipeline({
|
|
|
307
307
|
const r2Promises = successfulRefs.map(async (cand) => {
|
|
308
308
|
const opponents = successfulRefs
|
|
309
309
|
.filter((c) => c.index !== cand.index)
|
|
310
|
-
.map((c) => ({
|
|
310
|
+
.map((c) => ({
|
|
311
|
+
label: isBlindEvaluation ? `Candidate ${c.index}` : c.label,
|
|
312
|
+
role_persona: c.role_persona || c.slot?.role_persona,
|
|
313
|
+
text: c.text,
|
|
314
|
+
}))
|
|
311
315
|
const r2Prompt = buildPeerCritiquePrompt(userPrompt, cand.text, opponents, isBlindEvaluation)
|
|
312
316
|
try {
|
|
313
317
|
const slot = referenceModels[cand.index - 1] || { provider: cand.provider, model: cand.model }
|
package/lib/routes.js
CHANGED
|
@@ -8,10 +8,13 @@ import {
|
|
|
8
8
|
getMoaRunById,
|
|
9
9
|
exportMoaHistory,
|
|
10
10
|
} from './history.js'
|
|
11
|
-
import { promoteCandidateWorkspace } from './file-workspace.js'
|
|
11
|
+
import { promoteCandidateWorkspace, readCandidateFiles, computeLineDiff, isSafeRelativePath } from './file-workspace.js'
|
|
12
12
|
import { isSafeWriteRequest } from './updater.js'
|
|
13
13
|
import { runMoAPipeline } from './moa-runner.js'
|
|
14
14
|
|
|
15
|
+
let _modelsCache = { timestamp: 0, models: [] }
|
|
16
|
+
const MODELS_CACHE_TTL = 60 * 1000
|
|
17
|
+
|
|
15
18
|
function writeJson(res, status, body) {
|
|
16
19
|
res.writeHead(status, {
|
|
17
20
|
'Content-Type': 'application/json; charset=utf-8',
|
|
@@ -60,6 +63,11 @@ ctx.effect(() => {
|
|
|
60
63
|
kind: 'exact',
|
|
61
64
|
path: '/dsh-moa/models',
|
|
62
65
|
handler: async (_req, res) => {
|
|
66
|
+
if (_modelsCache.models.length > 0 && Date.now() - _modelsCache.timestamp < MODELS_CACHE_TTL) {
|
|
67
|
+
writeJson(res, 200, { ok: true, models: _modelsCache.models, cached: true })
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
const result = []
|
|
64
72
|
const seen = new Set()
|
|
65
73
|
|
|
@@ -87,18 +95,23 @@ ctx.effect(() => {
|
|
|
87
95
|
try {
|
|
88
96
|
if (typeof ctx.llm.listConfigurableProviders === 'function') {
|
|
89
97
|
const cProvs = ctx.llm.listConfigurableProviders() || []
|
|
90
|
-
|
|
98
|
+
const queries = cProvs.map(async (cp) => {
|
|
91
99
|
const cpId = cp.provider || cp.id
|
|
92
|
-
if (cpId
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
if (!cpId || typeof ctx.llm.listModels !== 'function') return
|
|
101
|
+
try {
|
|
102
|
+
const mList = await Promise.race([
|
|
103
|
+
ctx.llm.listModels(cpId),
|
|
104
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000)),
|
|
105
|
+
])
|
|
106
|
+
for (const m of (mList || [])) {
|
|
107
|
+
const mId = typeof m === 'string' ? m : (m?.id || m?.model || m?.name)
|
|
108
|
+
if (mId) add(cpId, mId)
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
/* provider query timeout or error handled best-effort */
|
|
100
112
|
}
|
|
101
|
-
}
|
|
113
|
+
})
|
|
114
|
+
await Promise.allSettled(queries)
|
|
102
115
|
}
|
|
103
116
|
} catch (e) {
|
|
104
117
|
console.warn('[dsh-moa] ctx.llm.listConfigurableProviders error:', e)
|
|
@@ -136,6 +149,7 @@ ctx.effect(() => {
|
|
|
136
149
|
console.warn('[dsh-moa] Error reading settings:', e)
|
|
137
150
|
}
|
|
138
151
|
|
|
152
|
+
_modelsCache = { timestamp: Date.now(), models: result }
|
|
139
153
|
writeJson(res, 200, { ok: true, models: result })
|
|
140
154
|
},
|
|
141
155
|
})
|
|
@@ -288,7 +302,7 @@ ctx.effect(() => {
|
|
|
288
302
|
}
|
|
289
303
|
const cwd = body?.cwd || process.cwd()
|
|
290
304
|
const promotedFiles = await promoteCandidateWorkspace(cwd, candidateIndex, { keepMoa: true })
|
|
291
|
-
writeJson(res, 200, { ok: true, candidateIndex, promotedFiles })
|
|
305
|
+
writeJson(res, 200, { ok: true, candidateIndex, promotedFiles, checkpoint: promotedFiles?.checkpoint || null })
|
|
292
306
|
} catch (err) {
|
|
293
307
|
writeJson(res, 500, { ok: false, error: err?.message || String(err) })
|
|
294
308
|
}
|
|
@@ -296,6 +310,63 @@ ctx.effect(() => {
|
|
|
296
310
|
})
|
|
297
311
|
}, 'dsh-moa: promote candidate route')
|
|
298
312
|
|
|
313
|
+
// Route: /dsh-moa/diff (Candidate Diff Viewer)
|
|
314
|
+
ctx.effect(() => {
|
|
315
|
+
return ctx.webServer.register({
|
|
316
|
+
kind: 'exact',
|
|
317
|
+
path: '/dsh-moa/diff',
|
|
318
|
+
handler: async (req, res) => {
|
|
319
|
+
if (req.method !== 'GET') {
|
|
320
|
+
writeJson(res, 405, { ok: false, error: 'GET required' })
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const u = new URL(req.url, 'http://localhost')
|
|
325
|
+
const cwd = u.searchParams.get('cwd') || process.cwd()
|
|
326
|
+
const sanitizeTarget = (val, def) => {
|
|
327
|
+
const clean = String(val || '').trim().replace(/[^a-zA-Z0-9_-]/g, '')
|
|
328
|
+
return clean || def
|
|
329
|
+
}
|
|
330
|
+
const from = sanitizeTarget(u.searchParams.get('from'), '1')
|
|
331
|
+
const to = sanitizeTarget(u.searchParams.get('to'), 'curator-synthesis')
|
|
332
|
+
const rawFile = u.searchParams.get('file') || ''
|
|
333
|
+
const file = (rawFile && isSafeRelativePath(rawFile)) ? rawFile : ''
|
|
334
|
+
|
|
335
|
+
const fromFiles = await readCandidateFiles(cwd, from)
|
|
336
|
+
const toFiles = await readCandidateFiles(cwd, to)
|
|
337
|
+
|
|
338
|
+
// Collect all unique file paths
|
|
339
|
+
const allPaths = Array.from(new Set([
|
|
340
|
+
...fromFiles.map((f) => f.relativePath),
|
|
341
|
+
...toFiles.map((f) => f.relativePath),
|
|
342
|
+
])).sort()
|
|
343
|
+
|
|
344
|
+
const targetPath = file || allPaths[0] || ''
|
|
345
|
+
const fromItem = fromFiles.find((f) => f.relativePath === targetPath)
|
|
346
|
+
const toItem = toFiles.find((f) => f.relativePath === targetPath)
|
|
347
|
+
|
|
348
|
+
const diff = targetPath
|
|
349
|
+
? computeLineDiff(fromItem?.content || '', toItem?.content || '')
|
|
350
|
+
: []
|
|
351
|
+
|
|
352
|
+
writeJson(res, 200, {
|
|
353
|
+
ok: true,
|
|
354
|
+
from,
|
|
355
|
+
to,
|
|
356
|
+
files: allPaths,
|
|
357
|
+
selectedFile: targetPath,
|
|
358
|
+
fromSize: fromItem?.size || 0,
|
|
359
|
+
toSize: toItem?.size || 0,
|
|
360
|
+
diff,
|
|
361
|
+
})
|
|
362
|
+
} catch (err) {
|
|
363
|
+
writeJson(res, 500, { ok: false, error: err?.message || String(err) })
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
})
|
|
367
|
+
}, 'dsh-moa: candidate diff route')
|
|
368
|
+
|
|
369
|
+
|
|
299
370
|
// Route: /dsh-moa/history/export
|
|
300
371
|
ctx.effect(() => {
|
|
301
372
|
return ctx.webServer.register({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-moa",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.21",
|
|
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",
|
|
@@ -14,10 +14,13 @@
|
|
|
14
14
|
"lib/",
|
|
15
15
|
"cordis.patch.yml",
|
|
16
16
|
"README.md",
|
|
17
|
+
"README.ru.md",
|
|
18
|
+
"README.zh.md",
|
|
17
19
|
"LICENSE"
|
|
18
20
|
],
|
|
19
21
|
"scripts": {
|
|
20
|
-
"test": "node --test test/*.test.mjs"
|
|
22
|
+
"test": "node --test test/*.test.mjs",
|
|
23
|
+
"build:client": "node scripts/build-client.mjs"
|
|
21
24
|
},
|
|
22
25
|
"keywords": [
|
|
23
26
|
"dsh",
|