@goodandready/dsh-moa 0.2.71 → 0.2.73

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.
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Cross-Candidate Unchanged Base Context Pruning (#305)
3
+ *
4
+ * Replaces large, mostly-unchanged candidate file blocks with compact unified diffs
5
+ * containing only modified hunks and a configurable number of context lines.
6
+ * This drastically cuts Judge prompt token overhead (often by 70-85%) on large files
7
+ * while preserving full context for winner promotion.
8
+ */
9
+
10
+ import { computeLineDiff } from '../workspace/diff.js'
11
+ import { isSafeRelativePath } from '../workspace/security.js'
12
+
13
+ /**
14
+ * Extracts code blocks along with their raw Markdown text from candidate output.
15
+ *
16
+ * @param {string} text
17
+ * @returns {Array<{ relativePath: string, content: string, rawBlock: string }>}
18
+ */
19
+ export function extractCandidateFileBlocks(text) {
20
+ if (!text || typeof text !== 'string') return []
21
+ const blocks = []
22
+ const seenPaths = new Set()
23
+
24
+ const patterns = [
25
+ /```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi,
26
+ /(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
27
+ /#{1,4}\s+(?:File|Path):\s*([^\s\n\r]+)[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
28
+ /```[\w-]*[\r\n]+(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+([\s\S]*?)```/gi,
29
+ ]
30
+
31
+ for (const regex of patterns) {
32
+ let match
33
+ while ((match = regex.exec(text)) !== null) {
34
+ const rawPath = match[1].trim()
35
+ if (rawPath && isSafeRelativePath(rawPath) && !seenPaths.has(rawPath)) {
36
+ seenPaths.add(rawPath)
37
+ blocks.push({
38
+ relativePath: rawPath,
39
+ content: match[2].trimEnd() + '\n',
40
+ rawBlock: match[0],
41
+ })
42
+ }
43
+ }
44
+ }
45
+
46
+ return blocks
47
+ }
48
+
49
+
50
+ /**
51
+ * Generates unified diff hunks from line diff items with a specified context window.
52
+ *
53
+ * @param {string} oldText - Original baseline file content
54
+ * @param {string} newText - Candidate's proposed file content
55
+ * @param {object} [options]
56
+ * @param {number} [options.contextLines=5] - Unchanged lines before and after changes
57
+ * @param {string} [options.filePath='file'] - Relative file path for header
58
+ * @returns {string} Standard unified diff representation
59
+ */
60
+ export function generateUnifiedDiff(oldText = '', newText = '', options = {}) {
61
+ const { contextLines = 5, filePath = 'file' } = options
62
+ const trimmedOld = String(oldText || '').replace(/[\r\n]+$/, '')
63
+ const trimmedNew = String(newText || '').replace(/[\r\n]+$/, '')
64
+ if (trimmedOld === trimmedNew) return ''
65
+
66
+ const diffItems = computeLineDiff(trimmedOld, trimmedNew)
67
+ if (!diffItems || diffItems.length === 0) return ''
68
+
69
+ // Group diff items into hunks with contextLines
70
+ const changeIndices = []
71
+ for (let i = 0; i < diffItems.length; i++) {
72
+ if (diffItems[i].type !== 'same') {
73
+ changeIndices.push(i)
74
+ }
75
+ }
76
+
77
+ if (changeIndices.length === 0) return ''
78
+
79
+ // Cluster change ranges that are closer than 2 * contextLines
80
+ const clusters = []
81
+ let currentCluster = {
82
+ start: Math.max(0, changeIndices[0] - contextLines),
83
+ end: Math.min(diffItems.length - 1, changeIndices[0] + contextLines),
84
+ }
85
+
86
+ for (let k = 1; k < changeIndices.length; k++) {
87
+ const idx = changeIndices[k]
88
+ const nextStart = Math.max(0, idx - contextLines)
89
+ const nextEnd = Math.min(diffItems.length - 1, idx + contextLines)
90
+
91
+ if (nextStart <= currentCluster.end + 1) {
92
+ currentCluster.end = Math.max(currentCluster.end, nextEnd)
93
+ } else {
94
+ clusters.push(currentCluster)
95
+ currentCluster = { start: nextStart, end: nextEnd }
96
+ }
97
+ }
98
+ clusters.push(currentCluster)
99
+
100
+ const hunkOutputs = []
101
+ for (const cluster of clusters) {
102
+ const slice = diffItems.slice(cluster.start, cluster.end + 1)
103
+ let oldStart = null
104
+ let newStart = null
105
+ let oldCount = 0
106
+ let newCount = 0
107
+ const hunkLines = []
108
+
109
+ for (const item of slice) {
110
+ if (item.type === 'same') {
111
+ if (oldStart === null && item.oldNum !== undefined) oldStart = item.oldNum
112
+ if (newStart === null && item.newNum !== undefined) newStart = item.newNum
113
+ oldCount++
114
+ newCount++
115
+ hunkLines.push(` ${item.line}`)
116
+ } else if (item.type === 'removed') {
117
+ if (oldStart === null && item.oldNum !== undefined) oldStart = item.oldNum
118
+ oldCount++
119
+ hunkLines.push(`-${item.line}`)
120
+ } else if (item.type === 'added') {
121
+ if (newStart === null && item.newNum !== undefined) newStart = item.newNum
122
+ newCount++
123
+ hunkLines.push(`+${item.line}`)
124
+ }
125
+ }
126
+
127
+ const header = `@@ -${oldStart || 1},${oldCount} +${newStart || 1},${newCount} @@`
128
+ hunkOutputs.push([header, ...hunkLines].join('\n'))
129
+ }
130
+
131
+ const prefix = `--- a/${filePath}\n+++ b/${filePath}`
132
+ return `${prefix}\n${hunkOutputs.join('\n')}`
133
+ }
134
+
135
+ /**
136
+ * Checks whether a candidate's file output qualifies for context pruning.
137
+ *
138
+ * @param {string} originalText - Original file content
139
+ * @param {string} candidateText - Proposed file content
140
+ * @param {object} [options]
141
+ * @param {number} [options.minLines=25] - Minimum file length to consider pruning
142
+ * @param {number} [options.maxChangeRatio=0.65] - Maximum proportion of changed lines
143
+ * @returns {boolean}
144
+ */
145
+ export function shouldPruneFile(originalText, candidateText, options = {}) {
146
+ const { minLines = 25, maxChangeRatio = 0.65 } = options
147
+ if (typeof originalText !== 'string' || typeof candidateText !== 'string') return false
148
+ const trimmedOld = originalText.replace(/[\r\n]+$/, '')
149
+ const trimmedNew = candidateText.replace(/[\r\n]+$/, '')
150
+ if (trimmedOld === trimmedNew) return false
151
+
152
+ const oldLines = trimmedOld.split(/\r?\n/)
153
+ if (oldLines.length < minLines) return false
154
+
155
+ const diffItems = computeLineDiff(trimmedOld, trimmedNew)
156
+ let changed = 0
157
+ for (const item of diffItems) {
158
+ if (item.type !== 'same') changed++
159
+ }
160
+
161
+ const ratio = changed / Math.max(1, diffItems.length)
162
+ return ratio > 0 && ratio <= maxChangeRatio
163
+ }
164
+
165
+ /**
166
+ * Prunes unmodified base context from a single candidate's Markdown output.
167
+ *
168
+ * @param {string} candidateText - Raw candidate text containing file blocks
169
+ * @param {Map<string, string>|Record<string, string>} baseFiles - Map of relative paths to baseline file contents
170
+ * @param {object} [options]
171
+ * @param {number} [options.contextLines=5]
172
+ * @param {number} [options.minLines=25]
173
+ * @returns {{ text: string, prunedFiles: string[], savedChars: number, originalChars: number }}
174
+ */
175
+ export function pruneCandidateOutput(candidateText = '', baseFiles = {}, options = {}) {
176
+ if (typeof candidateText !== 'string' || !candidateText.trim()) {
177
+ return { text: candidateText, prunedFiles: [], savedChars: 0, originalChars: 0 }
178
+ }
179
+
180
+ const { contextLines = 5, minLines = 25 } = options
181
+ const baseMap = baseFiles instanceof Map
182
+ ? baseFiles
183
+ : new Map(Object.entries(baseFiles || {}))
184
+
185
+ if (baseMap.size === 0) {
186
+ return { text: candidateText, prunedFiles: [], savedChars: 0, originalChars: 0 }
187
+ }
188
+
189
+ const fileBlocks = extractCandidateFileBlocks(candidateText)
190
+ if (!Array.isArray(fileBlocks) || fileBlocks.length === 0) {
191
+ return { text: candidateText, prunedFiles: [], savedChars: 0, originalChars: 0 }
192
+ }
193
+
194
+ let prunedText = candidateText
195
+ const prunedFiles = []
196
+ let savedChars = 0
197
+
198
+ for (const block of fileBlocks) {
199
+ const relPath = block.relativePath || block.path
200
+ if (!relPath || !baseMap.has(relPath)) continue
201
+
202
+ const baseContent = baseMap.get(relPath)
203
+ if (!shouldPruneFile(baseContent, block.content, { minLines })) continue
204
+
205
+ const unifiedDiff = generateUnifiedDiff(baseContent, block.content, {
206
+ contextLines,
207
+ filePath: relPath,
208
+ })
209
+
210
+ if (!unifiedDiff) continue
211
+
212
+ const originalBlockText = block.rawBlock || block.block || `\`\`\`${block.language || ''}\n${block.content}\n\`\`\``
213
+ const replacementBlock = `\`\`\`diff\n// Unified diff patch for ${relPath} (context pruned)\n${unifiedDiff}\n\`\`\``
214
+
215
+ if (replacementBlock.length < originalBlockText.length) {
216
+ prunedText = prunedText.replace(originalBlockText, replacementBlock)
217
+ savedChars += (originalBlockText.length - replacementBlock.length)
218
+ prunedFiles.push(relPath)
219
+ }
220
+ }
221
+
222
+ return {
223
+ text: prunedText,
224
+ prunedFiles,
225
+ savedChars,
226
+ originalChars: candidateText.length,
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Prunes unmodified context across all successful candidate outputs.
232
+ *
233
+ * @param {Array<object>} candidates - List of candidate response objects
234
+ * @param {Array<object>|Map<string, string>|Record<string, string>} projectFiles - Project context files
235
+ * @param {object} [options]
236
+ * @param {boolean} [options.enabled=true]
237
+ * @param {number} [options.contextLines=5]
238
+ * @returns {{ candidates: Array<object>, totalSavedChars: number, totalSavedTokens: number, prunedFiles: string[], isPruned: boolean }}
239
+ */
240
+ export function pruneCandidateEnsemble(candidates = [], projectFiles = [], options = {}) {
241
+ const { enabled = true, contextLines = 5, minLines = 25 } = options
242
+ if (!enabled || !Array.isArray(candidates) || candidates.length === 0) {
243
+ return {
244
+ candidates,
245
+ totalSavedChars: 0,
246
+ totalSavedTokens: 0,
247
+ prunedFiles: [],
248
+ isPruned: false,
249
+ }
250
+ }
251
+
252
+ // Build base files map
253
+ const baseMap = new Map()
254
+ if (Array.isArray(projectFiles)) {
255
+ for (const f of projectFiles) {
256
+ if (f && f.path && typeof f.content === 'string') {
257
+ baseMap.set(f.path, f.content)
258
+ } else if (f && f.relativePath && typeof f.content === 'string') {
259
+ baseMap.set(f.relativePath, f.content)
260
+ }
261
+ }
262
+ } else if (projectFiles instanceof Map) {
263
+ for (const [k, v] of projectFiles.entries()) baseMap.set(k, v)
264
+ } else if (projectFiles && typeof projectFiles === 'object') {
265
+ for (const [k, v] of Object.entries(projectFiles)) baseMap.set(k, v)
266
+ }
267
+
268
+ if (baseMap.size === 0) {
269
+ return {
270
+ candidates,
271
+ totalSavedChars: 0,
272
+ totalSavedTokens: 0,
273
+ prunedFiles: [],
274
+ isPruned: false,
275
+ }
276
+ }
277
+
278
+ let totalSavedChars = 0
279
+ const allPrunedFiles = new Set()
280
+
281
+ const prunedCandidates = candidates.map((cand) => {
282
+ if (!cand || typeof cand.text !== 'string') return cand
283
+ const result = pruneCandidateOutput(cand.text, baseMap, { contextLines, minLines })
284
+ if (result.savedChars > 0) {
285
+ totalSavedChars += result.savedChars
286
+ result.prunedFiles.forEach((f) => allPrunedFiles.add(f))
287
+ return {
288
+ ...cand,
289
+ text: result.text,
290
+ prunedStats: {
291
+ savedChars: result.savedChars,
292
+ prunedFiles: result.prunedFiles,
293
+ },
294
+ }
295
+ }
296
+ return cand
297
+ })
298
+
299
+ const totalSavedTokens = Math.round(totalSavedChars / 4)
300
+ return {
301
+ candidates: prunedCandidates,
302
+ totalSavedChars,
303
+ totalSavedTokens,
304
+ prunedFiles: Array.from(allPrunedFiles),
305
+ isPruned: totalSavedChars > 0,
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Emits a structured instruction directive for the Judge when context pruning was applied.
311
+ *
312
+ * @param {Array<string>} [prunedFiles=[]] - List of pruned file paths
313
+ * @param {number} [savedTokens=0] - Estimated token count saved
314
+ * @returns {string}
315
+ */
316
+ export function formatPruningDirective(prunedFiles = [], savedTokens = 0) {
317
+ if (!Array.isArray(prunedFiles) || prunedFiles.length === 0) return ''
318
+ const fileList = prunedFiles.map((f) => `\`${f}\``).join(', ')
319
+ return `### Context Pruning Notice
320
+ To minimize token bloat and accelerate evaluation, large baseline files with localized modifications (${fileList}) have been pruned into standard unified diff patches (---/+++ with context hunks).
321
+ Evaluate the precision, correctness, and edge-case safety of the added and removed lines without penalizing candidates for using diff format.`
322
+ }
323
+
@@ -38,6 +38,13 @@ export async function stageProjectContext({
38
38
  const isBlindEvaluation = Boolean(preset?.blind_evaluation)
39
39
  const isPeerCritiqueEnabled = Boolean(preset?.peer_critique_enabled)
40
40
  const allowCandidateOverride = Boolean(preset?.allow_candidate_override)
41
+ const localPreflightCheck = preset?.local_preflight_check !== false
42
+ const earlyExitConsensusEnabled = Boolean(preset?.early_exit_consensus_enabled)
43
+ const earlyExitThreshold = typeof preset?.early_exit_threshold === 'number' ? preset.early_exit_threshold : 0.95
44
+ const contextPruningEnabled = preset?.context_pruning_enabled !== false
45
+ const contextPruningDiffLines = typeof preset?.context_pruning_diff_lines === 'number' ? preset.context_pruning_diff_lines : 5
46
+ const semanticCacheEnabled = Boolean(preset?.semantic_cache_enabled)
47
+ const semanticCacheTtlHours = typeof preset?.semantic_cache_ttl_hours === 'number' ? preset.semantic_cache_ttl_hours : 168
41
48
 
42
49
  const collectedCtx = await collectProjectContext(cwd, 16000)
43
50
  const isRefinement = Boolean(collectedCtx?.files?.length > 0 && isRefinementTask(userPrompt, collectedCtx.files))
@@ -123,5 +130,9 @@ export async function stageProjectContext({
123
130
  allowCandidateOverride, collectedCtx, isRefinement, projectContext,
124
131
  effectiveReferences, quotaSubstitutions, priorTurnBaseline, candidateSystemPrompt,
125
132
  needsQuestions, enrichedMessages,
133
+ localPreflightCheck, earlyExitConsensusEnabled, earlyExitThreshold,
134
+ contextPruningEnabled, contextPruningDiffLines,
135
+ semanticCacheEnabled, semanticCacheTtlHours,
126
136
  }
127
137
  }
138
+
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Early Exit upon Semantic Consensus (#299)
3
+ * Detects convergence between reference candidates and bypasses judge round when consensus >= threshold.
4
+ * Saves up to 60-80% tokens and latency on requests where candidates converge.
5
+ */
6
+
7
+ import { logger } from '../logger.js'
8
+ import { promoteCandidateWorkspace, cleanMoaWorkspaces } from '../workspace/fs.js'
9
+ import { createPromotedPreview } from '../live-canvas.js'
10
+ import { recordMoaRunAsync, candidatesForHistory } from '../history.js'
11
+ import { emitEvent, MOA_EVENTS } from '../events.js'
12
+ import { estimateTokenCost } from '../pricing.js'
13
+
14
+ /**
15
+ * Normalizes text/code by stripping comments and normalizing whitespace for consensus comparison.
16
+ * @param {string} text
17
+ * @returns {string}
18
+ */
19
+ export function normalizeForConsensus(text = '') {
20
+ if (typeof text !== 'string') return ''
21
+ return text
22
+ // Strip block comments /* ... */
23
+ .replace(/\/\*[\s\S]*?\*\//g, '')
24
+ // Strip JS/TS single line comments // ...
25
+ .replace(/\/\/.*$/gm, '')
26
+ // Strip Python/Shell comments # ...
27
+ .replace(/(^|\s)#.*$/gm, '$1')
28
+ // Normalize newlines
29
+ .replace(/\r?\n/g, '\n')
30
+ // Collapse horizontal whitespace
31
+ .replace(/[ \t]+/g, ' ')
32
+ // Collapse multi-blank lines
33
+ .replace(/\n\s*\n/g, '\n')
34
+ .trim()
35
+ }
36
+
37
+ /**
38
+ * Calculates Sørensen-Dice similarity coefficient between two code/text snippets (0.0 to 1.0).
39
+ * @param {string} textA
40
+ * @param {string} textB
41
+ * @returns {number}
42
+ */
43
+ export function calculateSimilarity(textA = '', textB = '') {
44
+ const normA = normalizeForConsensus(textA)
45
+ const normB = normalizeForConsensus(textB)
46
+
47
+ if (normA === normB) return 1.0
48
+ if (!normA || !normB) return 0.0
49
+
50
+ const tokensA = normA.match(/[\w$]+|[^\s\w]/g) || []
51
+ const tokensB = normB.match(/[\w$]+|[^\s\w]/g) || []
52
+
53
+ if (tokensA.length === 0 && tokensB.length === 0) return 1.0
54
+ if (tokensA.length === 0 || tokensB.length === 0) return 0.0
55
+
56
+ const freqA = new Map()
57
+ for (const t of tokensA) freqA.set(t, (freqA.get(t) || 0) + 1)
58
+
59
+ const freqB = new Map()
60
+ for (const t of tokensB) freqB.set(t, (freqB.get(t) || 0) + 1)
61
+
62
+ let intersection = 0
63
+ for (const [token, countA] of freqA.entries()) {
64
+ const countB = freqB.get(token) || 0
65
+ intersection += Math.min(countA, countB)
66
+ }
67
+
68
+ const dice = (2 * intersection) / (tokensA.length + tokensB.length)
69
+ return Number(dice.toFixed(4))
70
+ }
71
+
72
+ /**
73
+ * Checks if candidates have reached semantic consensus above threshold.
74
+ * @param {Array<object>} candidates
75
+ * @param {number} [threshold=0.95]
76
+ * @returns {{ reached: boolean, similarity: number, winnerIndex: number, consensusIndices: number[], winnerCandidate: object | null, reason: string }}
77
+ */
78
+ export function checkSemanticConsensus(candidates = [], threshold = 0.95) {
79
+ if (!Array.isArray(candidates) || candidates.length < 2) {
80
+ return {
81
+ reached: false,
82
+ similarity: 0,
83
+ winnerIndex: 0,
84
+ consensusIndices: [],
85
+ winnerCandidate: null,
86
+ reason: 'Insufficient candidates for consensus check',
87
+ }
88
+ }
89
+
90
+ const n = candidates.length
91
+ let maxSim = 0
92
+ let bestPair = [0, 1]
93
+ const matrix = Array.from({ length: n }, () => new Float32Array(n))
94
+
95
+ for (let i = 0; i < n; i++) {
96
+ matrix[i][i] = 1.0
97
+ for (let j = i + 1; j < n; j++) {
98
+ const sim = calculateSimilarity(candidates[i].text || '', candidates[j].text || '')
99
+ matrix[i][j] = sim
100
+ matrix[j][i] = sim
101
+ if (sim > maxSim) {
102
+ maxSim = sim
103
+ bestPair = [i, j]
104
+ }
105
+ }
106
+ }
107
+
108
+ if (maxSim < threshold) {
109
+ return {
110
+ reached: false,
111
+ similarity: maxSim,
112
+ winnerIndex: 0,
113
+ consensusIndices: [],
114
+ winnerCandidate: null,
115
+ reason: `Max pairwise similarity ${maxSim} is below consensus threshold ${threshold}`,
116
+ }
117
+ }
118
+
119
+ // Find consensus cluster matching threshold with the best pair
120
+ const clusterSet = new Set(bestPair)
121
+ for (let k = 0; k < n; k++) {
122
+ if (k === bestPair[0] || k === bestPair[1]) continue
123
+ if (matrix[k][bestPair[0]] >= threshold && matrix[k][bestPair[1]] >= threshold) {
124
+ clusterSet.add(k)
125
+ }
126
+ }
127
+
128
+ const consensusIndices = Array.from(clusterSet).map((idx) => candidates[idx].index ?? idx)
129
+ const clusterCandidates = Array.from(clusterSet).map((idx) => candidates[idx])
130
+
131
+ // Select winner from consensus cluster (prioritize valid syntax, then lowest error count)
132
+ let winner = clusterCandidates[0]
133
+ for (const c of clusterCandidates) {
134
+ if (c.syntaxValidation?.valid && !winner.syntaxValidation?.valid) {
135
+ winner = c
136
+ }
137
+ }
138
+
139
+ return {
140
+ reached: true,
141
+ similarity: maxSim,
142
+ winnerIndex: winner.index ?? 1,
143
+ consensusIndices,
144
+ winnerCandidate: winner,
145
+ reason: `Semantic consensus reached (${maxSim} >= ${threshold}) across candidates ${consensusIndices.join(', ')}`,
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Estimates saved tokens and cost from bypassing the judge round.
151
+ * @param {Array<object>} candidates
152
+ * @param {string} prompt
153
+ * @param {number} [maxTokens=4096]
154
+ * @param {object} [prices={}]
155
+ * @returns {{ skippedTokens: number, skippedCostUsd: number }}
156
+ */
157
+ export function estimateSkippedJudgeUsage(candidates = [], prompt = '', maxTokens = 4096, prices = {}) {
158
+ const promptChars = (prompt?.length || 0) + candidates.reduce((sum, c) => sum + (c.text?.length || 0), 0)
159
+ const estimatedInputTokens = Math.max(100, Math.round(promptChars / 3.8))
160
+ const estimatedOutputTokens = Math.min(Math.max(200, Math.round(maxTokens * 0.25)), 1000)
161
+ const skippedTokens = estimatedInputTokens + estimatedOutputTokens
162
+
163
+ const costEst = estimateTokenCost({ provider: 'default', model: 'judge' }, {
164
+ inputTokens: estimatedInputTokens,
165
+ outputTokens: estimatedOutputTokens,
166
+ }, prices)
167
+
168
+ return {
169
+ skippedTokens,
170
+ skippedCostUsd: Number((costEst?.costUsd || 0.005).toFixed(5)),
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Evaluates candidates for early consensus and executes early exit bypass if reached.
176
+ * @param {object} params
177
+ * @returns {Promise<{ ok: boolean, consensusReached: boolean, payload?: object, error?: Error, abort?: boolean }>}
178
+ */
179
+ export function stageEarlyConsensus({
180
+ successfulRefs = [],
181
+ referenceOutputs = [],
182
+ preset = {},
183
+ userPrompt = '',
184
+ prices = {},
185
+ maxTokens = 4096,
186
+ cwd = process.cwd(),
187
+ runId = '',
188
+ signal = null,
189
+ promoteOpts = {},
190
+ allowCandidateOverride = false,
191
+ isRefinement = false,
192
+ historyFilePath = null,
193
+ startTime = Date.now(),
194
+ collectedCtx = null,
195
+ liveCanvas = null,
196
+ ctx = null,
197
+ }) {
198
+ const enabled = Boolean(preset?.early_exit_consensus_enabled)
199
+ const threshold = typeof preset?.early_exit_threshold === 'number' ? preset.early_exit_threshold : 0.95
200
+
201
+ if (!enabled || successfulRefs.length < 2) {
202
+ return Promise.resolve({ ok: true, consensusReached: false })
203
+ }
204
+
205
+ const consensus = checkSemanticConsensus(successfulRefs, threshold)
206
+ if (!consensus.reached || !consensus.winnerCandidate) {
207
+ return Promise.resolve({ ok: true, consensusReached: false, similarity: consensus.similarity })
208
+ }
209
+
210
+ const winner = consensus.winnerCandidate
211
+ emitEvent(ctx, MOA_EVENTS.EARLY_CONSENSUS, {
212
+ runId,
213
+ similarity: consensus.similarity,
214
+ threshold,
215
+ winnerIndex: consensus.winnerIndex,
216
+ winnerModel: winner.label,
217
+ consensusCandidates: consensus.consensusIndices,
218
+ })
219
+
220
+ // Promote winning candidate files
221
+ let promotedFiles = []
222
+ let promoteErr = null
223
+ const runPromotion = async () => {
224
+ if (winner.files?.length > 0) {
225
+ try {
226
+ promotedFiles = await promoteCandidateWorkspace(cwd, winner.index, {
227
+ ...promoteOpts,
228
+ runId,
229
+ keepMoa: allowCandidateOverride,
230
+ })
231
+ } catch (err) {
232
+ promoteErr = err
233
+ }
234
+ } else if (!allowCandidateOverride) {
235
+ await cleanMoaWorkspaces(cwd, { runId })
236
+ }
237
+
238
+ if (promoteErr) {
239
+ if (!allowCandidateOverride) await cleanMoaWorkspaces(cwd, { runId })
240
+ if (signal?.aborted || promoteErr?.message?.includes('aborted')) {
241
+ return { ok: false, consensusReached: true, abort: true, error: new Error('Turn aborted') }
242
+ }
243
+ }
244
+
245
+ const skippedUsage = estimateSkippedJudgeUsage(successfulRefs, userPrompt, maxTokens, prices)
246
+ const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs, {
247
+ winningIndex: winner.index,
248
+ })
249
+
250
+ const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0)
251
+ const totalCostUsd = Number(referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0).toFixed(5))
252
+ const durationMs = Date.now() - startTime
253
+
254
+ try {
255
+ await recordMoaRunAsync({
256
+ id: runId,
257
+ cwd,
258
+ prompt: userPrompt,
259
+ preset: preset?.name || 'default',
260
+ isRefinement,
261
+ isEarlyConsensus: true,
262
+ candidates: candidatesForHistory(referenceOutputs),
263
+ aggregator: 'Early Consensus (Bypassed)',
264
+ winnerIndex: winner.index,
265
+ winnerModel: winner.label,
266
+ promotedFiles,
267
+ totalTokens,
268
+ totalCostUsd,
269
+ skippedJudgeTokens: skippedUsage.skippedTokens,
270
+ durationMs,
271
+ }, historyFilePath || undefined)
272
+ } catch (histErr) {
273
+ logger.warn('[dsh-moa] Failed to record early consensus in history:', histErr?.message || histErr)
274
+ }
275
+
276
+ emitEvent(ctx, MOA_EVENTS.RUN_FINISH, {
277
+ runId,
278
+ durationMs,
279
+ totalTokens,
280
+ totalCostUsd,
281
+ promotedFiles,
282
+ winnerIndex: winner.index,
283
+ isEarlyConsensus: true,
284
+ })
285
+
286
+ return {
287
+ ok: true,
288
+ consensusReached: true,
289
+ payload: {
290
+ kind: 'synthesis',
291
+ content: winner.text,
292
+ aggregator: 'Early Consensus (Bypassed)',
293
+ references: referenceOutputs,
294
+ presetName: preset?.name || 'default',
295
+ isRefinement,
296
+ isEarlyConsensus: true,
297
+ winningIndex: winner.index,
298
+ winnerModel: winner.label,
299
+ promotedFiles,
300
+ runId,
301
+ consensusInfo: {
302
+ similarity: consensus.similarity,
303
+ threshold,
304
+ consensusIndices: consensus.consensusIndices,
305
+ },
306
+ ...(livePreview ? { liveCanvas: livePreview } : {}),
307
+ usage: {
308
+ totalTokens,
309
+ totalCostUsd,
310
+ skippedJudgeTokens: skippedUsage.skippedTokens,
311
+ skippedJudgeCostUsd: skippedUsage.skippedCostUsd,
312
+ candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
313
+ aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
314
+ },
315
+ skippedFiles: collectedCtx?.skippedFiles || 0,
316
+ skippedList: collectedCtx?.skippedList || [],
317
+ durationMs,
318
+ },
319
+ }
320
+ }
321
+
322
+ return runPromotion()
323
+ }
324
+