@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 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
 
@@ -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
- // Pattern 1: fenced blocks with file="..." or path="..." in info string
23
- // Example: ```html file="index.html" or ```js path=src/main.js
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: sanitizePath(rawPath), content })
41
+ files.push({ relativePath: rawPath, content: fileContent.trimEnd() + '\n' })
32
42
  }
33
43
  }
34
44
 
35
- // Pattern 2: Explicit headers before code blocks, e.g.
36
- // **File: `index.html`** or ### File: index.html or `File: index.html` followed by ```
37
- const headerRegex = /(?:###?\s*File:\s*|File:\s*|#\s*File:\s*)[`"]?([a-zA-Z0-9_.\-\/\\]+)[`"]?\s*[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi
38
- while ((match = headerRegex.exec(text)) !== null) {
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 3: Comment inside the code block on line 1: // File: foo.js or /* File: foo.css */ or <!-- File: index.html -->
48
- const commentRegex = /```[\w-]*[\r\n]+(?:\/\/|\/\*|<!--|#)\s*(?:File|filename):\s*([a-zA-Z0-9_.\-\/\\]+)(?:\s*\*\/|\s*-->)?[\r\n]+([\s\S]*?)```/gi
49
- while ((match = commentRegex.exec(text)) !== null) {
50
- const rawPath = match[1].trim()
51
- const content = match[2]
52
- if (rawPath && !seenPaths.has(rawPath)) {
53
- seenPaths.add(rawPath)
54
- files.push({ relativePath: sanitizePath(rawPath), content })
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: If no annotated blocks found, check if text has exactly one prominent standalone code block with common extensions
62
+ // Fallback: single anonymous code block if it contains complete HTML
59
63
  if (files.length === 0) {
60
- const singleBlockRegex = /```(html|css|javascript|js|json|python|py|sh|bash)[\r\n]+([\s\S]*?)```/gi
61
- const singleMatches = []
62
- while ((match = singleBlockRegex.exec(text)) !== null) {
63
- singleMatches.push({ lang: match[1].toLowerCase(), content: match[2] })
64
- }
65
- if (singleMatches.length === 1) {
66
- const extMap = {
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
- * Collects readable text files from current workspace to feed as context for candidate models (#6).
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 (readDirErr) {
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 (fileErr) {
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 candidateDir = path.join(baseDir, '.moa', `candidate-${candidateIndex}`)
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
- const fullPath = path.join(candidateDir, file.relativePath)
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
- * Promotes winning candidate files to baseDir root,
248
- * then cleans up the temporary .moa folder.
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 winningDir = path.join(baseDir, '.moa', `candidate-${winningIndex}`)
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
- const srcPath = path.join(winningDir, rel)
268
- const destPath = path.join(baseDir, rel)
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 (!options.keepMoa) {
276
- // Clean up temporary .moa folder
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 (statErr) {
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
- await scan(full, rel)
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 (readErr) {
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 raw = fs.readFileSync(filePath, 'utf8')
137
- const lines = raw.trim().split('\n').filter(Boolean)
138
- const total = lines.length
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
- const sliced = lines
141
- .slice(Math.max(0, total - offset - limit), total - offset)
142
- .reverse()
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
- return { total, runs: sliced }
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
- return { totalRuns: presetFilter && presetFilter !== 'all' ? filteredRuns : totalRuns, models, preset: presetFilter }
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,
@@ -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 persona = slot?.role_persona && ROLE_PERSONA_PROMPTS[slot.role_persona]
85
- ? `\n\n${ROLE_PERSONA_PROMPTS[slot.role_persona]}`
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(() => reject(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s`)), timeoutMs)
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,
@@ -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 label = isBlind ? `Candidate ${idx + 1}` : (p.label || `Candidate ${idx + 1}`)
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) => ({ label: isBlindEvaluation ? `Candidate ${c.index}` : c.label, text: c.text }))
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
- for (const cp of cProvs) {
98
+ const queries = cProvs.map(async (cp) => {
91
99
  const cpId = cp.provider || cp.id
92
- if (cpId && typeof ctx.llm.listModels === 'function') {
93
- try {
94
- const mList = await ctx.llm.listModels(cpId)
95
- for (const m of (mList || [])) {
96
- const mId = typeof m === 'string' ? m : (m.id || m.model || m.name)
97
- if (mId) add(cpId, mId)
98
- }
99
- } catch (mErr) { /* listModels best-effort */ }
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.19",
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",