@goodandready/dsh-moa 0.2.13 → 0.2.15

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.
@@ -69,3 +69,11 @@
69
69
  - 2026-09-12 (вечер) — Декомпозиция lib/moa-runner.js на специализированные модули (`lib/moa-prompts.js`, `lib/moa-parser.js`, `lib/moa-runner.js`) с соблюдением канонического лимита <= 800 строк. Реализация автоматической ротации истории при превышении 10 МБ (`rotateHistoryFileIfNeeded`) и неблокирующего асинхронного сохранения (`recordMoaRunAsync`). Настраиваемые таймауты кандидатов и агрегатора (`reference_timeout_sec`, `aggregator_timeout_sec`).
70
70
 
71
71
  - 2026-09-13 — Расширение функционала MoA (Gitea Issue #59): интерактивный селектор альтернативного кандидата в чате (User Candidate Override: allow_candidate_override), пакет из 10 специализированных пресетов с ролями кандидатов (role_persona), локальный гейт синтаксической проверки (Syntax Pre-check Gate) с директивой автоисправления синтаксиса судьёй, режим взаимного рецензирования («Консилиум» / Peer Critique Round 2: peer_critique_enabled), фильтрация лидерборда по пресетам и выгрузка телеметрии в CSV/JSON.
72
+
73
+ - 2026-09-13 — Приведение локализации плагина в строгое соответствие со стандартами dhs-plugin-release-workflow и dsh-plugin-authoring (Gitea Issue #61): добавление полного китайского словаря (zh) в lib/client.js наряду с каноническим английским (en), удаление захардкоженных русских строк в серверной части (lib/moa-parser.js, lib/moa-runner.js), интернационализация эвристик в lib/file-workspace.js и lib/moa-prompts.js (en + zh), создание issue в goodandready/dsh-russian-lang для русификации.
74
+
75
+ ## 2026-09-15: Pipeline Stability & Quality Polish (#63)
76
+ - **Refinement Context**: `formatProjectContext(files)` serializes workspace files as fenced Markdown code blocks. `moa-runner.js` passes collected files into `isRefinementTask(prompt, files)`.
77
+ - **Round 2 Persistence**: Candidate file refinements from Round 2 are persisted to `.moa/candidate-N/` via `writeCandidateWorkspace`.
78
+ - **AbortSignal Lifecycle**: `signal` is propagated end-to-end to terminate parallel LLM calls immediately when user aborts or disconnects.
79
+ - **Token Protection**: Peer critique prompts summarize oversized competitor code blocks (>3000 chars) to prevent context window exhaustion.
@@ -0,0 +1,15 @@
1
+ # Implementation Plan - Issue #63: Pipeline Stability and Quality Polish
2
+
3
+ ## 1. Context & Objectives
4
+ Polish runtime stability, error recovery, token efficiency, and file generation integrity across `@goodandready/dsh-moa`:
5
+ 1. **Refinement Context & Heuristic**: Fix `isRefinementTask` parameter handling in `moa-runner.js` and format collected files into Markdown code fences via `formatProjectContext(files)`, preventing `[object Object]` prompt injection.
6
+ 2. **Round 2 Workspace Sync**: Save refined files to `.moa/candidate-N/` in Consilium Round 2 so promoted winner files contain peer-critiqued code rather than stale Round 1 drafts.
7
+ 3. **End-to-End AbortSignal Propagation**: Propagate `signal` from `streamMoATurn` through `runMoAPipeline`, `runReferencesParallel`, Round 2 calls, and judge synthesis, cancelling in-flight requests and preventing token waste.
8
+ 4. **Context Window Protection**: Apply `stripOrSummarizeCode` in Round 2 peer review and judge synthesis prompts when candidate outputs exceed threshold.
9
+ 5. **Architectural Standard**: Maintain file size of `lib/moa-runner.js` strictly under 800 lines.
10
+
11
+ ## 2. Modules Impacted
12
+ - `lib/file-workspace.js`: Add `formatProjectContext`, enhance `isRefinementTask`.
13
+ - `lib/moa-prompts.js`: Context protection in `buildPeerCritiquePrompt` & `buildSynthesisPrompt`.
14
+ - `lib/moa-runner.js`: `signal` propagation, Round 2 workspace persistence, refinement context formatting.
15
+ - `test/`: Add regression tests for all fixes.
package/lib/client.js CHANGED
@@ -60,6 +60,11 @@ window.__ModuleLoader__.load({
60
60
  'aggregator.override_label': 'Allow Candidate Override Actions',
61
61
  'aggregator.override_hint': 'Keeps candidate workspaces in .moa to let you apply any candidate files via chat command.',
62
62
  'proposers.role_label': 'Persona:',
63
+ 'proposers.role_general': 'General',
64
+ 'proposers.role_minimalist': 'Minimalist',
65
+ 'proposers.role_robustness': 'Robustness',
66
+ 'proposers.role_performance': 'Performance',
67
+ 'proposers.role_tester': 'Tester',
63
68
  'leaderboard.filter_all': 'All Presets',
64
69
  'leaderboard.export_csv': 'Export CSV',
65
70
  'leaderboard.export_json': 'Export JSON',
@@ -110,6 +115,99 @@ window.__ModuleLoader__.load({
110
115
  'slash.desc': 'Run turn with Mixture of Agents ensemble synthesis (/moa [preset] <prompt>)',
111
116
  }
112
117
 
118
+ const zh = {
119
+ title: '智能体混合 (MoA)',
120
+ description: '多模型协同编排:通过 /moa <提示词> 进行候选模型并行提案与主裁判综合。',
121
+ 'header.title': '智能体混合 (MoA) 编排系统',
122
+ 'header.sub': '由多个候选提案模型并行生成方案,再由领先的聚合裁判模型进行终审评测与代码提拔。完成后自动切回您的会话模型。',
123
+ 'badge.online': 'MoA 已启用',
124
+ 'badge.offline': '主机不可达',
125
+ 'badge.preset': '预设:{name}',
126
+ 'badge.models': '{count} 个可用模型',
127
+ 'badge.candidates': '{count} 个提案模型',
128
+ 'presets.title': '📋 预设管理',
129
+ 'presets.desc': '选择当前活动预设或针对特定任务(如代码审查、数学推演、快速分流)创建自定义配置。',
130
+ 'presets.active_badge': '默认激活',
131
+ 'presets.set_default': '设为默认',
132
+ 'presets.new_btn': '+ 新建预设',
133
+ 'presets.del_btn': '删除',
134
+ 'presets.del_confirm': '确定要删除预设“{name}”吗?',
135
+ 'presets.create': '创建',
136
+ 'presets.cancel': '取消',
137
+ 'presets.name_placeholder': '新预设名称(例如:fast-audit)',
138
+ 'aggregator.title': '⚖️ 领先模型 / 裁判(综合与审计)',
139
+ 'aggregator.desc': '对比候选方案,消除差异,过滤幻觉,并综合生成最终生产级解决方案。',
140
+ 'aggregator.temp_label': '裁判采样温度 (Temperature)',
141
+ 'aggregator.temp_hint': '推荐:0.2 – 0.4,以确保严格的验证和确定性的代码综合。',
142
+ 'aggregator.criteria_label': '裁判评测标准(可选):',
143
+ 'aggregator.criteria_placeholder': '例如:优先考虑高性能、零外部依赖、完善的边界异常处理...',
144
+ 'aggregator.questions_label': '对于宽泛任务先提出澄清问题(需求问卷)',
145
+ 'aggregator.questions_hint': '启用时:对于简短或模糊的任务,裁判在生成代码前会先综合一份包含 2–4 个问题的选项清单。',
146
+ 'aggregator.curator_label': '策展人综合与主组装模型建议',
147
+ 'aggregator.curator_hint': '启用时:策展人会提炼每个候选方案的精华部分,应用反模式清单,并建议由哪个智能体模型组装最终方案。',
148
+ 'aggregator.stream_label': '实时流式传输裁判/策展人思考过程',
149
+ 'aggregator.stream_hint': '将裁判生成的思考过程实时推送到聊天框,实现零延迟首字响应。',
150
+ 'aggregator.peer_label': '决策评议会:候选模型交叉互评(第二轮 Consilium)',
151
+ 'aggregator.peer_hint': '在主裁判终审前,各候选模型互相审阅并改进彼此的方案代码。',
152
+ 'aggregator.override_label': '允许手动候选方案提拔操作 (Override)',
153
+ 'aggregator.override_hint': '在 .moa 目录中保留候选模型的工作区,允许通过聊天命令提拔任意候选模型的文件。',
154
+ 'proposers.role_label': '工程角色画像:',
155
+ 'proposers.role_general': '通用平衡 (General)',
156
+ 'proposers.role_minimalist': '极简标准库 (Minimalist)',
157
+ 'proposers.role_robustness': '防御鲁棒 (Robustness)',
158
+ 'proposers.role_performance': '极致性能 (Performance)',
159
+ 'proposers.role_tester': '测试驱动 (Tester)',
160
+ 'leaderboard.filter_all': '全部预设',
161
+ 'leaderboard.export_csv': '导出 CSV',
162
+ 'leaderboard.export_json': '导出 JSON',
163
+ 'aggregator.blind_label': '盲审模式(对裁判隐藏候选模型名称)',
164
+ 'aggregator.blind_hint': '启用时:从裁判提示词中匿名化候选模型的提供商和名称,杜绝品牌偏见。',
165
+ 'aggregator.timeout_label': '裁判综合超时时长(秒):',
166
+ 'proposers.timeout_label': '候选模型生成超时时长(秒):',
167
+ 'leaderboard.title': '🏆 模型胜率排行榜',
168
+ 'leaderboard.desc': '基于历史记录的 MoA 裁判评测判决数据统计指标。',
169
+ 'leaderboard.model': '模型',
170
+ 'leaderboard.runs': '运行次数',
171
+ 'leaderboard.wins': '胜出次数',
172
+ 'leaderboard.winrate': '胜率',
173
+ 'leaderboard.cost': '平均成本',
174
+ 'leaderboard.empty': '暂无历史运行记录。',
175
+ 'aggregator.fallbacks_title': '备用裁判链',
176
+ 'aggregator.fallbacks_desc': '当主裁判触发速率限制或服务不可用时,自动顺序调用的备用模型。',
177
+ 'aggregator.add_fallback_btn': '+ 添加备用裁判',
178
+ 'proposers.quorum_label': '法定人数快进(防拖尾模型机制)',
179
+ 'proposers.quorum_hint': '当 67% 以上候选模型完成时,开启缓冲定时器(默认 10 秒),不再死等卡顿的模型。',
180
+ 'proposers.grace_label': '缓冲等待时长(秒):',
181
+ 'proposers.title': '👥 候选提案模型 (Proposers)',
182
+ 'proposers.desc': '并行生成互相独立的解决方案,最大化思路多样性与解空间覆盖度。',
183
+ 'proposers.parallel_count': '并行提案模型数量',
184
+ 'proposers.candidate': '候选模型',
185
+ 'proposers.add_btn': '+ 添加候选模型',
186
+ 'proposers.temp_label': '候选模型采样温度 (Temperature)',
187
+ 'proposers.temp_hint': '推荐:0.6 – 0.8,鼓励创造性、发散性的独立探索。',
188
+ 'stats.title': '📈 MoA 数据分析与遥测',
189
+ 'stats.desc': '会话遥测、历史运行追踪及模型胜率统计。',
190
+ 'stats.total_runs': '总运行次数',
191
+ 'stats.avg_cost': '平均每次成本',
192
+ 'stats.active_models': '已配置模型数',
193
+ 'stats.preset_count': '预设总数',
194
+ 'picker.select': '选择模型…',
195
+ 'picker.search_placeholder': '按名称或提供商搜索模型…',
196
+ 'picker.found': '找到',
197
+ 'picker.of': '共',
198
+ 'picker.clear': '清除',
199
+ 'picker.none': '未找到匹配的模型。',
200
+ 'picker.custom': '使用自定义:',
201
+ 'actions.save': '保存配置',
202
+ 'actions.saving': '正在保存…',
203
+ 'actions.saved': '配置已成功保存!',
204
+ 'actions.retry': '重新加载配置',
205
+ 'badge.disabled': 'MoA 已禁用',
206
+ 'config.enabled_label': '启用 MoA(斜杠命令与轮次拦截路由)',
207
+ 'slash.desc': '使用智能体混合 (MoA) 协同综合运行此轮次 (/moa [预设] <提示词>)',
208
+ }
209
+
210
+
113
211
  function makeT(dict, fallback) {
114
212
  return function t(key, vars) {
115
213
  let val = (dict && dict[key]) || (fallback && fallback[key]) || key
@@ -1110,11 +1208,11 @@ window.__ModuleLoader__.load({
1110
1208
  })
1111
1209
  },
1112
1210
  },
1113
- React.createElement('option', { value: 'general' }, 'General'),
1114
- React.createElement('option', { value: 'minimalist' }, 'Minimalist'),
1115
- React.createElement('option', { value: 'robustness' }, 'Robustness'),
1116
- React.createElement('option', { value: 'performance' }, 'Performance'),
1117
- React.createElement('option', { value: 'tester' }, 'Tester')
1211
+ React.createElement('option', { value: 'general' }, t('proposers.role_general') || 'General'),
1212
+ React.createElement('option', { value: 'minimalist' }, t('proposers.role_minimalist') || 'Minimalist'),
1213
+ React.createElement('option', { value: 'robustness' }, t('proposers.role_robustness') || 'Robustness'),
1214
+ React.createElement('option', { value: 'performance' }, t('proposers.role_performance') || 'Performance'),
1215
+ React.createElement('option', { value: 'tester' }, t('proposers.role_tester') || 'Tester')
1118
1216
  ),
1119
1217
  currentRefs.length > 1 &&
1120
1218
  React.createElement(
@@ -1229,7 +1327,7 @@ window.__ModuleLoader__.load({
1229
1327
  className: 'moa-btn',
1230
1328
  style: { height: 26, fontSize: 11, padding: '0 8px', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
1231
1329
  },
1232
- 'CSV'
1330
+ t('leaderboard.export_csv') || 'CSV'
1233
1331
  ),
1234
1332
  React.createElement(
1235
1333
  'a',
@@ -1239,7 +1337,7 @@ window.__ModuleLoader__.load({
1239
1337
  className: 'moa-btn',
1240
1338
  style: { height: 26, fontSize: 11, padding: '0 8px', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
1241
1339
  },
1242
- 'JSON'
1340
+ t('leaderboard.export_json') || 'JSON'
1243
1341
  )
1244
1342
  )
1245
1343
  ),
@@ -1628,7 +1726,7 @@ window.__ModuleLoader__.load({
1628
1726
 
1629
1727
  if (typeof ctx.effect === 'function') {
1630
1728
  ctx.effect(() => {
1631
- const undo = [addLocale('en', en)]
1729
+ const undo = [addLocale('en', en), addLocale('zh', zh)]
1632
1730
  return () => {
1633
1731
  for (const off of undo) {
1634
1732
  if (typeof off === 'function') {
@@ -90,6 +90,18 @@ function sanitizePath(filePath) {
90
90
  * Collects readable text files from current workspace to feed as context for candidate models (#6).
91
91
  * Ignores node_modules, .git, .moa, binary files, large bundles.
92
92
  */
93
+ /**
94
+ * Formats collected project files into structured Markdown code blocks
95
+ * suitable for inclusion in candidate system prompts.
96
+ */
97
+ export function formatProjectContext(projectFiles = []) {
98
+ if (!Array.isArray(projectFiles) || projectFiles.length === 0) return ''
99
+ const fence = '```'
100
+ return projectFiles
101
+ .map((f) => `### File: ${f.relativePath}\n${fence}\n${f.content}\n${fence}`)
102
+ .join('\n\n')
103
+ }
104
+
93
105
  export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
94
106
  if (!baseDir) return { files: [], totalChars: 0 }
95
107
 
@@ -145,17 +157,17 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
145
157
  * Checks if a task prompt is an iterative refinement / modification
146
158
  * of an existing project rather than a greenfield project creation (#4).
147
159
  */
148
- export function isRefinementTask(userPrompt = '', projectFiles = []) {
149
- if (!Array.isArray(projectFiles) || projectFiles.length === 0) return false
160
+ export function isRefinementTask(userPrompt = '', projectFiles = null) {
161
+ if (Array.isArray(projectFiles) && projectFiles.length === 0) return false
150
162
  const p = userPrompt.toLowerCase().trim()
151
163
  if (!p) return false
152
164
 
153
165
  // Greenfield creation phrases - ALWAYS fresh task
154
166
  const freshPhrases = [
155
- 'с нуля', 'from scratch', 'новый проект', 'новое приложение', 'new project', 'new app',
156
- 'создай проект', 'создай приложение', 'создай игру', 'создай сервис',
167
+ 'from scratch', 'new project', 'new app', 'create a new', 'build a new', 'generate a new',
168
+ '从头开始', '新建项目', '创建项目', '新应用', '新建应用',
169
+ 'с нуля', 'новый проект', 'новое приложение', 'создай проект', 'создай приложение', 'создай игру', 'создай сервис',
157
170
  'сделай проект', 'сделай приложение', 'сделай игру',
158
- 'create a new', 'build a new', 'generate a new',
159
171
  ]
160
172
  if (freshPhrases.some((phrase) => p.includes(phrase))) {
161
173
  return false
@@ -163,9 +175,11 @@ export function isRefinementTask(userPrompt = '', projectFiles = []) {
163
175
 
164
176
  // Modification keywords
165
177
  const modKeywords = [
166
- 'добавь', 'измени', 'поменяй', 'исправь', 'обнови', 'переделай', 'доработай', 'удали',
167
178
  'add', 'change', 'update', 'fix', 'modify', 'refactor', 'improve', 'enhance', 'adjust',
168
- 'style', 'css', 'темн', 'светл', 'color', 'цвет', 'кнопк', 'theme', 'bug', 'баг',
179
+ 'style', 'css', 'color', 'theme', 'bug',
180
+ '添加', '修改', '更新', '修复', '重构', '改进', '调整', '样式', '颜色', '主题',
181
+ 'добавь', 'измени', 'поменяй', 'исправь', 'обнови', 'переделай', 'доработай', 'удали',
182
+ 'темн', 'светл', 'цвет', 'кнопк', 'баг',
169
183
  ]
170
184
 
171
185
  return modKeywords.some((kw) => p.includes(kw))
package/lib/history.js CHANGED
@@ -298,3 +298,13 @@ export function exportMoaHistory(filePath = DEFAULT_HISTORY_FILE, options = {})
298
298
  return options?.format === 'csv' ? '' : '[]'
299
299
  }
300
300
  }
301
+
302
+ export function candidatesForHistory(referenceOutputs) {
303
+ return (referenceOutputs || []).map((r) => ({
304
+ provider: r.slot?.provider || "",
305
+ model: r.slot?.model || "",
306
+ files: (r.files || []).map((f) => f.relativePath),
307
+ usage: r.usage || { inputTokens: 0, outputTokens: 0 },
308
+ costUsd: r.costUsd || 0,
309
+ }))
310
+ }
@@ -1,3 +1,25 @@
1
+ import path from "node:path"
2
+
3
+ export async function createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs) {
4
+ if (!liveCanvas || typeof liveCanvas.createPreviewFromContent !== "function") return null
5
+ const htmlRel = (promotedFiles || []).find((f) => f.endsWith(".html") || f.endsWith(".htm"))
6
+ if (!htmlRel) return null
7
+ let content = null
8
+ for (const r of referenceOutputs || []) {
9
+ const block = (r?.files || []).find((f) => f.relativePath === htmlRel)
10
+ if (block?.content) {
11
+ content = block.content
12
+ break
13
+ }
14
+ }
15
+ if (!content) return null
16
+ try {
17
+ return await liveCanvas.createPreviewFromContent({ content, title: htmlRel, filePath: path.join(cwd, htmlRel) })
18
+ } catch {
19
+ return null
20
+ }
21
+ }
22
+
1
23
  /**
2
24
  * Optional Live Canvas preview client (@goodandready/dsh-live-canvas).
3
25
  *
package/lib/moa-parser.js CHANGED
@@ -148,7 +148,7 @@ export function formatMoAResponse({ moaResult, presetName }) {
148
148
  }
149
149
 
150
150
  if (moaResult?.liveCanvas?.previewUrl) {
151
- parts.push(`> 🎨 **Live Canvas**: [🚀 Открыть ${moaResult.liveCanvas.title || 'превью'} в Live Canvas](${moaResult.liveCanvas.previewUrl}) | [↗ Открыть в новой вкладке](${moaResult.liveCanvas.previewUrl})`)
151
+ parts.push(`> 🎨 **Live Canvas**: [🚀 Open ${moaResult.liveCanvas.title || 'preview'} in Live Canvas](${moaResult.liveCanvas.previewUrl}) | [↗ Open in new tab](${moaResult.liveCanvas.previewUrl})`)
152
152
  }
153
153
 
154
154
  // Cost tracking card
@@ -166,7 +166,7 @@ export function formatMoAResponse({ moaResult, presetName }) {
166
166
  parts.push('')
167
167
  const cleanJudgeContent = hasPromoted
168
168
  ? stripOrSummarizeCode(moaResult?.content || '')
169
- : (moaResult?.content || '(нет ответа)')
169
+ : (moaResult?.content || '(no response)')
170
170
  parts.push(cleanJudgeContent)
171
171
  parts.push('')
172
172
  }
@@ -177,7 +177,7 @@ export function formatMoAResponse({ moaResult, presetName }) {
177
177
  parts.push('')
178
178
  refs.forEach((ref, i) => {
179
179
  const statusIcon = ref.ok ? '✅' : '⚠️'
180
- const fileBadge = ref.files?.length ? ` (${ref.files.length} файл(ов))` : ''
180
+ const fileBadge = ref.files?.length ? ` (${ref.files.length} file${ref.files.length > 1 ? 's' : ''})` : ''
181
181
  const costBadge = ref.costUsd > 0 ? ` [~\$${ref.costUsd.toFixed(4)}]` : ''
182
182
  parts.push(`#### ${statusIcon} Model ${i + 1}: ${ref.label}${fileBadge}${costBadge}`)
183
183
  parts.push('')
@@ -128,21 +128,22 @@ export function isBroadPromptRequiringQuestions(userPrompt = '', messages = [])
128
128
  const p = userPrompt.trim()
129
129
  const wordCount = p.split(/\s+/).length
130
130
 
131
- if (/^(да|нет|1|2|3|4|ок|погнали|давай|yes|no)\b/i.test(p) && wordCount <= 5) {
131
+ if (/^(yes|no|1|2|3|4|ok|sure|是|否|好|да|нет|ок|погнали|давай)\b/i.test(p) && wordCount <= 5) {
132
132
  return false
133
133
  }
134
134
 
135
135
  const hasRecentQuestion = messages.some((m) => {
136
136
  const text = typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content || '')
137
- return text.includes('Уточнение требований') || text.includes('опросник') || text.includes('Вариант 1')
137
+ return text.includes('Clarification of Requirements') || text.includes('需求澄清') || text.includes('Уточнение требований') || text.includes('опросник') || text.includes('Option 1') || text.includes('Вариант 1')
138
138
  })
139
139
  if (hasRecentQuestion) {
140
140
  return false
141
141
  }
142
142
 
143
143
  const creationTriggers = [
144
+ 'make', 'build', 'create', 'generate', 'develop', 'design',
145
+ '制作', '创建', '构建', '开发', '设计',
144
146
  'сделай', 'создай', 'напиши', 'разработай', 'придумай', 'реализуй',
145
- 'make', 'build', 'create', 'generate', 'develop',
146
147
  ]
147
148
  const startsWithCreation = creationTriggers.some((t) => p.toLowerCase().startsWith(t))
148
149
 
@@ -150,7 +151,7 @@ export function isBroadPromptRequiringQuestions(userPrompt = '', messages = [])
150
151
  return true
151
152
  }
152
153
 
153
- const vagueNouns = ['приложение', 'игру', 'сервис', 'сайт', 'лендинг', 'калькулятор', 'виджет', 'дашборд', 'app', 'game', 'tool', 'website']
154
+ const vagueNouns = ['app', 'game', 'tool', 'website', 'dashboard', 'widget', 'service', 'landing', 'calculator', '应用', '游戏', '工具', '网站', '仪表盘', '服务', 'приложение', 'игру', 'сервис', 'сайт', 'лендинг', 'калькулятор', 'виджет', 'дашборд']
154
155
  if (vagueNouns.some((n) => p.toLowerCase().includes(n))) {
155
156
  if (wordCount <= 12) return true
156
157
  }
@@ -190,8 +191,11 @@ export function buildPeerCritiquePrompt(userPrompt, myProposal, otherProposals =
190
191
  const othersText = otherProposals
191
192
  .map((p, idx) => {
192
193
  const label = isBlind ? `Candidate ${idx + 1}` : (p.label || `Candidate ${idx + 1}`)
193
- return `### ${label} Alternative Proposal:
194
- ${p.text}`
194
+ let text = p.text || ''
195
+ if (text.length > 3000) {
196
+ text = stripOrSummarizeCode(text)
197
+ }
198
+ return `### ${label} Alternative Proposal:\n${text}`
195
199
  })
196
200
  .join('\n\n---\n\n')
197
201
 
@@ -223,7 +227,7 @@ export function buildCuratorSynthesisPrompt(userPrompt, referenceOutputs = [], j
223
227
  ? ` [Files created: ${r.files.map((f) => f.relativePath).join(', ')}]`
224
228
  : ''
225
229
  let textContent = r.text
226
- if (referenceOutputs.length >= 3 && textContent.length > 3000) {
230
+ if (textContent.length > 3000) {
227
231
  textContent = stripOrSummarizeCode(textContent)
228
232
  }
229
233
  const syntaxNote = r.syntaxWarning ? ` [⚠️ Syntax Warning: ${r.syntaxWarning}]` : ''
@@ -290,7 +294,7 @@ export function buildSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCri
290
294
  ? ` [Files created: ${r.files.map((f) => f.relativePath).join(', ')}]`
291
295
  : ''
292
296
  let textContent = r.text
293
- if (referenceOutputs.length >= 3 && textContent.length > 3000) {
297
+ if (textContent.length > 3000) {
294
298
  textContent = stripOrSummarizeCode(textContent)
295
299
  }
296
300
  const syntaxNote = r.syntaxWarning ? ` [⚠️ Syntax Warning: ${r.syntaxWarning}]` : ''
package/lib/moa-runner.js CHANGED
@@ -1,27 +1,21 @@
1
1
  /**
2
2
  * DeepSeek Harness Mixture of Agents (MoA) — Runner Engine
3
- *
4
- * Implements the full ensemble pipeline:
5
- * - Parallel fan-out to reference models with transient retry & quorum straggler mitigation
6
- * - Prompt caching aligned message structures
7
- * - Curator synthesis & antipatterns evaluation
8
- * - Aggregator fallback chain for resilience & malformed output recovery
9
- * - Live token streaming for aggregator
10
- * - File promotion & Live Canvas sandbox preview
3
+ * Parallel fan-out, Consilium Round 2 peer critique, aggregator synthesis & streaming.
11
4
  */
12
5
 
13
6
  import path from 'node:path'
14
7
  import crypto from 'node:crypto'
15
- import { extractFileBlocks, collectProjectContext, isRefinementTask, writeCandidateWorkspace, promoteCandidateWorkspace, cleanMoaWorkspaces, verifyFileSyntax } from './file-workspace.js'
16
- import { estimateTokenCost } from './pricing.js'
17
- import { recordMoaRun, recordMoaRunAsync } from './history.js'
8
+ import { extractFileBlocks, collectProjectContext, formatProjectContext, isRefinementTask, writeCandidateWorkspace, promoteCandidateWorkspace, cleanMoaWorkspaces, verifyFileSyntax } from './file-workspace.js'
9
+ import { estimateTokenCost, summarizeMoAUsage } from './pricing.js'
10
+ import { recordMoaRun, recordMoaRunAsync, candidatesForHistory } from './history.js'
11
+ import { createPromotedPreview } from './live-canvas.js'
18
12
  import { slotLabel, cleanAdvisoryMessages, isBroadPromptRequiringQuestions, buildQuestionSynthesisPrompt, buildCuratorSynthesisPrompt, buildSynthesisPrompt, buildPeerCritiquePrompt, ROLE_PERSONA_PROMPTS, SYSTEM_ROLE_PROPOSER, ANTIPATTERNS_RUBRIC } from './moa-prompts.js'
19
13
  import { parseWinnerIndex, parseRecommendedAssembler, parseMoACommand, stripOrSummarizeCode, formatMoAResponse } from './moa-parser.js'
20
14
 
21
15
  // Re-exports for consumers & backward compatibility
22
16
  export { slotLabel, cleanAdvisoryMessages, isBroadPromptRequiringQuestions, buildQuestionSynthesisPrompt, buildCuratorSynthesisPrompt, buildSynthesisPrompt, ANTIPATTERNS_RUBRIC } from './moa-prompts.js'
23
17
  export { parseWinnerIndex, parseRecommendedAssembler, parseMoACommand, stripOrSummarizeCode, formatMoAResponse } from './moa-parser.js'
24
- export { estimateTokenCost } from './pricing.js'
18
+ export { estimateTokenCost, summarizeMoAUsage } from './pricing.js'
25
19
 
26
20
  export const DEFAULT_PROMPT = 'Please propose an optimal, well-structured, production-ready solution with full code and explanations.'
27
21
  export const REFERENCE_SYSTEM_PROMPT = SYSTEM_ROLE_PROPOSER
@@ -32,14 +26,16 @@ export const REFERENCE_SYSTEM_PROMPT = SYSTEM_ROLE_PROPOSER
32
26
  export async function callWithTransientRetry(callLlmFn, callArgs, maxRetries = 0, retryDelayMs = 1200) {
33
27
  let attempt = 0
34
28
  while (true) {
29
+ if (callArgs?.signal?.aborted) throw new Error('Aborted')
35
30
  try {
36
31
  return await callLlmFn(callArgs)
37
32
  } catch (err) {
38
33
  attempt++
39
34
  const msg = err?.message || String(err)
40
35
  const isTransient = /429|rate limit|502|503|504|econnreset|etimedout|socket hang up/i.test(msg)
41
- if (attempt <= maxRetries && isTransient) {
36
+ if (attempt <= maxRetries && isTransient && !callArgs?.signal?.aborted) {
42
37
  await new Promise((r) => setTimeout(r, retryDelayMs))
38
+ if (callArgs?.signal?.aborted) throw new Error('Aborted')
43
39
  continue
44
40
  }
45
41
  throw err
@@ -66,6 +62,28 @@ export async function runReferencesParallel(references, messages, options = {},
66
62
  let finishedCount = 0
67
63
  const results = new Array(total)
68
64
  const abortControllers = references.map(() => new AbortController())
65
+ if (options.signal) {
66
+ if (options.signal.aborted) {
67
+ return references.map((r, i) => ({
68
+ index: i + 1,
69
+ provider: r.provider,
70
+ model: r.model,
71
+ label: slotLabel(r),
72
+ ok: false,
73
+ text: '(aborted)',
74
+ error: 'Turn aborted',
75
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
76
+ costUsd: 0,
77
+ }))
78
+ }
79
+ if (typeof options.signal.addEventListener === 'function') {
80
+ options.signal.addEventListener('abort', () => {
81
+ for (const ac of abortControllers) {
82
+ try { ac.abort(new Error('Turn aborted')) } catch {}
83
+ }
84
+ }, { once: true })
85
+ }
86
+ }
69
87
 
70
88
  let onTaskFinished = null
71
89
  const notifyFinished = () => {
@@ -221,35 +239,7 @@ export async function runReferencesParallel(references, messages, options = {},
221
239
  return results
222
240
  }
223
241
 
224
- function candidatesForHistory(referenceOutputs) {
225
- return (referenceOutputs || []).map((r) => ({
226
- provider: r.slot?.provider || '',
227
- model: r.slot?.model || '',
228
- files: (r.files || []).map((f) => f.relativePath),
229
- usage: r.usage || { inputTokens: 0, outputTokens: 0 },
230
- costUsd: r.costUsd || 0,
231
- }))
232
- }
233
242
 
234
- async function createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs) {
235
- if (!liveCanvas || typeof liveCanvas.createPreviewFromContent !== 'function') return null
236
- const htmlRel = (promotedFiles || []).find((f) => f.endsWith('.html') || f.endsWith('.htm'))
237
- if (!htmlRel) return null
238
- let content = null
239
- for (const r of referenceOutputs || []) {
240
- const block = (r?.files || []).find((f) => f.relativePath === htmlRel)
241
- if (block?.content) {
242
- content = block.content
243
- break
244
- }
245
- }
246
- if (!content) return null
247
- try {
248
- return await liveCanvas.createPreviewFromContent({ content, title: htmlRel, filePath: path.join(cwd, htmlRel) })
249
- } catch {
250
- return null
251
- }
252
- }
253
243
 
254
244
  /**
255
245
  * Executes the full Mixture of Agents pipeline.
@@ -265,7 +255,9 @@ export async function runMoAPipeline({
265
255
  historyFilePath = null,
266
256
  liveCanvas = null,
267
257
  onStreamDelta = null,
258
+ signal = null,
268
259
  }) {
260
+ if (signal?.aborted) return { error: new Error('Turn aborted') }
269
261
  const startTime = Date.now()
270
262
 
271
263
  // 1. Resolve configurations
@@ -298,13 +290,14 @@ export async function runMoAPipeline({
298
290
  const runId = crypto.randomUUID()
299
291
 
300
292
  // 2. Collect project context for refinement tasks
301
- const isRefinement = isRefinementTask(userPrompt)
293
+ const collectedCtx = await collectProjectContext(cwd, 16000)
294
+ const isRefinement = Boolean(collectedCtx?.files?.length > 0 && isRefinementTask(userPrompt, collectedCtx.files))
302
295
  let projectContext = ''
303
296
  if (isRefinement) {
304
297
  if (typeof onProgress === 'function') {
305
298
  onProgress('🔍 *Reading project files for refinement context...*\n')
306
299
  }
307
- projectContext = await collectProjectContext(cwd, 16000)
300
+ projectContext = formatProjectContext(collectedCtx.files)
308
301
  }
309
302
 
310
303
  // 3. Build prompts & evaluate broad questionnaire needs
@@ -330,11 +323,17 @@ export async function runMoAPipeline({
330
323
  quorumEnabled: isQuorumEnabled,
331
324
  gracePeriodSec,
332
325
  maxRetries: candidateRetries,
326
+ signal,
333
327
  },
334
328
  callLlm,
335
329
  onProgress
336
330
  )
337
331
 
332
+ if (signal?.aborted) {
333
+ await cleanMoaWorkspaces(cwd)
334
+ return { error: new Error('Turn aborted') }
335
+ }
336
+
338
337
  // Fail fast if all candidates failed
339
338
  const successfulRefs = referenceOutputs.filter((r) => r.ok)
340
339
  if (successfulRefs.length === 0) {
@@ -396,11 +395,11 @@ export async function runMoAPipeline({
396
395
  qUsage = estimateTokenCost(primaryJudge, qFallbackUsage, prices)
397
396
  } catch (err) {
398
397
  console.warn('[dsh-moa] Questionnaire synthesis failed, proceeding with fallback questions:', err)
399
- questionsContent = `### Уточнение требований по задаче: "${userPrompt}"\n\n` +
400
- '1. **Формат решения**: однофайловый HTML/JS или многомодульный проект?\n' +
401
- '2. **Стиль и визуальное оформление**: минимализм, темная тема или нейтральный интерфейс?\n' +
402
- '3. **Функциональные приоритеты**: базовый MVP или расширенная реализация?\n\n' +
403
- '*Ответьте кратко (например, "1, 2") или доверьтесь выбору по умолчанию.*'
398
+ questionsContent = `### Clarification of Requirements: "${userPrompt}"\n\n` +
399
+ '1. **Architecture & Scope**: Single-file deliverable or multi-module project structure?\n' +
400
+ '2. **Design & Style**: Minimalist, dark mode, or clean neutral theme?\n' +
401
+ '3. **Functional Priorities**: Core MVP or comprehensive extended implementation?\n\n' +
402
+ '*Reply with your preferences (e.g. "1, 2") or proceed with defaults.*'
404
403
  }
405
404
 
406
405
  await cleanMoaWorkspaces(cwd)
@@ -518,6 +517,7 @@ export async function runMoAPipeline({
518
517
  temperature: refTemp,
519
518
  maxTokens,
520
519
  timeoutMs: refTimeoutSec * 1000,
520
+ signal,
521
521
  }, candidateRetries)
522
522
  const refinedText = typeof res === 'string' ? res : (res?.content || res?.text || cand.text)
523
523
  cand.text = refinedText
@@ -525,6 +525,7 @@ export async function runMoAPipeline({
525
525
  if (r2Files.length > 0) {
526
526
  cand.files = r2Files
527
527
  cand.syntaxWarning = (verifyFileSyntax(r2Files) || []).map((w) => `${w.file}: ${w.error}`).join('; ')
528
+ await writeCandidateWorkspace(cwd, cand.index, r2Files)
528
529
  }
529
530
  if (typeof res === 'object' && res?.usage) {
530
531
  cand.usage.inputTokens = (cand.usage.inputTokens || 0) + (res.usage.inputTokens || 0)
@@ -567,6 +568,7 @@ export async function runMoAPipeline({
567
568
  temperature: aggTemp,
568
569
  maxTokens,
569
570
  timeoutMs: aggTimeoutSec * 1000,
571
+ signal,
570
572
  onStreamDelta: (delta) => {
571
573
  if (isStreamAggregator && typeof onStreamDelta === 'function') {
572
574
  onStreamDelta(delta)
@@ -626,15 +628,12 @@ export async function runMoAPipeline({
626
628
 
627
629
  const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
628
630
 
629
- const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + aggUsage.totalTokens
630
- const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + aggUsage.costUsd).toFixed(5))
631
631
  const durationMs = Date.now() - startTime
632
-
632
+ const usageSummary = summarizeMoAUsage(referenceOutputs, aggUsage)
633
633
  const winningRef = referenceOutputs[winningIndex - 1]
634
634
  const winnerModel = winningRef?.label || slotLabel(referenceModels[0])
635
635
  const finalAggLabel = slotLabel(chosenJudge)
636
636
 
637
- // Record run to history
638
637
  try {
639
638
  recordMoaRun({
640
639
  id: runId,
@@ -646,8 +645,8 @@ export async function runMoAPipeline({
646
645
  winnerIndex: winningIndex,
647
646
  winnerModel,
648
647
  promotedFiles,
649
- totalTokens,
650
- totalCostUsd,
648
+ totalTokens: usageSummary.totalTokens,
649
+ totalCostUsd: usageSummary.totalCostUsd,
651
650
  durationMs,
652
651
  }, historyFilePath || undefined)
653
652
  } catch (histErr) {
@@ -670,12 +669,7 @@ export async function runMoAPipeline({
670
669
  runId,
671
670
  allowCandidateOverride,
672
671
  ...(livePreview ? { liveCanvas: livePreview } : {}),
673
- usage: {
674
- totalTokens,
675
- totalCostUsd,
676
- candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
677
- aggregator: aggUsage,
678
- },
672
+ usage: usageSummary,
679
673
  durationMs,
680
674
  }
681
675
  }
@@ -716,6 +710,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
716
710
  prices,
717
711
  historyFilePath,
718
712
  liveCanvas,
713
+ signal,
719
714
  })
720
715
  .catch((err) => ({ error: err }))
721
716
  .finally(() => {
package/lib/pricing.js CHANGED
@@ -205,4 +205,13 @@ export function estimateTokenCost(slot = {}, usage = {}, customPrices = {}, cach
205
205
  rates,
206
206
  }
207
207
  }
208
-
208
+
209
+
210
+
211
+ export function summarizeMoAUsage(referenceOutputs = [], aggUsage = null) {
212
+ const normAgg = aggUsage || { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
213
+ const totalTokens = (referenceOutputs || []).reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + normAgg.totalTokens
214
+ const totalCostUsd = Number(((referenceOutputs || []).reduce((acc, r) => acc + (r.costUsd || 0), 0) + normAgg.costUsd).toFixed(5))
215
+ const candidates = (referenceOutputs || []).map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd }))
216
+ return { totalTokens, totalCostUsd, candidates, aggregator: normAgg }
217
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
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",