@goodandready/dsh-moa 0.2.14 → 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.
@@ -71,3 +71,9 @@
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
72
 
73
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.
@@ -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,8 +157,8 @@ 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
 
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
  *
@@ -191,8 +191,11 @@ export function buildPeerCritiquePrompt(userPrompt, myProposal, otherProposals =
191
191
  const othersText = otherProposals
192
192
  .map((p, idx) => {
193
193
  const label = isBlind ? `Candidate ${idx + 1}` : (p.label || `Candidate ${idx + 1}`)
194
- return `### ${label} Alternative Proposal:
195
- ${p.text}`
194
+ let text = p.text || ''
195
+ if (text.length > 3000) {
196
+ text = stripOrSummarizeCode(text)
197
+ }
198
+ return `### ${label} Alternative Proposal:\n${text}`
196
199
  })
197
200
  .join('\n\n---\n\n')
198
201
 
@@ -224,7 +227,7 @@ export function buildCuratorSynthesisPrompt(userPrompt, referenceOutputs = [], j
224
227
  ? ` [Files created: ${r.files.map((f) => f.relativePath).join(', ')}]`
225
228
  : ''
226
229
  let textContent = r.text
227
- if (referenceOutputs.length >= 3 && textContent.length > 3000) {
230
+ if (textContent.length > 3000) {
228
231
  textContent = stripOrSummarizeCode(textContent)
229
232
  }
230
233
  const syntaxNote = r.syntaxWarning ? ` [⚠️ Syntax Warning: ${r.syntaxWarning}]` : ''
@@ -291,7 +294,7 @@ export function buildSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCri
291
294
  ? ` [Files created: ${r.files.map((f) => f.relativePath).join(', ')}]`
292
295
  : ''
293
296
  let textContent = r.text
294
- if (referenceOutputs.length >= 3 && textContent.length > 3000) {
297
+ if (textContent.length > 3000) {
295
298
  textContent = stripOrSummarizeCode(textContent)
296
299
  }
297
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) {
@@ -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.14",
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",