@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.
- package/README.md +11 -0
- package/README.ru.md +11 -0
- package/README.zh.md +11 -0
- package/lib/client.js +94 -0
- package/lib/events.js +5 -0
- package/lib/moa-adversarial-tdd.js +4 -1
- package/lib/moa-ast-merge.js +4 -1
- package/lib/moa-schema.js +23 -0
- package/lib/moa-swarm.js +13 -4
- package/lib/pipeline/candidates.js +16 -3
- package/lib/pipeline/context-pruner.js +323 -0
- package/lib/pipeline/context.js +11 -0
- package/lib/pipeline/early-consensus.js +324 -0
- package/lib/pipeline/runner.js +89 -6
- package/lib/pipeline/semantic-cache.js +348 -0
- package/lib/pipeline/syntax-filter.js +324 -0
- package/lib/routes/cache.js +42 -0
- package/lib/routes.js +3 -0
- package/lib/tui-settings.js +2 -1
- package/package.json +1 -1
package/lib/pipeline/runner.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
+
}
|