@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.
@@ -1,3 +1,4 @@
1
+ import { pruneCandidateEnsemble, formatPruningDirective } from './context-pruner.js'
1
2
  import { buildAstCompositeMergeDirective } from '../moa-ast-merge.js'
2
3
  import { runVisualMoaConsensus } from '../moa-visual-consensus.js'
3
4
  import crypto from 'node:crypto'
@@ -13,6 +14,8 @@ import { stagePeerCritique } from './peer-critique.js'
13
14
  import { stageMultiJudge } from './multi-judge.js'
14
15
  import { stageSynthesis } from './synthesis.js'
15
16
  import { stagePromotion } from './promotion.js'
17
+ import { applySyntaxFilter, formatSyntaxDirective } from './syntax-filter.js'
18
+ import { stageEarlyConsensus } from './early-consensus.js'
16
19
  import { emitEvent, MOA_EVENTS } from '../events.js'
17
20
 
18
21
  export async function runMoAPipeline({
@@ -63,6 +66,9 @@ export async function runMoAPipeline({
63
66
  isFastMode: ctxStage.isFastMode,
64
67
  collectedCtx: ctxStage.collectedCtx,
65
68
  testGateEnabled, testCommand, execFn,
69
+ semanticCacheEnabled: ctxStage.semanticCacheEnabled,
70
+ semanticCacheTtlHours: ctxStage.semanticCacheTtlHours,
71
+ ctx,
66
72
  })
67
73
  if (!candStage.ok) {
68
74
  if (candStage.abort) return { error: candStage.error }
@@ -90,6 +96,16 @@ export async function runMoAPipeline({
90
96
  })
91
97
  }
92
98
 
99
+ // Pre-Judge Stage 2.5: Zero-Token Local Syntax Gate (#303)
100
+ const syntaxResult = applySyntaxFilter(successfulRefs, { enabled: ctxStage.localPreflightCheck })
101
+ emitEvent(ctx, MOA_EVENTS.SYNTAX_VALIDATION, {
102
+ runId,
103
+ filterApplied: syntaxResult.filterApplied,
104
+ validCount: syntaxResult.validCandidates.length,
105
+ invalidCount: syntaxResult.invalidCandidates.length,
106
+ })
107
+ const syntaxDirective = syntaxResult.filterApplied ? formatSyntaxDirective(referenceOutputs, { blind: ctxStage.isBlindEvaluation }) : null
108
+
93
109
  // Stage 3: Clarifying Questions Branch
94
110
  if (ctxStage.needsQuestions) {
95
111
  return await stageClarifyingQuestions({
@@ -103,7 +119,8 @@ export async function runMoAPipeline({
103
119
  // Stage 4: Fast Mode (1 candidate bypass)
104
120
  if (ctxStage.isFastMode) {
105
121
  const fastRes = await stageFastMode({
106
- successfulRefs, referenceOutputs,
122
+ successfulRefs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
123
+ referenceOutputs,
107
124
  allowCandidateOverride: ctxStage.allowCandidateOverride,
108
125
  promoteOpts, cwd, runId, signal, userPrompt, preset,
109
126
  isRefinement: ctxStage.isRefinement, historyFilePath, startTime,
@@ -116,6 +133,32 @@ export async function runMoAPipeline({
116
133
  return fastRes.payload
117
134
  }
118
135
 
136
+ // Pre-Judge Stage 4.5: Early Exit upon Semantic Consensus (#299)
137
+ if (ctxStage.earlyExitConsensusEnabled && successfulRefs.length > 1) {
138
+ const earlyRes = await stageEarlyConsensus({
139
+ successfulRefs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
140
+ referenceOutputs,
141
+ preset,
142
+ userPrompt,
143
+ prices,
144
+ maxTokens: ctxStage.maxTokens,
145
+ cwd,
146
+ runId,
147
+ signal,
148
+ promoteOpts,
149
+ allowCandidateOverride: ctxStage.allowCandidateOverride,
150
+ isRefinement: ctxStage.isRefinement,
151
+ historyFilePath,
152
+ startTime,
153
+ collectedCtx: ctxStage.collectedCtx,
154
+ liveCanvas,
155
+ ctx,
156
+ })
157
+ if (earlyRes.consensusReached) {
158
+ return earlyRes.payload
159
+ }
160
+ }
161
+
119
162
  if (signal?.aborted) {
120
163
  await cleanMoaWorkspaces(cwd, { runId })
121
164
  return { error: new Error('Turn aborted') }
@@ -124,7 +167,8 @@ export async function runMoAPipeline({
124
167
  // Stage 5: Consilium Round 2 (Peer Critique)
125
168
  const peerRes = await stagePeerCritique({
126
169
  isPeerCritiqueEnabled: ctxStage.isPeerCritiqueEnabled,
127
- successfulRefs, referenceOutputs, preset, prices,
170
+ successfulRefs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
171
+ referenceOutputs, preset, prices,
128
172
  maxTokens: ctxStage.maxTokens, primaryJudge: ctxStage.primaryJudge,
129
173
  userPrompt, isBlindEvaluation: ctxStage.isBlindEvaluation,
130
174
  refTemp: ctxStage.refTemp, refTimeoutSec: ctxStage.refTimeoutSec,
@@ -148,7 +192,9 @@ export async function runMoAPipeline({
148
192
 
149
193
  // Stage 6: Multi-Judge Panel
150
194
  const mjRes = await stageMultiJudge({
151
- preset, successfulRefs, referenceOutputs, primaryJudge: ctxStage.primaryJudge,
195
+ preset,
196
+ successfulRefs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
197
+ referenceOutputs, primaryJudge: ctxStage.primaryJudge,
152
198
  userPrompt, judgeCriteria: ctxStage.judgeCriteria, prices, maxTokens: ctxStage.maxTokens,
153
199
  callLlm, signal, onProgress, cwd, runId, isRefinement: ctxStage.isRefinement,
154
200
  isFastMode: ctxStage.isFastMode, collectedCtx: ctxStage.collectedCtx, startTime,
@@ -176,11 +222,45 @@ export async function runMoAPipeline({
176
222
  const rawFileDirective = (preset?.interactive_workbench_enabled)
177
223
  ? formatFileScoresDirective()
178
224
  : null
179
- const fileScoringDirective = [rawFileDirective, visualResult?.directive, adversarialTddResult?.directive].filter(Boolean).join("\n\n") || null
225
+
226
+ // Pre-Judge Stage 6.8: Cross-Candidate Unchanged Base Context Pruning (#305)
227
+ let synthCandidateRefs = syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs
228
+ let synthReferenceOutputs = referenceOutputs
229
+ let pruningDirective = null
230
+ if (ctxStage.contextPruningEnabled && ctxStage.collectedCtx?.files?.length > 0) {
231
+ const pruneRes = pruneCandidateEnsemble(synthCandidateRefs, ctxStage.collectedCtx.files, {
232
+ enabled: true,
233
+ contextLines: ctxStage.contextPruningDiffLines,
234
+ })
235
+ if (pruneRes.isPruned) {
236
+ synthCandidateRefs = pruneRes.candidates
237
+ const prunedByLabel = new Map(synthCandidateRefs.map((c) => [c.label, c.text]))
238
+ synthReferenceOutputs = referenceOutputs.map((r) => {
239
+ if (prunedByLabel.has(r.label)) {
240
+ return { ...r, text: prunedByLabel.get(r.label) }
241
+ }
242
+ return r
243
+ })
244
+ pruningDirective = formatPruningDirective(pruneRes.prunedFiles, pruneRes.totalSavedTokens)
245
+ emitEvent(ctx, MOA_EVENTS.CONTEXT_PRUNING, {
246
+ runId,
247
+ savedTokens: pruneRes.totalSavedTokens,
248
+ prunedFiles: pruneRes.prunedFiles,
249
+ })
250
+ if (typeof onProgress === 'function') {
251
+ onProgress(`✂️ *Context Pruning: Reduced ~${pruneRes.totalSavedTokens} prompt tokens across ${pruneRes.prunedFiles.length} file block(s)*\n`)
252
+ }
253
+ }
254
+ }
255
+
256
+ const fileScoringDirective = [rawFileDirective, visualResult?.directive, adversarialTddResult?.directive, syntaxDirective, pruningDirective].filter(Boolean).join("\n\n") || null
180
257
 
181
258
  // Stage 7: Synthesis Phase
182
259
  const synthRes = await stageSynthesis({
183
- userPrompt, referenceOutputs, successfulRefs, judgeCriteria: ctxStage.judgeCriteria,
260
+ userPrompt,
261
+ referenceOutputs: synthReferenceOutputs,
262
+ successfulRefs: synthCandidateRefs,
263
+ judgeCriteria: ctxStage.judgeCriteria,
184
264
  isCuratorSynthesis: ctxStage.isCuratorSynthesis, isBlindEvaluation: ctxStage.isBlindEvaluation,
185
265
  priorTurnBaseline: ctxStage.priorTurnBaseline, compositeMergeDirective, fileScoringDirective,
186
266
  multiJudgeResult, preset, prices, maxTokens: ctxStage.maxTokens, isFastMode: ctxStage.isFastMode,
@@ -200,7 +280,9 @@ export async function runMoAPipeline({
200
280
 
201
281
  // Stage 8: Evaluation, Winner & Promotion
202
282
  const promRes = await stagePromotion({
203
- synthesizedText, referenceOutputs, successfulRefs, multiJudgeResult,
283
+ synthesizedText, referenceOutputs,
284
+ successfulRefs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
285
+ multiJudgeResult,
204
286
  isCuratorSynthesis: ctxStage.isCuratorSynthesis,
205
287
  allowCandidateOverride: ctxStage.allowCandidateOverride,
206
288
  promoteOpts, cwd, runId, signal, liveCanvas, startTime, chosenJudge, aggUsage,
@@ -214,3 +296,4 @@ export async function runMoAPipeline({
214
296
  }
215
297
  return promRes.payload
216
298
  }
299
+
@@ -0,0 +1,348 @@
1
+ import { bestEffort } from '../best-effort.js'
2
+ import { emitEvent, MOA_EVENTS } from '../events.js'
3
+ /**
4
+ * Semantic Caching for Intermediate Candidate Generations (#318)
5
+ *
6
+ * Persists and resolves candidate generations and similarity fingerprints locally
7
+ * to eliminate duplicate API costs during iterative development, questionnaire retries,
8
+ * or recurring benchmark runs.
9
+ */
10
+
11
+ import crypto from 'node:crypto'
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+
15
+ // Runtime metrics tracking
16
+ let _runtimeHits = 0
17
+ let _runtimeMisses = 0
18
+ let _runtimeSavedTokens = 0
19
+ let _runtimeSavedCostUsd = 0
20
+
21
+ /**
22
+ * Resets runtime cache hit/miss statistics (useful for tests).
23
+ */
24
+ export function resetRuntimeCacheStats() {
25
+ _runtimeHits = 0
26
+ _runtimeMisses = 0
27
+ _runtimeSavedTokens = 0
28
+ _runtimeSavedCostUsd = 0
29
+ }
30
+
31
+ /**
32
+ * Normalizes prompt text to eliminate trivial whitespace or capitalization variations.
33
+ *
34
+ * @param {string} prompt
35
+ * @returns {string}
36
+ */
37
+ export function normalizePrompt(prompt = '') {
38
+ if (typeof prompt !== 'string') return ''
39
+ return prompt
40
+ .trim()
41
+ .replace(/\r\n/g, '\n')
42
+ .split('\n')
43
+ .map((line) => line.trim().replace(/[ \t]+/g, ' '))
44
+ .join('\n')
45
+ .replace(/\n{3,}/g, '\n\n')
46
+ }
47
+
48
+ /**
49
+ * Computes a deterministic SHA-256 fingerprint for a candidate generation request.
50
+ *
51
+ * @param {string} prompt
52
+ * @param {object} [options]
53
+ * @param {string} [options.model='']
54
+ * @param {number} [options.temperature=0.6]
55
+ * @param {string} [options.systemPrompt='']
56
+ * @param {string} [options.contextHash='']
57
+ * @returns {string}
58
+ */
59
+ export function computePromptFingerprint(prompt = '', options = {}) {
60
+ const {
61
+ model = '',
62
+ temperature = 0.6,
63
+ systemPrompt = '',
64
+ contextHash = '',
65
+ } = options
66
+
67
+ const normPrompt = normalizePrompt(prompt)
68
+ const normSys = normalizePrompt(systemPrompt)
69
+ const roundedTemp = Number(temperature || 0).toFixed(2)
70
+
71
+ const payload = [
72
+ normPrompt,
73
+ String(model || '').trim().toLowerCase(),
74
+ roundedTemp,
75
+ normSys,
76
+ String(contextHash || ''),
77
+ ].join(':::')
78
+
79
+ return crypto.createHash('sha256').update(payload, 'utf8').digest('hex')
80
+ }
81
+
82
+ /**
83
+ * Resolves the cache directory, ensuring it is created safely.
84
+ *
85
+ * @param {string} cwd - Base workspace directory
86
+ * @param {string} [customDir]
87
+ * @returns {string}
88
+ */
89
+ export function resolveCacheDir(cwd = process.cwd(), customDir = null) {
90
+ const dir = customDir
91
+ ? path.resolve(cwd, customDir)
92
+ : path.resolve(cwd, '.moa', 'cache', 'semantic')
93
+ try {
94
+ if (!fs.existsSync(dir)) {
95
+ fs.mkdirSync(dir, { recursive: true })
96
+ }
97
+ } catch (_err) {
98
+ /* ignore directory creation errors */
99
+ }
100
+ return dir
101
+ }
102
+
103
+ /**
104
+ * Retrieves a cached candidate generation entry.
105
+ *
106
+ * @param {string} key - SHA-256 cache fingerprint
107
+ * @param {object} [options]
108
+ * @param {string} [options.cacheDir]
109
+ * @param {number} [options.maxAgeMs=604800000] - 7 days default TTL
110
+ * @returns {object|null}
111
+ */
112
+ export function getCacheEntry(key, options = {}) {
113
+ if (!key || typeof key !== 'string') return null
114
+ const { cacheDir, maxAgeMs = 7 * 24 * 60 * 60 * 1000 } = options
115
+ const targetDir = resolveCacheDir(options.cwd, cacheDir)
116
+ const filePath = path.join(targetDir, `${key}.json`)
117
+
118
+ try {
119
+ if (!fs.existsSync(filePath)) {
120
+ _runtimeMisses++
121
+ return null
122
+ }
123
+
124
+ const raw = fs.readFileSync(filePath, 'utf8')
125
+ const entry = JSON.parse(raw)
126
+ const now = Date.now()
127
+
128
+ if (entry.createdAt && (now - entry.createdAt > (entry.ttlMs || maxAgeMs))) {
129
+ // Expired entry
130
+ bestEffort('unlink expired cache entry', () => fs.unlinkSync(filePath))
131
+ _runtimeMisses++
132
+ return null
133
+ }
134
+
135
+ _runtimeHits++
136
+ const tokens = entry.usage?.totalTokens || 0
137
+ const cost = entry.costUsd || 0
138
+ _runtimeSavedTokens += tokens
139
+ _runtimeSavedCostUsd += cost
140
+
141
+ // Update access metadata
142
+ entry.lastAccessedAt = now
143
+ entry.hitCount = (entry.hitCount || 0) + 1
144
+ bestEffort('update cache access metadata', () => {
145
+ fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), 'utf8')
146
+ })
147
+
148
+ return entry
149
+ } catch (_err) {
150
+ _runtimeMisses++
151
+ return null
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Stores a candidate generation output into the semantic cache.
157
+ *
158
+ * @param {string} key - Cache fingerprint
159
+ * @param {object} entryData - Candidate generation payload
160
+ * @param {object} [options]
161
+ * @param {string} [options.cacheDir]
162
+ * @param {number} [options.ttlMs=604800000]
163
+ * @returns {boolean}
164
+ */
165
+ export function setCacheEntry(key, entryData = {}, options = {}) {
166
+ if (!key || typeof key !== 'string') return false
167
+ const { cacheDir, ttlMs = 7 * 24 * 60 * 60 * 1000 } = options
168
+ const targetDir = resolveCacheDir(options.cwd, cacheDir)
169
+ const filePath = path.join(targetDir, `${key}.json`)
170
+
171
+ const payload = {
172
+ key,
173
+ model: entryData.model || '',
174
+ output: entryData.output || entryData.text || '',
175
+ usage: entryData.usage || { totalTokens: 0, promptTokens: 0, completionTokens: 0 },
176
+ costUsd: entryData.costUsd || 0,
177
+ createdAt: Date.now(),
178
+ ttlMs: entryData.ttlMs || ttlMs,
179
+ hitCount: 0,
180
+ metadata: entryData.metadata || {},
181
+ }
182
+
183
+ try {
184
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8')
185
+ return true
186
+ } catch (_err) {
187
+ return false
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Clears expired or all entries from the semantic cache.
193
+ *
194
+ * @param {object} [options]
195
+ * @param {string} [options.cacheDir]
196
+ * @param {number} [options.olderThanMs]
197
+ * @returns {{ clearedCount: number }}
198
+ */
199
+ export function clearSemanticCache(options = {}) {
200
+ const { cacheDir, olderThanMs = null } = options
201
+ const targetDir = resolveCacheDir(options.cwd, cacheDir)
202
+ let clearedCount = 0
203
+
204
+ try {
205
+ if (!fs.existsSync(targetDir)) return { clearedCount: 0 }
206
+ const files = fs.readdirSync(targetDir)
207
+ const now = Date.now()
208
+
209
+ for (const f of files) {
210
+ if (!f.endsWith('.json')) continue
211
+ const filePath = path.join(targetDir, f)
212
+ if (typeof olderThanMs === 'number' && olderThanMs > 0) {
213
+ try {
214
+ const raw = fs.readFileSync(filePath, 'utf8')
215
+ const entry = JSON.parse(raw)
216
+ if (now - (entry.createdAt || 0) < olderThanMs) {
217
+ continue
218
+ }
219
+ } catch (_err) {
220
+ /* corrupt file, proceed to unlink */
221
+ }
222
+ }
223
+ bestEffort('unlink cleared cache entry', () => {
224
+ fs.unlinkSync(filePath)
225
+ clearedCount++
226
+ })
227
+ }
228
+ } catch (_err) {
229
+ /* ignore read/delete errors */
230
+ }
231
+
232
+ return { clearedCount }
233
+ }
234
+
235
+ /**
236
+ * Returns cache usage metrics and statistics.
237
+ *
238
+ * @param {object} [options]
239
+ * @returns {object}
240
+ */
241
+ export function getSemanticCacheStats(options = {}) {
242
+ const { cacheDir } = options
243
+ const targetDir = resolveCacheDir(options.cwd, cacheDir)
244
+ let entriesCount = 0
245
+ let totalSizeBytes = 0
246
+
247
+ try {
248
+ if (fs.existsSync(targetDir)) {
249
+ const files = fs.readdirSync(targetDir)
250
+ for (const f of files) {
251
+ if (!f.endsWith('.json')) continue
252
+ try {
253
+ const stat = fs.statSync(path.join(targetDir, f))
254
+ entriesCount++
255
+ totalSizeBytes += stat.size
256
+ } catch (_err) {
257
+ /* file unlinked concurrently */
258
+ }
259
+ }
260
+ }
261
+ } catch (_err) {
262
+ /* ignore dir access errors */
263
+ }
264
+
265
+ return {
266
+ entriesCount,
267
+ totalSizeBytes,
268
+ hits: _runtimeHits,
269
+ misses: _runtimeMisses,
270
+ savedTokens: _runtimeSavedTokens,
271
+ savedCostUsd: Number(_runtimeSavedCostUsd.toFixed(5)),
272
+ }
273
+ }
274
+
275
+
276
+ /**
277
+ * Wraps callLlm with local semantic caching for intermediate candidate generations (#318).
278
+ *
279
+ * @param {Function} callLlm - Raw LLM invocation function
280
+ * @param {object} [options]
281
+ * @param {boolean} [options.enabled=false]
282
+ * @param {number} [options.ttlMs=604800000]
283
+ * @param {string} [options.cwd]
284
+ * @param {object} [options.ctx]
285
+ * @returns {Function}
286
+ */
287
+ export function createCachingCallLlm(callLlm, options = {}) {
288
+ const { enabled = false, ttlMs = 7 * 24 * 60 * 60 * 1000, cwd, ctx } = options
289
+ if (!enabled || typeof callLlm !== 'function') return callLlm
290
+
291
+ return async function cachedCallLlm(callArgs) {
292
+ if (!callArgs || typeof callArgs !== 'object') {
293
+ return callLlm(callArgs)
294
+ }
295
+
296
+ const messages = Array.isArray(callArgs.messages) ? callArgs.messages : []
297
+ const promptText = messages
298
+ .filter((m) => m && m.role === 'user')
299
+ .map((m) => m.content)
300
+ .join('\n') || String(callArgs.prompt || '')
301
+ const systemPrompt = messages
302
+ .filter((m) => m && m.role === 'system')
303
+ .map((m) => m.content)
304
+ .join('\n') || ''
305
+
306
+ const modelName = callArgs.model
307
+ ? (callArgs.provider ? `${callArgs.provider}:${callArgs.model}` : callArgs.model)
308
+ : ''
309
+
310
+ const key = computePromptFingerprint(promptText, {
311
+ model: modelName,
312
+ temperature: callArgs.temperature ?? 0.6,
313
+ systemPrompt,
314
+ })
315
+
316
+ const cached = getCacheEntry(key, { cwd, maxAgeMs: ttlMs })
317
+ if (cached && typeof cached.output === 'string' && cached.output.length > 0) {
318
+ emitEvent(ctx, MOA_EVENTS.CACHE_HIT, { key, model: modelName })
319
+ return {
320
+ text: cached.output,
321
+ content: cached.output,
322
+ usage: cached.usage || { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
323
+ costUsd: 0,
324
+ cached: true,
325
+ }
326
+ }
327
+
328
+ const res = await callLlm(callArgs)
329
+ const text = typeof res === 'string' ? res : (res?.content || res?.text || '')
330
+ if (text) {
331
+ const usage = (typeof res === 'object' && res?.usage) ? res.usage : {
332
+ inputTokens: Math.max(1, Math.round((promptText.length + systemPrompt.length) / 4)),
333
+ outputTokens: Math.max(1, Math.round(text.length / 4)),
334
+ totalTokens: Math.max(2, Math.round((promptText.length + systemPrompt.length + text.length) / 4)),
335
+ }
336
+ const costUsd = typeof res === 'object' && typeof res?.costUsd === 'number' ? res.costUsd : 0
337
+ setCacheEntry(key, {
338
+ model: modelName,
339
+ output: text,
340
+ usage,
341
+ costUsd,
342
+ ttlMs,
343
+ }, { cwd, ttlMs })
344
+ }
345
+
346
+ return res
347
+ }
348
+ }