@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.
- package/README.md +11 -0
- package/README.ru.md +11 -0
- package/README.zh.md +11 -0
- package/lib/client.js +183 -2
- package/lib/events.js +5 -1
- package/lib/moa-schema.js +29 -1
- package/lib/pipeline/candidates.js +16 -3
- package/lib/pipeline/context-pruner.js +323 -0
- package/lib/pipeline/context.js +46 -3
- package/lib/pipeline/escalation.js +160 -0
- package/lib/pipeline/runner.js +99 -9
- package/lib/pipeline/self-consistency.js +135 -0
- package/lib/pipeline/semantic-cache.js +348 -0
- package/lib/routes/cache.js +42 -0
- package/lib/routes.js +3 -0
- package/package.json +1 -2
package/lib/pipeline/runner.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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,
|
|
226
|
-
|
|
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:
|
|
232
|
-
judgesChain:
|
|
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
|
}
|