@goodandready/dsh-moa 0.2.19 → 0.2.20
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 +235 -3
- package/lib/file-workspace.js +134 -2
- package/lib/history.js +1 -0
- package/lib/moa-candidates.js +5 -2
- package/lib/moa-prompts.js +11 -5
- package/lib/moa-runner.js +5 -1
- package/lib/routes.js +54 -2
- 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',
|
package/lib/file-workspace.js
CHANGED
|
@@ -225,7 +225,9 @@ export function isRefinementTask(userPrompt = '', projectFiles = null) {
|
|
|
225
225
|
export async function writeCandidateWorkspace(baseDir, candidateIndex, files = []) {
|
|
226
226
|
if (!baseDir || !Array.isArray(files) || files.length === 0) return []
|
|
227
227
|
|
|
228
|
-
const
|
|
228
|
+
const targetStr = String(candidateIndex).trim()
|
|
229
|
+
const sub = /^\d+$/.test(targetStr) ? `candidate-${targetStr}` : targetStr
|
|
230
|
+
const candidateDir = path.join(baseDir, '.moa', sub)
|
|
229
231
|
await fs.mkdir(candidateDir, { recursive: true })
|
|
230
232
|
|
|
231
233
|
const written = []
|
|
@@ -247,10 +249,49 @@ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [
|
|
|
247
249
|
* Promotes winning candidate files to baseDir root,
|
|
248
250
|
* then cleans up the temporary .moa folder.
|
|
249
251
|
*/
|
|
252
|
+
/**
|
|
253
|
+
* Best-effort integration with dsh-time-machine to create a shadow Git checkpoint
|
|
254
|
+
* before candidate promotion overwrites files in the project workspace.
|
|
255
|
+
*/
|
|
256
|
+
export async function createPrePromotionCheckpoint(baseDir, candidateTarget, options = {}) {
|
|
257
|
+
if (!baseDir || options.checkpoint === false) return null
|
|
258
|
+
if (typeof options.checkpointFn === 'function') {
|
|
259
|
+
const res = await bestEffort('custom checkpointFn', () => options.checkpointFn(baseDir, candidateTarget))
|
|
260
|
+
return res || null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const label = options.checkpointLabel || `moa-pre-promotion: candidate-${candidateTarget}`
|
|
264
|
+
const port = options.port || process.env.DSH_PORT || process.env.PORT || 3000
|
|
265
|
+
const endpoint = options.timeMachineUrl || `http://127.0.0.1:${port}/dsh-time-machine/create`
|
|
266
|
+
|
|
267
|
+
const snap = await bestEffort('dsh-time-machine pre-promotion checkpoint', async () => {
|
|
268
|
+
const res = await fetch(endpoint, {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
headers: {
|
|
271
|
+
'Content-Type': 'application/json',
|
|
272
|
+
'x-dsh-trusted': '1',
|
|
273
|
+
},
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
label,
|
|
276
|
+
cwd: baseDir,
|
|
277
|
+
}),
|
|
278
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 1500),
|
|
279
|
+
})
|
|
280
|
+
if (res.ok) {
|
|
281
|
+
const data = await res.json()
|
|
282
|
+
return data?.snapshot || data || null
|
|
283
|
+
}
|
|
284
|
+
return null
|
|
285
|
+
})
|
|
286
|
+
return snap || null
|
|
287
|
+
}
|
|
288
|
+
|
|
250
289
|
export async function promoteCandidateWorkspace(baseDir, winningIndex, options = { keepMoa: false }) {
|
|
251
290
|
if (!baseDir || winningIndex === null || winningIndex === undefined) return []
|
|
252
291
|
|
|
253
|
-
const
|
|
292
|
+
const targetStr = String(winningIndex).trim()
|
|
293
|
+
const sub = /^\d+$/.test(targetStr) ? `candidate-${targetStr}` : targetStr
|
|
294
|
+
const winningDir = path.join(baseDir, '.moa', sub)
|
|
254
295
|
const moaRoot = path.join(baseDir, '.moa')
|
|
255
296
|
|
|
256
297
|
try {
|
|
@@ -262,6 +303,9 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
|
|
|
262
303
|
return []
|
|
263
304
|
}
|
|
264
305
|
|
|
306
|
+
// Best-effort Time Machine checkpoint before overwriting files in baseDir
|
|
307
|
+
const checkpoint = await createPrePromotionCheckpoint(baseDir, winningIndex, options)
|
|
308
|
+
|
|
265
309
|
const promoted = []
|
|
266
310
|
for (const rel of entries) {
|
|
267
311
|
const srcPath = path.join(winningDir, rel)
|
|
@@ -272,6 +316,10 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
|
|
|
272
316
|
promoted.push(rel)
|
|
273
317
|
}
|
|
274
318
|
|
|
319
|
+
if (checkpoint) {
|
|
320
|
+
promoted.checkpoint = checkpoint
|
|
321
|
+
}
|
|
322
|
+
|
|
275
323
|
if (!options.keepMoa) {
|
|
276
324
|
// Clean up temporary .moa folder
|
|
277
325
|
await fs.rm(moaRoot, { recursive: true, force: true })
|
|
@@ -369,3 +417,87 @@ export function verifyFileSyntax(files = []) {
|
|
|
369
417
|
|
|
370
418
|
return warnings
|
|
371
419
|
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Reads all files from a candidate workspace (.moa/candidate-N or .moa/curator-synthesis)
|
|
423
|
+
*/
|
|
424
|
+
export async function readCandidateFiles(baseDir, candidateTarget) {
|
|
425
|
+
if (!baseDir || candidateTarget === undefined || candidateTarget === null) return []
|
|
426
|
+
const targetStr = String(candidateTarget).trim()
|
|
427
|
+
const sub = /^\d+$/.test(targetStr) ? `candidate-${targetStr}` : targetStr
|
|
428
|
+
const targetDir = path.join(baseDir, '.moa', sub)
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
const relFiles = await getFilesRecursively(targetDir)
|
|
432
|
+
const result = []
|
|
433
|
+
for (const rel of relFiles) {
|
|
434
|
+
try {
|
|
435
|
+
const fullPath = path.join(targetDir, rel)
|
|
436
|
+
const content = await fs.readFile(fullPath, 'utf8')
|
|
437
|
+
result.push({
|
|
438
|
+
relativePath: rel,
|
|
439
|
+
content,
|
|
440
|
+
size: Buffer.byteLength(content, 'utf8'),
|
|
441
|
+
})
|
|
442
|
+
} catch (readErr) {
|
|
443
|
+
// file read error best-effort
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return result
|
|
447
|
+
} catch (err) {
|
|
448
|
+
return []
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Computes a standard line-by-line diff between two text strings using LCS.
|
|
454
|
+
* Returns an array of diff items: { type: 'same' | 'added' | 'removed', line: string, oldNum?: number, newNum?: number }
|
|
455
|
+
*/
|
|
456
|
+
export function computeLineDiff(oldText = '', newText = '') {
|
|
457
|
+
const oldLines = typeof oldText === 'string' ? oldText.split(/\r?\n/) : []
|
|
458
|
+
const newLines = typeof newText === 'string' ? newText.split(/\r?\n/) : []
|
|
459
|
+
|
|
460
|
+
const n = oldLines.length
|
|
461
|
+
const m = newLines.length
|
|
462
|
+
|
|
463
|
+
// Safety bound to avoid polynomial blowup on enormous files
|
|
464
|
+
if (n > 2000 || m > 2000) {
|
|
465
|
+
const res = []
|
|
466
|
+
for (let i = 0; i < n; i++) res.push({ type: 'removed', line: oldLines[i], oldNum: i + 1 })
|
|
467
|
+
for (let j = 0; j < m; j++) res.push({ type: 'added', line: newLines[j], newNum: j + 1 })
|
|
468
|
+
return res
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// DP table for LCS
|
|
472
|
+
const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1))
|
|
473
|
+
for (let i = 0; i < n; i++) {
|
|
474
|
+
for (let j = 0; j < m; j++) {
|
|
475
|
+
if (oldLines[i] === newLines[j]) {
|
|
476
|
+
dp[i + 1][j + 1] = dp[i][j] + 1
|
|
477
|
+
} else {
|
|
478
|
+
dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1])
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Backtrack
|
|
484
|
+
let i = n
|
|
485
|
+
let j = m
|
|
486
|
+
const stack = []
|
|
487
|
+
|
|
488
|
+
while (i > 0 || j > 0) {
|
|
489
|
+
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
|
|
490
|
+
stack.push({ type: 'same', line: oldLines[i - 1], oldNum: i, newNum: j })
|
|
491
|
+
i--
|
|
492
|
+
j--
|
|
493
|
+
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
494
|
+
stack.push({ type: 'added', line: newLines[j - 1], newNum: j })
|
|
495
|
+
j--
|
|
496
|
+
} else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
|
|
497
|
+
stack.push({ type: 'removed', line: oldLines[i - 1], oldNum: i })
|
|
498
|
+
i--
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return stack.reverse()
|
|
503
|
+
}
|
package/lib/history.js
CHANGED
|
@@ -303,6 +303,7 @@ export function candidatesForHistory(referenceOutputs) {
|
|
|
303
303
|
return (referenceOutputs || []).map((r) => ({
|
|
304
304
|
provider: r.slot?.provider || "",
|
|
305
305
|
model: r.slot?.model || "",
|
|
306
|
+
role: r.role_persona || r.slot?.role_persona || r.slot?.role || "general",
|
|
306
307
|
files: (r.files || []).map((f) => f.relativePath),
|
|
307
308
|
usage: r.usage || { inputTokens: 0, outputTokens: 0 },
|
|
308
309
|
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
|
|
|
@@ -127,6 +128,7 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
127
128
|
index: i + 1,
|
|
128
129
|
slot,
|
|
129
130
|
label,
|
|
131
|
+
role_persona: role,
|
|
130
132
|
text,
|
|
131
133
|
usage: costInfo,
|
|
132
134
|
costUsd: costInfo.costUsd,
|
|
@@ -143,6 +145,7 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
143
145
|
index: i + 1,
|
|
144
146
|
slot,
|
|
145
147
|
label,
|
|
148
|
+
role_persona: role,
|
|
146
149
|
text: `[Model ${label} error: ${errMsg}]`,
|
|
147
150
|
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
148
151
|
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,7 +8,7 @@ import {
|
|
|
8
8
|
getMoaRunById,
|
|
9
9
|
exportMoaHistory,
|
|
10
10
|
} from './history.js'
|
|
11
|
-
import { promoteCandidateWorkspace } from './file-workspace.js'
|
|
11
|
+
import { promoteCandidateWorkspace, readCandidateFiles, computeLineDiff } from './file-workspace.js'
|
|
12
12
|
import { isSafeWriteRequest } from './updater.js'
|
|
13
13
|
import { runMoAPipeline } from './moa-runner.js'
|
|
14
14
|
|
|
@@ -288,7 +288,7 @@ ctx.effect(() => {
|
|
|
288
288
|
}
|
|
289
289
|
const cwd = body?.cwd || process.cwd()
|
|
290
290
|
const promotedFiles = await promoteCandidateWorkspace(cwd, candidateIndex, { keepMoa: true })
|
|
291
|
-
writeJson(res, 200, { ok: true, candidateIndex, promotedFiles })
|
|
291
|
+
writeJson(res, 200, { ok: true, candidateIndex, promotedFiles, checkpoint: promotedFiles?.checkpoint || null })
|
|
292
292
|
} catch (err) {
|
|
293
293
|
writeJson(res, 500, { ok: false, error: err?.message || String(err) })
|
|
294
294
|
}
|
|
@@ -296,6 +296,58 @@ ctx.effect(() => {
|
|
|
296
296
|
})
|
|
297
297
|
}, 'dsh-moa: promote candidate route')
|
|
298
298
|
|
|
299
|
+
// Route: /dsh-moa/diff (Candidate Diff Viewer)
|
|
300
|
+
ctx.effect(() => {
|
|
301
|
+
return ctx.webServer.register({
|
|
302
|
+
kind: 'exact',
|
|
303
|
+
path: '/dsh-moa/diff',
|
|
304
|
+
handler: async (req, res) => {
|
|
305
|
+
if (req.method !== 'GET') {
|
|
306
|
+
writeJson(res, 405, { ok: false, error: 'GET required' })
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const u = new URL(req.url, 'http://localhost')
|
|
311
|
+
const cwd = u.searchParams.get('cwd') || process.cwd()
|
|
312
|
+
const from = u.searchParams.get('from') || '1'
|
|
313
|
+
const to = u.searchParams.get('to') || 'curator-synthesis'
|
|
314
|
+
const file = u.searchParams.get('file') || ''
|
|
315
|
+
|
|
316
|
+
const fromFiles = await readCandidateFiles(cwd, from)
|
|
317
|
+
const toFiles = await readCandidateFiles(cwd, to)
|
|
318
|
+
|
|
319
|
+
// Collect all unique file paths
|
|
320
|
+
const allPaths = Array.from(new Set([
|
|
321
|
+
...fromFiles.map((f) => f.relativePath),
|
|
322
|
+
...toFiles.map((f) => f.relativePath),
|
|
323
|
+
])).sort()
|
|
324
|
+
|
|
325
|
+
const targetPath = file || allPaths[0] || ''
|
|
326
|
+
const fromItem = fromFiles.find((f) => f.relativePath === targetPath)
|
|
327
|
+
const toItem = toFiles.find((f) => f.relativePath === targetPath)
|
|
328
|
+
|
|
329
|
+
const diff = targetPath
|
|
330
|
+
? computeLineDiff(fromItem?.content || '', toItem?.content || '')
|
|
331
|
+
: []
|
|
332
|
+
|
|
333
|
+
writeJson(res, 200, {
|
|
334
|
+
ok: true,
|
|
335
|
+
from,
|
|
336
|
+
to,
|
|
337
|
+
files: allPaths,
|
|
338
|
+
selectedFile: targetPath,
|
|
339
|
+
fromSize: fromItem?.size || 0,
|
|
340
|
+
toSize: toItem?.size || 0,
|
|
341
|
+
diff,
|
|
342
|
+
})
|
|
343
|
+
} catch (err) {
|
|
344
|
+
writeJson(res, 500, { ok: false, error: err?.message || String(err) })
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
})
|
|
348
|
+
}, 'dsh-moa: candidate diff route')
|
|
349
|
+
|
|
350
|
+
|
|
299
351
|
// Route: /dsh-moa/history/export
|
|
300
352
|
ctx.effect(() => {
|
|
301
353
|
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.20",
|
|
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",
|