@goodandready/dsh-moa 0.2.72 → 0.2.74

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,6 @@
1
+ import { evaluateJudgeEscalation } from './escalation.js'
2
+ import { calculateSelfConsistencyAgreement } from './self-consistency.js'
3
+ import { pruneCandidateEnsemble, formatPruningDirective } from './context-pruner.js'
1
4
  import { buildAstCompositeMergeDirective } from '../moa-ast-merge.js'
2
5
  import { runVisualMoaConsensus } from '../moa-visual-consensus.js'
3
6
  import crypto from 'node:crypto'
@@ -65,6 +68,9 @@ export async function runMoAPipeline({
65
68
  isFastMode: ctxStage.isFastMode,
66
69
  collectedCtx: ctxStage.collectedCtx,
67
70
  testGateEnabled, testCommand, execFn,
71
+ semanticCacheEnabled: ctxStage.semanticCacheEnabled,
72
+ semanticCacheTtlHours: ctxStage.semanticCacheTtlHours,
73
+ ctx,
68
74
  })
69
75
  if (!candStage.ok) {
70
76
  if (candStage.abort) return { error: candStage.error }
@@ -102,6 +108,13 @@ export async function runMoAPipeline({
102
108
  })
103
109
  const syntaxDirective = syntaxResult.filterApplied ? formatSyntaxDirective(referenceOutputs, { blind: ctxStage.isBlindEvaluation }) : null
104
110
 
111
+ if (ctxStage.selfConsistencyExpanded && successfulRefs.length > 1) {
112
+ const scAgreement = calculateSelfConsistencyAgreement(successfulRefs)
113
+ if (typeof onProgress === 'function') {
114
+ onProgress(`🎯 *Self-Consistency Agreement: ${(scAgreement.agreementRatio * 100).toFixed(0)}% (${scAgreement.isStrongConsensus ? 'Strong consensus' : 'Divergent sampling'}) across ${scAgreement.sampleCount} samples*\n`)
115
+ }
116
+ }
117
+
105
118
  // Stage 3: Clarifying Questions Branch
106
119
  if (ctxStage.needsQuestions) {
107
120
  return await stageClarifyingQuestions({
@@ -186,11 +199,56 @@ export async function runMoAPipeline({
186
199
  return { error: new Error('Turn aborted') }
187
200
  }
188
201
 
202
+ // Pre-Stage 6: Dynamic Consensus Threshold & Multi-Judge Escalation (#300)
203
+ const escalationResult = evaluateJudgeEscalation({
204
+ candidateOutputs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
205
+ primaryJudge: ctxStage.primaryJudge,
206
+ fallbackJudges: ctxStage.fallbackJudges,
207
+ escalationJudge: ctxStage.escalationJudge,
208
+ policy: ctxStage.escalationPolicy,
209
+ threshold: ctxStage.escalationDisagreementThreshold,
210
+ })
211
+
212
+ let effectivePrimaryJudge = ctxStage.primaryJudge
213
+ let effectiveFallbackJudges = ctxStage.fallbackJudges
214
+ let effectiveJudgesChain = ctxStage.judgesChain
215
+ let effectivePreset = preset
216
+
217
+ if (escalationResult.escalated) {
218
+ emitEvent(ctx, MOA_EVENTS.JUDGE_ESCALATION, {
219
+ runId,
220
+ policy: ctxStage.escalationPolicy,
221
+ disagreement: escalationResult.disagreement,
222
+ threshold: escalationResult.threshold,
223
+ escalatedType: escalationResult.escalatedType,
224
+ originalJudge: escalationResult.originalJudge,
225
+ escalatedJudge: escalationResult.judge,
226
+ councilJudges: escalationResult.councilJudges,
227
+ })
228
+ if (typeof onProgress === 'function') {
229
+ onProgress(`⚖️ *Judge Escalation (${escalationResult.escalatedType}): ${escalationResult.reason}*\n`)
230
+ }
231
+
232
+ if (escalationResult.escalatedType === 'auto_upgrade') {
233
+ effectivePrimaryJudge = escalationResult.judge
234
+ effectiveFallbackJudges = ctxStage.fallbackJudges.filter(
235
+ (j) => !(j.provider === escalationResult.judge.provider && j.model === escalationResult.judge.model)
236
+ )
237
+ effectiveJudgesChain = [effectivePrimaryJudge, ...effectiveFallbackJudges]
238
+ } else if (escalationResult.escalatedType === 'council_majority') {
239
+ effectivePreset = {
240
+ ...preset,
241
+ multi_judge_enabled: true,
242
+ judge_models: escalationResult.councilJudges,
243
+ }
244
+ }
245
+ }
246
+
189
247
  // Stage 6: Multi-Judge Panel
190
248
  const mjRes = await stageMultiJudge({
191
- preset,
249
+ preset: effectivePreset,
192
250
  successfulRefs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
193
- referenceOutputs, primaryJudge: ctxStage.primaryJudge,
251
+ referenceOutputs, primaryJudge: effectivePrimaryJudge,
194
252
  userPrompt, judgeCriteria: ctxStage.judgeCriteria, prices, maxTokens: ctxStage.maxTokens,
195
253
  callLlm, signal, onProgress, cwd, runId, isRefinement: ctxStage.isRefinement,
196
254
  isFastMode: ctxStage.isFastMode, collectedCtx: ctxStage.collectedCtx, startTime,
@@ -218,18 +276,50 @@ export async function runMoAPipeline({
218
276
  const rawFileDirective = (preset?.interactive_workbench_enabled)
219
277
  ? formatFileScoresDirective()
220
278
  : null
221
- const fileScoringDirective = [rawFileDirective, visualResult?.directive, adversarialTddResult?.directive, syntaxDirective].filter(Boolean).join("\n\n") || null
279
+
280
+ // Pre-Judge Stage 6.8: Cross-Candidate Unchanged Base Context Pruning (#305)
281
+ let synthCandidateRefs = syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs
282
+ let synthReferenceOutputs = referenceOutputs
283
+ let pruningDirective = null
284
+ if (ctxStage.contextPruningEnabled && ctxStage.collectedCtx?.files?.length > 0) {
285
+ const pruneRes = pruneCandidateEnsemble(synthCandidateRefs, ctxStage.collectedCtx.files, {
286
+ enabled: true,
287
+ contextLines: ctxStage.contextPruningDiffLines,
288
+ })
289
+ if (pruneRes.isPruned) {
290
+ synthCandidateRefs = pruneRes.candidates
291
+ const prunedByLabel = new Map(synthCandidateRefs.map((c) => [c.label, c.text]))
292
+ synthReferenceOutputs = referenceOutputs.map((r) => {
293
+ if (prunedByLabel.has(r.label)) {
294
+ return { ...r, text: prunedByLabel.get(r.label) }
295
+ }
296
+ return r
297
+ })
298
+ pruningDirective = formatPruningDirective(pruneRes.prunedFiles, pruneRes.totalSavedTokens)
299
+ emitEvent(ctx, MOA_EVENTS.CONTEXT_PRUNING, {
300
+ runId,
301
+ savedTokens: pruneRes.totalSavedTokens,
302
+ prunedFiles: pruneRes.prunedFiles,
303
+ })
304
+ if (typeof onProgress === 'function') {
305
+ onProgress(`✂️ *Context Pruning: Reduced ~${pruneRes.totalSavedTokens} prompt tokens across ${pruneRes.prunedFiles.length} file block(s)*\n`)
306
+ }
307
+ }
308
+ }
309
+
310
+ const fileScoringDirective = [rawFileDirective, visualResult?.directive, adversarialTddResult?.directive, syntaxDirective, pruningDirective].filter(Boolean).join("\n\n") || null
222
311
 
223
312
  // Stage 7: Synthesis Phase
224
313
  const synthRes = await stageSynthesis({
225
- userPrompt, referenceOutputs,
226
- successfulRefs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
314
+ userPrompt,
315
+ referenceOutputs: synthReferenceOutputs,
316
+ successfulRefs: synthCandidateRefs,
227
317
  judgeCriteria: ctxStage.judgeCriteria,
228
318
  isCuratorSynthesis: ctxStage.isCuratorSynthesis, isBlindEvaluation: ctxStage.isBlindEvaluation,
229
319
  priorTurnBaseline: ctxStage.priorTurnBaseline, compositeMergeDirective, fileScoringDirective,
230
- multiJudgeResult, preset, prices, maxTokens: ctxStage.maxTokens, isFastMode: ctxStage.isFastMode,
231
- primaryJudge: ctxStage.primaryJudge, fallbackJudges: ctxStage.fallbackJudges,
232
- judgesChain: ctxStage.judgesChain, aggTemp: ctxStage.aggTemp,
320
+ multiJudgeResult, preset: effectivePreset, prices, maxTokens: ctxStage.maxTokens, isFastMode: ctxStage.isFastMode,
321
+ primaryJudge: effectivePrimaryJudge, fallbackJudges: effectiveFallbackJudges,
322
+ judgesChain: effectiveJudgesChain, aggTemp: ctxStage.aggTemp,
233
323
  aggTimeoutSec: ctxStage.aggTimeoutSec, isStreamAggregator: ctxStage.isStreamAggregator,
234
324
  onStreamDelta, signal, callLlm, onProgress, cwd, runId, isRefinement: ctxStage.isRefinement,
235
325
  collectedCtx: ctxStage.collectedCtx, startTime,
@@ -260,4 +350,4 @@ export async function runMoAPipeline({
260
350
  }
261
351
  return promRes.payload
262
352
  }
263
-
353
+
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Self-Consistency Temperature Sampling for Single Models (#301)
3
+ *
4
+ * Allows users with a single model slot (e.g., local Ollama/vLLM or a single API key)
5
+ * to benefit from Mixture of Agents ensemble diversity by generating parallel samples
6
+ * at varying temperatures and complementary system personas, followed by consistency voting.
7
+ */
8
+
9
+ import { ROLE_PERSONA_PROMPTS } from '../moa-prompts.js'
10
+
11
+ export const DEFAULT_SELF_CONSISTENCY_TEMPERATURES = Object.freeze([0.2, 0.6, 0.9])
12
+ export const DEFAULT_SELF_CONSISTENCY_PERSONAS = Object.freeze(['proposer', 'challenger', 'minimalist'])
13
+
14
+ /**
15
+ * Expands a single reference model into multiple parallel proposer slots
16
+ * with distinct temperatures and complementary reasoning personas.
17
+ *
18
+ * @param {Array<object>} referenceModels - Original reference model slots
19
+ * @param {object} [options]
20
+ * @param {boolean} [options.enabled=false] - Whether self-consistency expansion is enabled
21
+ * @param {number} [options.samples=3] - Number of candidate samples to generate (2-10)
22
+ * @param {Array<number>} [options.temperatures] - Array of temperatures for samples
23
+ * @param {Array<string>} [options.personas] - Array of persona role names
24
+ * @returns {{ references: Array<object>, isExpanded: boolean, sampleCount: number }}
25
+ */
26
+ export function expandSelfConsistencySlots(referenceModels = [], options = {}) {
27
+ const {
28
+ enabled = false,
29
+ samples = 3,
30
+ temperatures = DEFAULT_SELF_CONSISTENCY_TEMPERATURES,
31
+ personas = DEFAULT_SELF_CONSISTENCY_PERSONAS,
32
+ } = options
33
+
34
+ // Expansion applies only when explicitly enabled or requested
35
+ if (!enabled) {
36
+ return {
37
+ references: Array.isArray(referenceModels) ? referenceModels : [],
38
+ isExpanded: false,
39
+ sampleCount: Array.isArray(referenceModels) ? referenceModels.length : 0,
40
+ }
41
+ }
42
+
43
+ const rawList = Array.isArray(referenceModels) && referenceModels.length > 0
44
+ ? referenceModels
45
+ : [{ provider: '', model: '' }]
46
+
47
+ // Only expand when a single model slot is configured
48
+ if (rawList.length !== 1) {
49
+ return {
50
+ references: rawList,
51
+ isExpanded: false,
52
+ sampleCount: rawList.length,
53
+ }
54
+ }
55
+
56
+ const baseSlot = rawList[0] || {}
57
+ const count = Math.min(10, Math.max(2, Number(samples) || 3))
58
+ const expandedList = []
59
+
60
+ for (let i = 0; i < count; i++) {
61
+ const temp = Array.isArray(temperatures) && typeof temperatures[i] === 'number'
62
+ ? temperatures[i]
63
+ : (DEFAULT_SELF_CONSISTENCY_TEMPERATURES[i % DEFAULT_SELF_CONSISTENCY_TEMPERATURES.length] || 0.6)
64
+
65
+ const role = Array.isArray(personas) && personas[i]
66
+ ? personas[i]
67
+ : (DEFAULT_SELF_CONSISTENCY_PERSONAS[i % DEFAULT_SELF_CONSISTENCY_PERSONAS.length] || 'proposer')
68
+
69
+ expandedList.push({
70
+ ...baseSlot,
71
+ label: baseSlot.label
72
+ ? `${baseSlot.label} [T=${temp}, ${role}]`
73
+ : `Sample ${i + 1} (${baseSlot.model || 'model'}, T=${temp})`,
74
+ temperature: temp,
75
+ role_persona: role,
76
+ sampleIndex: i + 1,
77
+ isSelfConsistencySample: true,
78
+ })
79
+ }
80
+
81
+ return {
82
+ references: expandedList,
83
+ isExpanded: true,
84
+ sampleCount: expandedList.length,
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Calculates majority agreement and self-consistency metrics across candidate outputs.
90
+ *
91
+ * @param {Array<object>} candidateOutputs - Array of successful candidate results
92
+ * @returns {{ agreementRatio: number, isStrongConsensus: boolean, sampleCount: number }}
93
+ */
94
+ export function calculateSelfConsistencyAgreement(candidateOutputs = []) {
95
+ if (!Array.isArray(candidateOutputs) || candidateOutputs.length < 2) {
96
+ return {
97
+ agreementRatio: 1.0,
98
+ isStrongConsensus: true,
99
+ sampleCount: Array.isArray(candidateOutputs) ? candidateOutputs.length : 0,
100
+ }
101
+ }
102
+
103
+ const texts = candidateOutputs
104
+ .filter((c) => c && typeof c.text === 'string' && c.text.trim())
105
+ .map((c) => c.text.trim().toLowerCase().replace(/\s+/g, ' '))
106
+
107
+ if (texts.length < 2) {
108
+ return {
109
+ agreementRatio: 1.0,
110
+ isStrongConsensus: true,
111
+ sampleCount: texts.length,
112
+ }
113
+ }
114
+
115
+ // Count exact and near-match clusters
116
+ let maxClusterSize = 1
117
+ for (let i = 0; i < texts.length; i++) {
118
+ let clusterSize = 1
119
+ for (let j = 0; j < texts.length; j++) {
120
+ if (i === j) continue
121
+ // If texts are very similar (> 85% overlap)
122
+ if (texts[i] === texts[j] || (texts[i].length > 20 && texts[j].includes(texts[i].slice(0, 40)))) {
123
+ clusterSize++
124
+ }
125
+ }
126
+ if (clusterSize > maxClusterSize) maxClusterSize = clusterSize
127
+ }
128
+
129
+ const agreementRatio = Number((maxClusterSize / texts.length).toFixed(2))
130
+ return {
131
+ agreementRatio,
132
+ isStrongConsensus: agreementRatio >= 0.66,
133
+ sampleCount: texts.length,
134
+ }
135
+ }
@@ -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
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Semantic Cache Management Routes (#318)
3
+ * Exposes inspection and purge endpoints for locally cached candidate generations.
4
+ */
5
+
6
+ import { getSemanticCacheStats, clearSemanticCache } from '../pipeline/semantic-cache.js'
7
+ import { writeJson, readBody, assertAuthorizedRequest } from '../access.js'
8
+ import { isSafeWriteRequest } from '../updater.js'
9
+
10
+ export function registerCacheRoutes(ctx, { workspaceRoot }) {
11
+ ctx.effect(() => {
12
+ return ctx.webServer.register({
13
+ kind: 'exact',
14
+ path: '/dsh-moa/cache',
15
+ handler: async (req, res) => {
16
+ if (!assertAuthorizedRequest(req, res, ctx)) return
17
+ if (req.method === 'GET') {
18
+ const stats = getSemanticCacheStats({ cwd: workspaceRoot })
19
+ return writeJson(res, 200, { ok: true, stats })
20
+ }
21
+ if (req.method === 'POST') {
22
+ if (!isSafeWriteRequest(req)) {
23
+ return writeJson(res, 403, { ok: false, error: 'Forbidden: cross-site request rejected' })
24
+ }
25
+ let olderThanMs = null
26
+ try {
27
+ const raw = await readBody(req)
28
+ if (raw) {
29
+ const body = JSON.parse(raw)
30
+ if (typeof body.olderThanMs === 'number') olderThanMs = body.olderThanMs
31
+ }
32
+ } catch (_err) {
33
+ /* ignore invalid body for clear */
34
+ }
35
+ const result = clearSemanticCache({ cwd: workspaceRoot, olderThanMs })
36
+ return writeJson(res, 200, { ok: true, clearedCount: result.clearedCount })
37
+ }
38
+ return writeJson(res, 405, { ok: false, error: 'GET or POST required' })
39
+ },
40
+ })
41
+ })
42
+ }
package/lib/routes.js CHANGED
@@ -7,15 +7,18 @@ import { registerPresetRoutes } from './routes/presets.js'
7
7
  import { registerHistoryRoutes } from './routes/history.js'
8
8
  import { registerWorkbenchRoutes } from './routes/workbench.js'
9
9
  import { registerPipelineRoutes } from './routes/pipeline.js'
10
+ import { registerCacheRoutes } from './routes/cache.js'
10
11
 
11
12
  export { registerPresetRoutes } from './routes/presets.js'
12
13
  export { registerHistoryRoutes } from './routes/history.js'
13
14
  export { registerWorkbenchRoutes } from './routes/workbench.js'
14
15
  export { registerPipelineRoutes } from './routes/pipeline.js'
16
+ export { registerCacheRoutes } from './routes/cache.js'
15
17
 
16
18
  export function registerMoaRoutes(ctx, deps) {
17
19
  registerPresetRoutes(ctx, deps)
18
20
  registerHistoryRoutes(ctx, deps)
19
21
  registerWorkbenchRoutes(ctx, deps)
20
22
  registerPipelineRoutes(ctx, deps)
23
+ registerCacheRoutes(ctx, deps)
21
24
  }