@goodandready/dsh-moa 0.2.72 → 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 +2 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/lib/client.js +49 -0
- package/lib/events.js +3 -1
- package/lib/moa-schema.js +17 -1
- package/lib/pipeline/candidates.js +16 -3
- package/lib/pipeline/context-pruner.js +323 -0
- package/lib/pipeline/context.js +7 -1
- package/lib/pipeline/runner.js +40 -4
- 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/README.md
CHANGED
|
@@ -327,6 +327,8 @@ dsh-moa:
|
|
|
327
327
|
| `/dsh-moa/run` | `POST` | Runs the full MoA pipeline over HTTP (400 when `enabled: false`) |
|
|
328
328
|
| `/dsh-moa/diff` | `GET` | Computes line-by-line diff between candidate runs or curator synthesis |
|
|
329
329
|
| `/dsh-moa/promote` | `POST` | Manually promotes candidate workspace files to project root |
|
|
330
|
+
| `/dsh-moa/cache` | `GET` | Returns semantic cache stats, hit/miss counters, and saved token metrics |
|
|
331
|
+
| `/dsh-moa/cache` | `POST` | Purges cached candidate generations with optional olderThanMs filter |
|
|
330
332
|
| `/api/dsh-moa/update` | `POST` | One-click plugin updater from npm with safe-write verification |
|
|
331
333
|
|
|
332
334
|
---
|
package/README.ru.md
CHANGED
|
@@ -327,6 +327,8 @@ dsh-moa:
|
|
|
327
327
|
| `/dsh-moa/run` | `POST` | Запускает полный пайплайн MoA по HTTP (400 при `enabled: false`) |
|
|
328
328
|
| `/dsh-moa/diff` | `GET` | Вычисляет построчный diff между решениями кандидатов или синтезом куратора |
|
|
329
329
|
| `/dsh-moa/promote` | `POST` | Ручной промоушн файлов кандидата в корень проекта с созданием снимка |
|
|
330
|
+
| `/dsh-moa/cache` | `GET` | Статистика семантического кэша, число попаданий и сэкономленные токены |
|
|
331
|
+
| `/dsh-moa/cache` | `POST` | Очистка семантического кэша с опциональным фильтром olderThanMs |
|
|
330
332
|
| `/api/dsh-moa/update` | `POST` | One-click обновление плагина из npm с проверкой безопасности источника |
|
|
331
333
|
|
|
332
334
|
---
|
package/README.zh.md
CHANGED
|
@@ -257,6 +257,8 @@ dsh-moa:
|
|
|
257
257
|
| `/dsh-moa/run` | `POST` | 通过 HTTP 运行完整 MoA 管线(`enabled: false` 时返回 400) |
|
|
258
258
|
| `/dsh-moa/diff` | `GET` | 计算候选运行结果或综合输出的行级别差异(支持按 runId 解析工作区) |
|
|
259
259
|
| `/dsh-moa/promote` | `POST` | 手动将候选工作区文件提升到项目根目录(带时间机器快照) |
|
|
260
|
+
| `/dsh-moa/cache` | `GET` | 返回语义缓存统计信息、命中次数与节省 Token 指标 |
|
|
261
|
+
| `/dsh-moa/cache` | `POST` | 清除语义缓存条目,支持 olderThanMs 过滤 |
|
|
260
262
|
| `/api/dsh-moa/update` | `POST` | 从 npm 一键更新插件,带安全写入源校验 |
|
|
261
263
|
|
|
262
264
|
---
|
package/lib/client.js
CHANGED
|
@@ -167,6 +167,12 @@ window.__ModuleLoader__.load({
|
|
|
167
167
|
"config.smart_routing_desc": "Analyze task complexity and automatically select the optimal preset.",
|
|
168
168
|
"config.smart_routing_model": "Routing Model:",
|
|
169
169
|
// Multi-Turn Context
|
|
170
|
+
"aggregator.context_pruning_title": "\u2702\uFE0F Cross-Candidate Context Pruning (Diff-Only Judge Context)",
|
|
171
|
+
"aggregator.context_pruning_desc": "Replaces unchanged base project files with compact unified diffs (---/+++ hunks) in the Judge prompt, slashing prompt tokens by up to 85%.",
|
|
172
|
+
"aggregator.context_pruning_lines": "Context lines around changes:",
|
|
173
|
+
"aggregator.semantic_cache_title": "\u26A1 Semantic Caching for Candidate Generations",
|
|
174
|
+
"aggregator.semantic_cache_desc": "Persists intermediate candidate outputs locally by deterministic SHA-256 fingerprint, eliminating duplicate API costs on retry or repeat requests.",
|
|
175
|
+
"aggregator.semantic_cache_ttl": "Cache TTL (hours):",
|
|
170
176
|
"aggregator.multi_turn_title": "Multi-Turn Context & Diff Pruning",
|
|
171
177
|
"aggregator.multi_turn_desc": "Retain conversation context across turns while pruning duplicate code blocks.",
|
|
172
178
|
// UI States & Errors
|
|
@@ -365,6 +371,12 @@ window.__ModuleLoader__.load({
|
|
|
365
371
|
"config.smart_routing_desc": "\u5206\u6790\u4EFB\u52A1\u590D\u6742\u5EA6\u5E76\u81EA\u52A8\u5339\u914D\u6700\u4F73\u9884\u8BBE\u3002",
|
|
366
372
|
"config.smart_routing_model": "\u5206\u6D41\u51B3\u7B56\u6A21\u578B\uFF1A",
|
|
367
373
|
// Multi-Turn Context
|
|
374
|
+
"aggregator.context_pruning_title": "\u2702\uFE0F \u5019\u9009\u8DE8\u6A21\u578B\u4E0A\u4E0B\u6587\u7CBE\u7B80 (Judge \u4EC5\u5DEE\u5F02\u8865\u4E01)",
|
|
375
|
+
"aggregator.context_pruning_desc": "\u5728 Judge \u63D0\u793A\u8BCD\u4E2D\u5C06\u672A\u4FEE\u6539\u7684\u57FA\u7840\u9879\u76EE\u6587\u4EF6\u66FF\u6362\u4E3A\u7D27\u51D1\u7684\u7EDF\u4E00\u5DEE\u5F02\u5757 (---/+++)\uFF0C\u5728\u5927\u6587\u4EF6\u573A\u666F\u4E0B\u524A\u51CF\u9AD8\u8FBE 85% \u7684\u63D0\u793A\u8BCD Token\u3002",
|
|
376
|
+
"aggregator.context_pruning_lines": "\u5DEE\u5F02\u4E0A\u4E0B\u6587\u884C\u6570:",
|
|
377
|
+
"aggregator.semantic_cache_title": "\u26A1 \u5019\u9009\u751F\u6210\u8BED\u4E49\u7F13\u5B58",
|
|
378
|
+
"aggregator.semantic_cache_desc": "\u901A\u8FC7\u786E\u5B9A\u7684 SHA-256 \u6307\u7EB9\u5728\u672C\u5730\u6301\u4E45\u5316\u5019\u9009\u6A21\u578B\u4E2D\u95F4\u8F93\u51FA\uFF0C\u5728\u91CD\u8BD5\u6216\u91CD\u590D\u8FED\u4EE3\u4E2D\u6D88\u9664\u5197\u4F59 API \u8D39\u7528\u3002",
|
|
379
|
+
"aggregator.semantic_cache_ttl": "\u7F13\u5B58\u6709\u6548\u671F (\u5C0F\u65F6):",
|
|
368
380
|
"aggregator.multi_turn_title": "\u591A\u8F6E\u5BF9\u8BDD\u4E0A\u4E0B\u6587\u4E0E\u5DEE\u5F02\u526A\u679D",
|
|
369
381
|
"aggregator.multi_turn_desc": "\u8DE8\u8F6E\u6B21\u4FDD\u7559\u5BF9\u8BDD\u4E0A\u4E0B\u6587\u5E76\u526A\u679D\u91CD\u590D\u4EE3\u7801\u5757\u3002",
|
|
370
382
|
// UI States & Errors
|
|
@@ -2378,6 +2390,43 @@ window.__ModuleLoader__.load({
|
|
|
2378
2390
|
t("aggregator.multi_turn_desc") || "Retain conversation context across turns while pruning duplicate code blocks."
|
|
2379
2391
|
)
|
|
2380
2392
|
),
|
|
2393
|
+
/* Context Pruning (#305) & Semantic Caching (#318) */
|
|
2394
|
+
React.createElement(
|
|
2395
|
+
"div",
|
|
2396
|
+
{ style: { display: "flex", flexDirection: "column", gap: 6, padding: "8px 12px", background: "var(--dsw-alias-bg-layer-2)", borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2)", marginTop: 8 } },
|
|
2397
|
+
React.createElement(
|
|
2398
|
+
"label",
|
|
2399
|
+
{ style: { display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 13, fontWeight: 500 } },
|
|
2400
|
+
React.createElement("input", {
|
|
2401
|
+
type: "checkbox",
|
|
2402
|
+
checked: currentPreset.context_pruning_enabled !== false,
|
|
2403
|
+
onChange: (e) => updateCurrentPreset((p) => ({ ...p, context_pruning_enabled: e.target.checked })),
|
|
2404
|
+
style: { accentColor: "var(--dsw-alias-state-brand-primary, var(--dsw-alias-label-primary))", cursor: "pointer" }
|
|
2405
|
+
}),
|
|
2406
|
+
t("aggregator.context_pruning_title") || "\u2702\uFE0F Cross-Candidate Context Pruning (Diff-Only Judge Context)"
|
|
2407
|
+
),
|
|
2408
|
+
React.createElement(
|
|
2409
|
+
"div",
|
|
2410
|
+
{ style: { fontSize: 12, color: "var(--dsw-alias-label-secondary)", marginLeft: 22 } },
|
|
2411
|
+
t("aggregator.context_pruning_desc") || "Replaces unchanged base project files with compact unified diffs (---/+++ hunks) in the Judge prompt, slashing prompt tokens by up to 85%."
|
|
2412
|
+
),
|
|
2413
|
+
React.createElement(
|
|
2414
|
+
"label",
|
|
2415
|
+
{ style: { display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 13, fontWeight: 500, marginTop: 4 } },
|
|
2416
|
+
React.createElement("input", {
|
|
2417
|
+
type: "checkbox",
|
|
2418
|
+
checked: Boolean(currentPreset.semantic_cache_enabled),
|
|
2419
|
+
onChange: (e) => updateCurrentPreset((p) => ({ ...p, semantic_cache_enabled: e.target.checked })),
|
|
2420
|
+
style: { accentColor: "var(--dsw-alias-state-brand-primary, var(--dsw-alias-label-primary))", cursor: "pointer" }
|
|
2421
|
+
}),
|
|
2422
|
+
t("aggregator.semantic_cache_title") || "\u26A1 Semantic Caching for Candidate Generations"
|
|
2423
|
+
),
|
|
2424
|
+
React.createElement(
|
|
2425
|
+
"div",
|
|
2426
|
+
{ style: { fontSize: 12, color: "var(--dsw-alias-label-secondary)", marginLeft: 22 } },
|
|
2427
|
+
t("aggregator.semantic_cache_desc") || "Persists intermediate candidate outputs locally by deterministic SHA-256 fingerprint, eliminating duplicate API costs on retry or repeat requests."
|
|
2428
|
+
)
|
|
2429
|
+
),
|
|
2381
2430
|
/* Fallback Judges Chain */
|
|
2382
2431
|
React.createElement(
|
|
2383
2432
|
"div",
|
package/lib/events.js
CHANGED
|
@@ -9,6 +9,8 @@ export const MOA_EVENTS = {
|
|
|
9
9
|
EARLY_CONSENSUS: 'moa/early-consensus',
|
|
10
10
|
CRITIQUE_COMPLETE: 'moa/critique-complete',
|
|
11
11
|
JUDGE_VERDICT: 'moa/judge-verdict',
|
|
12
|
+
CONTEXT_PRUNING: 'moa/context-pruning',
|
|
13
|
+
CACHE_HIT: 'moa/cache-hit',
|
|
12
14
|
RUN_FINISH: 'moa/run-finish',
|
|
13
15
|
}
|
|
14
16
|
|
|
@@ -30,4 +32,4 @@ export function emitEvent(ctx, eventName, payload) {
|
|
|
30
32
|
/* best-effort event dispatch: listener exceptions must never abort pipeline */
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
|
-
|
|
35
|
+
|
package/lib/moa-schema.js
CHANGED
|
@@ -79,6 +79,10 @@ export const PresetSchema = z.object({
|
|
|
79
79
|
local_preflight_check: z.boolean().default(true),
|
|
80
80
|
early_exit_consensus_enabled: z.boolean().default(false),
|
|
81
81
|
early_exit_threshold: z.number().default(0.95),
|
|
82
|
+
context_pruning_enabled: z.boolean().default(true),
|
|
83
|
+
context_pruning_diff_lines: z.number().default(5),
|
|
84
|
+
semantic_cache_enabled: z.boolean().default(false),
|
|
85
|
+
semantic_cache_ttl_hours: z.number().default(168),
|
|
82
86
|
})
|
|
83
87
|
|
|
84
88
|
export const Config = z.object({
|
|
@@ -104,6 +108,10 @@ export const Config = z.object({
|
|
|
104
108
|
local_preflight_check: z.boolean().default(true).volatile(),
|
|
105
109
|
early_exit_consensus_enabled: z.boolean().default(false).volatile(),
|
|
106
110
|
early_exit_threshold: z.number().default(0.95).volatile(),
|
|
111
|
+
context_pruning_enabled: z.boolean().default(true).volatile(),
|
|
112
|
+
context_pruning_diff_lines: z.number().default(5).volatile(),
|
|
113
|
+
semantic_cache_enabled: z.boolean().default(false).volatile(),
|
|
114
|
+
semantic_cache_ttl_hours: z.number().default(168).volatile(),
|
|
107
115
|
smart_routing_model: ModelSlotSchema.default({ provider: '', model: '' }).volatile(),
|
|
108
116
|
prices: z.dict(PriceRow).default({}).volatile(),
|
|
109
117
|
presets: z.array(PresetSchema).default([
|
|
@@ -120,6 +128,10 @@ export const Config = z.object({
|
|
|
120
128
|
aggregator_temperature: 0.4,
|
|
121
129
|
max_tokens: 4096,
|
|
122
130
|
judge_criteria: '',
|
|
131
|
+
context_pruning_enabled: true,
|
|
132
|
+
context_pruning_diff_lines: 5,
|
|
133
|
+
semantic_cache_enabled: false,
|
|
134
|
+
semantic_cache_ttl_hours: 168,
|
|
123
135
|
},
|
|
124
136
|
{
|
|
125
137
|
name: 'fast',
|
|
@@ -133,6 +145,10 @@ export const Config = z.object({
|
|
|
133
145
|
aggregator_temperature: 0.2,
|
|
134
146
|
max_tokens: 4096,
|
|
135
147
|
judge_criteria: '',
|
|
148
|
+
context_pruning_enabled: true,
|
|
149
|
+
context_pruning_diff_lines: 5,
|
|
150
|
+
semantic_cache_enabled: false,
|
|
151
|
+
semantic_cache_ttl_hours: 168,
|
|
136
152
|
},
|
|
137
153
|
{
|
|
138
154
|
name: 'deep-reasoning',
|
|
@@ -165,4 +181,4 @@ export function plainConfig(value) {
|
|
|
165
181
|
}
|
|
166
182
|
return value
|
|
167
183
|
}
|
|
168
|
-
|
|
184
|
+
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createCachingCallLlm } from './semantic-cache.js'
|
|
1
2
|
import { runSpeculativeMoa } from '../moa-speculative.js'
|
|
2
3
|
import { runAdversarialTddLoop } from '../moa-adversarial-tdd.js'
|
|
3
4
|
import { assignAdaptivePersonas } from '../moa-adaptive-personas.js'
|
|
@@ -33,7 +34,19 @@ export async function stageCandidateRound1({
|
|
|
33
34
|
testGateEnabled,
|
|
34
35
|
testCommand,
|
|
35
36
|
execFn,
|
|
37
|
+
semanticCacheEnabled,
|
|
38
|
+
semanticCacheTtlHours,
|
|
39
|
+
ctx = null,
|
|
36
40
|
}) {
|
|
41
|
+
const isCacheEnabled = Boolean(preset?.semantic_cache_enabled || semanticCacheEnabled)
|
|
42
|
+
const effectiveCallLlm = isCacheEnabled
|
|
43
|
+
? createCachingCallLlm(callLlm, {
|
|
44
|
+
enabled: true,
|
|
45
|
+
ttlMs: (semanticCacheTtlHours || preset?.semantic_cache_ttl_hours || 168) * 60 * 60 * 1000,
|
|
46
|
+
cwd,
|
|
47
|
+
ctx,
|
|
48
|
+
})
|
|
49
|
+
: callLlm
|
|
37
50
|
const lastMsgText = enrichedMessages[enrichedMessages.length - 1]?.content || ''
|
|
38
51
|
const adaptiveRefs = preset?.adaptive_personas_enabled
|
|
39
52
|
? assignAdaptivePersonas(effectiveReferences, typeof lastMsgText === 'string' ? lastMsgText : '').references
|
|
@@ -52,7 +65,7 @@ export async function stageCandidateRound1({
|
|
|
52
65
|
prices,
|
|
53
66
|
signal,
|
|
54
67
|
},
|
|
55
|
-
|
|
68
|
+
effectiveCallLlm,
|
|
56
69
|
onProgress
|
|
57
70
|
)
|
|
58
71
|
if (!specRes.fallbackToStandard && Array.isArray(specRes.referenceOutputs)) {
|
|
@@ -62,7 +75,7 @@ export async function stageCandidateRound1({
|
|
|
62
75
|
|
|
63
76
|
if (!referenceOutputs) {
|
|
64
77
|
referenceOutputs = preset?.subagent_swarm_enabled
|
|
65
|
-
? await runSubagentSwarm(adaptiveRefs, enrichedMessages, { temperature: refTemp, maxTokens, signal, cwd, runId },
|
|
78
|
+
? await runSubagentSwarm(adaptiveRefs, enrichedMessages, { temperature: refTemp, maxTokens, signal, cwd, runId }, effectiveCallLlm, onProgress)
|
|
66
79
|
: await runReferencesParallel(
|
|
67
80
|
adaptiveRefs,
|
|
68
81
|
enrichedMessages,
|
|
@@ -80,7 +93,7 @@ export async function stageCandidateRound1({
|
|
|
80
93
|
maxRetries: candidateRetries,
|
|
81
94
|
signal,
|
|
82
95
|
},
|
|
83
|
-
|
|
96
|
+
effectiveCallLlm,
|
|
84
97
|
onProgress
|
|
85
98
|
)
|
|
86
99
|
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-Candidate Unchanged Base Context Pruning (#305)
|
|
3
|
+
*
|
|
4
|
+
* Replaces large, mostly-unchanged candidate file blocks with compact unified diffs
|
|
5
|
+
* containing only modified hunks and a configurable number of context lines.
|
|
6
|
+
* This drastically cuts Judge prompt token overhead (often by 70-85%) on large files
|
|
7
|
+
* while preserving full context for winner promotion.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { computeLineDiff } from '../workspace/diff.js'
|
|
11
|
+
import { isSafeRelativePath } from '../workspace/security.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extracts code blocks along with their raw Markdown text from candidate output.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} text
|
|
17
|
+
* @returns {Array<{ relativePath: string, content: string, rawBlock: string }>}
|
|
18
|
+
*/
|
|
19
|
+
export function extractCandidateFileBlocks(text) {
|
|
20
|
+
if (!text || typeof text !== 'string') return []
|
|
21
|
+
const blocks = []
|
|
22
|
+
const seenPaths = new Set()
|
|
23
|
+
|
|
24
|
+
const patterns = [
|
|
25
|
+
/```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi,
|
|
26
|
+
/(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
|
|
27
|
+
/#{1,4}\s+(?:File|Path):\s*([^\s\n\r]+)[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
|
|
28
|
+
/```[\w-]*[\r\n]+(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+([\s\S]*?)```/gi,
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
for (const regex of patterns) {
|
|
32
|
+
let match
|
|
33
|
+
while ((match = regex.exec(text)) !== null) {
|
|
34
|
+
const rawPath = match[1].trim()
|
|
35
|
+
if (rawPath && isSafeRelativePath(rawPath) && !seenPaths.has(rawPath)) {
|
|
36
|
+
seenPaths.add(rawPath)
|
|
37
|
+
blocks.push({
|
|
38
|
+
relativePath: rawPath,
|
|
39
|
+
content: match[2].trimEnd() + '\n',
|
|
40
|
+
rawBlock: match[0],
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return blocks
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generates unified diff hunks from line diff items with a specified context window.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} oldText - Original baseline file content
|
|
54
|
+
* @param {string} newText - Candidate's proposed file content
|
|
55
|
+
* @param {object} [options]
|
|
56
|
+
* @param {number} [options.contextLines=5] - Unchanged lines before and after changes
|
|
57
|
+
* @param {string} [options.filePath='file'] - Relative file path for header
|
|
58
|
+
* @returns {string} Standard unified diff representation
|
|
59
|
+
*/
|
|
60
|
+
export function generateUnifiedDiff(oldText = '', newText = '', options = {}) {
|
|
61
|
+
const { contextLines = 5, filePath = 'file' } = options
|
|
62
|
+
const trimmedOld = String(oldText || '').replace(/[\r\n]+$/, '')
|
|
63
|
+
const trimmedNew = String(newText || '').replace(/[\r\n]+$/, '')
|
|
64
|
+
if (trimmedOld === trimmedNew) return ''
|
|
65
|
+
|
|
66
|
+
const diffItems = computeLineDiff(trimmedOld, trimmedNew)
|
|
67
|
+
if (!diffItems || diffItems.length === 0) return ''
|
|
68
|
+
|
|
69
|
+
// Group diff items into hunks with contextLines
|
|
70
|
+
const changeIndices = []
|
|
71
|
+
for (let i = 0; i < diffItems.length; i++) {
|
|
72
|
+
if (diffItems[i].type !== 'same') {
|
|
73
|
+
changeIndices.push(i)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (changeIndices.length === 0) return ''
|
|
78
|
+
|
|
79
|
+
// Cluster change ranges that are closer than 2 * contextLines
|
|
80
|
+
const clusters = []
|
|
81
|
+
let currentCluster = {
|
|
82
|
+
start: Math.max(0, changeIndices[0] - contextLines),
|
|
83
|
+
end: Math.min(diffItems.length - 1, changeIndices[0] + contextLines),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (let k = 1; k < changeIndices.length; k++) {
|
|
87
|
+
const idx = changeIndices[k]
|
|
88
|
+
const nextStart = Math.max(0, idx - contextLines)
|
|
89
|
+
const nextEnd = Math.min(diffItems.length - 1, idx + contextLines)
|
|
90
|
+
|
|
91
|
+
if (nextStart <= currentCluster.end + 1) {
|
|
92
|
+
currentCluster.end = Math.max(currentCluster.end, nextEnd)
|
|
93
|
+
} else {
|
|
94
|
+
clusters.push(currentCluster)
|
|
95
|
+
currentCluster = { start: nextStart, end: nextEnd }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
clusters.push(currentCluster)
|
|
99
|
+
|
|
100
|
+
const hunkOutputs = []
|
|
101
|
+
for (const cluster of clusters) {
|
|
102
|
+
const slice = diffItems.slice(cluster.start, cluster.end + 1)
|
|
103
|
+
let oldStart = null
|
|
104
|
+
let newStart = null
|
|
105
|
+
let oldCount = 0
|
|
106
|
+
let newCount = 0
|
|
107
|
+
const hunkLines = []
|
|
108
|
+
|
|
109
|
+
for (const item of slice) {
|
|
110
|
+
if (item.type === 'same') {
|
|
111
|
+
if (oldStart === null && item.oldNum !== undefined) oldStart = item.oldNum
|
|
112
|
+
if (newStart === null && item.newNum !== undefined) newStart = item.newNum
|
|
113
|
+
oldCount++
|
|
114
|
+
newCount++
|
|
115
|
+
hunkLines.push(` ${item.line}`)
|
|
116
|
+
} else if (item.type === 'removed') {
|
|
117
|
+
if (oldStart === null && item.oldNum !== undefined) oldStart = item.oldNum
|
|
118
|
+
oldCount++
|
|
119
|
+
hunkLines.push(`-${item.line}`)
|
|
120
|
+
} else if (item.type === 'added') {
|
|
121
|
+
if (newStart === null && item.newNum !== undefined) newStart = item.newNum
|
|
122
|
+
newCount++
|
|
123
|
+
hunkLines.push(`+${item.line}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const header = `@@ -${oldStart || 1},${oldCount} +${newStart || 1},${newCount} @@`
|
|
128
|
+
hunkOutputs.push([header, ...hunkLines].join('\n'))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const prefix = `--- a/${filePath}\n+++ b/${filePath}`
|
|
132
|
+
return `${prefix}\n${hunkOutputs.join('\n')}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Checks whether a candidate's file output qualifies for context pruning.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} originalText - Original file content
|
|
139
|
+
* @param {string} candidateText - Proposed file content
|
|
140
|
+
* @param {object} [options]
|
|
141
|
+
* @param {number} [options.minLines=25] - Minimum file length to consider pruning
|
|
142
|
+
* @param {number} [options.maxChangeRatio=0.65] - Maximum proportion of changed lines
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
export function shouldPruneFile(originalText, candidateText, options = {}) {
|
|
146
|
+
const { minLines = 25, maxChangeRatio = 0.65 } = options
|
|
147
|
+
if (typeof originalText !== 'string' || typeof candidateText !== 'string') return false
|
|
148
|
+
const trimmedOld = originalText.replace(/[\r\n]+$/, '')
|
|
149
|
+
const trimmedNew = candidateText.replace(/[\r\n]+$/, '')
|
|
150
|
+
if (trimmedOld === trimmedNew) return false
|
|
151
|
+
|
|
152
|
+
const oldLines = trimmedOld.split(/\r?\n/)
|
|
153
|
+
if (oldLines.length < minLines) return false
|
|
154
|
+
|
|
155
|
+
const diffItems = computeLineDiff(trimmedOld, trimmedNew)
|
|
156
|
+
let changed = 0
|
|
157
|
+
for (const item of diffItems) {
|
|
158
|
+
if (item.type !== 'same') changed++
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const ratio = changed / Math.max(1, diffItems.length)
|
|
162
|
+
return ratio > 0 && ratio <= maxChangeRatio
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Prunes unmodified base context from a single candidate's Markdown output.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} candidateText - Raw candidate text containing file blocks
|
|
169
|
+
* @param {Map<string, string>|Record<string, string>} baseFiles - Map of relative paths to baseline file contents
|
|
170
|
+
* @param {object} [options]
|
|
171
|
+
* @param {number} [options.contextLines=5]
|
|
172
|
+
* @param {number} [options.minLines=25]
|
|
173
|
+
* @returns {{ text: string, prunedFiles: string[], savedChars: number, originalChars: number }}
|
|
174
|
+
*/
|
|
175
|
+
export function pruneCandidateOutput(candidateText = '', baseFiles = {}, options = {}) {
|
|
176
|
+
if (typeof candidateText !== 'string' || !candidateText.trim()) {
|
|
177
|
+
return { text: candidateText, prunedFiles: [], savedChars: 0, originalChars: 0 }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const { contextLines = 5, minLines = 25 } = options
|
|
181
|
+
const baseMap = baseFiles instanceof Map
|
|
182
|
+
? baseFiles
|
|
183
|
+
: new Map(Object.entries(baseFiles || {}))
|
|
184
|
+
|
|
185
|
+
if (baseMap.size === 0) {
|
|
186
|
+
return { text: candidateText, prunedFiles: [], savedChars: 0, originalChars: 0 }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const fileBlocks = extractCandidateFileBlocks(candidateText)
|
|
190
|
+
if (!Array.isArray(fileBlocks) || fileBlocks.length === 0) {
|
|
191
|
+
return { text: candidateText, prunedFiles: [], savedChars: 0, originalChars: 0 }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let prunedText = candidateText
|
|
195
|
+
const prunedFiles = []
|
|
196
|
+
let savedChars = 0
|
|
197
|
+
|
|
198
|
+
for (const block of fileBlocks) {
|
|
199
|
+
const relPath = block.relativePath || block.path
|
|
200
|
+
if (!relPath || !baseMap.has(relPath)) continue
|
|
201
|
+
|
|
202
|
+
const baseContent = baseMap.get(relPath)
|
|
203
|
+
if (!shouldPruneFile(baseContent, block.content, { minLines })) continue
|
|
204
|
+
|
|
205
|
+
const unifiedDiff = generateUnifiedDiff(baseContent, block.content, {
|
|
206
|
+
contextLines,
|
|
207
|
+
filePath: relPath,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
if (!unifiedDiff) continue
|
|
211
|
+
|
|
212
|
+
const originalBlockText = block.rawBlock || block.block || `\`\`\`${block.language || ''}\n${block.content}\n\`\`\``
|
|
213
|
+
const replacementBlock = `\`\`\`diff\n// Unified diff patch for ${relPath} (context pruned)\n${unifiedDiff}\n\`\`\``
|
|
214
|
+
|
|
215
|
+
if (replacementBlock.length < originalBlockText.length) {
|
|
216
|
+
prunedText = prunedText.replace(originalBlockText, replacementBlock)
|
|
217
|
+
savedChars += (originalBlockText.length - replacementBlock.length)
|
|
218
|
+
prunedFiles.push(relPath)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
text: prunedText,
|
|
224
|
+
prunedFiles,
|
|
225
|
+
savedChars,
|
|
226
|
+
originalChars: candidateText.length,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Prunes unmodified context across all successful candidate outputs.
|
|
232
|
+
*
|
|
233
|
+
* @param {Array<object>} candidates - List of candidate response objects
|
|
234
|
+
* @param {Array<object>|Map<string, string>|Record<string, string>} projectFiles - Project context files
|
|
235
|
+
* @param {object} [options]
|
|
236
|
+
* @param {boolean} [options.enabled=true]
|
|
237
|
+
* @param {number} [options.contextLines=5]
|
|
238
|
+
* @returns {{ candidates: Array<object>, totalSavedChars: number, totalSavedTokens: number, prunedFiles: string[], isPruned: boolean }}
|
|
239
|
+
*/
|
|
240
|
+
export function pruneCandidateEnsemble(candidates = [], projectFiles = [], options = {}) {
|
|
241
|
+
const { enabled = true, contextLines = 5, minLines = 25 } = options
|
|
242
|
+
if (!enabled || !Array.isArray(candidates) || candidates.length === 0) {
|
|
243
|
+
return {
|
|
244
|
+
candidates,
|
|
245
|
+
totalSavedChars: 0,
|
|
246
|
+
totalSavedTokens: 0,
|
|
247
|
+
prunedFiles: [],
|
|
248
|
+
isPruned: false,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Build base files map
|
|
253
|
+
const baseMap = new Map()
|
|
254
|
+
if (Array.isArray(projectFiles)) {
|
|
255
|
+
for (const f of projectFiles) {
|
|
256
|
+
if (f && f.path && typeof f.content === 'string') {
|
|
257
|
+
baseMap.set(f.path, f.content)
|
|
258
|
+
} else if (f && f.relativePath && typeof f.content === 'string') {
|
|
259
|
+
baseMap.set(f.relativePath, f.content)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} else if (projectFiles instanceof Map) {
|
|
263
|
+
for (const [k, v] of projectFiles.entries()) baseMap.set(k, v)
|
|
264
|
+
} else if (projectFiles && typeof projectFiles === 'object') {
|
|
265
|
+
for (const [k, v] of Object.entries(projectFiles)) baseMap.set(k, v)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (baseMap.size === 0) {
|
|
269
|
+
return {
|
|
270
|
+
candidates,
|
|
271
|
+
totalSavedChars: 0,
|
|
272
|
+
totalSavedTokens: 0,
|
|
273
|
+
prunedFiles: [],
|
|
274
|
+
isPruned: false,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let totalSavedChars = 0
|
|
279
|
+
const allPrunedFiles = new Set()
|
|
280
|
+
|
|
281
|
+
const prunedCandidates = candidates.map((cand) => {
|
|
282
|
+
if (!cand || typeof cand.text !== 'string') return cand
|
|
283
|
+
const result = pruneCandidateOutput(cand.text, baseMap, { contextLines, minLines })
|
|
284
|
+
if (result.savedChars > 0) {
|
|
285
|
+
totalSavedChars += result.savedChars
|
|
286
|
+
result.prunedFiles.forEach((f) => allPrunedFiles.add(f))
|
|
287
|
+
return {
|
|
288
|
+
...cand,
|
|
289
|
+
text: result.text,
|
|
290
|
+
prunedStats: {
|
|
291
|
+
savedChars: result.savedChars,
|
|
292
|
+
prunedFiles: result.prunedFiles,
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return cand
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const totalSavedTokens = Math.round(totalSavedChars / 4)
|
|
300
|
+
return {
|
|
301
|
+
candidates: prunedCandidates,
|
|
302
|
+
totalSavedChars,
|
|
303
|
+
totalSavedTokens,
|
|
304
|
+
prunedFiles: Array.from(allPrunedFiles),
|
|
305
|
+
isPruned: totalSavedChars > 0,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Emits a structured instruction directive for the Judge when context pruning was applied.
|
|
311
|
+
*
|
|
312
|
+
* @param {Array<string>} [prunedFiles=[]] - List of pruned file paths
|
|
313
|
+
* @param {number} [savedTokens=0] - Estimated token count saved
|
|
314
|
+
* @returns {string}
|
|
315
|
+
*/
|
|
316
|
+
export function formatPruningDirective(prunedFiles = [], savedTokens = 0) {
|
|
317
|
+
if (!Array.isArray(prunedFiles) || prunedFiles.length === 0) return ''
|
|
318
|
+
const fileList = prunedFiles.map((f) => `\`${f}\``).join(', ')
|
|
319
|
+
return `### Context Pruning Notice
|
|
320
|
+
To minimize token bloat and accelerate evaluation, large baseline files with localized modifications (${fileList}) have been pruned into standard unified diff patches (---/+++ with context hunks).
|
|
321
|
+
Evaluate the precision, correctness, and edge-case safety of the added and removed lines without penalizing candidates for using diff format.`
|
|
322
|
+
}
|
|
323
|
+
|
package/lib/pipeline/context.js
CHANGED
|
@@ -41,6 +41,10 @@ export async function stageProjectContext({
|
|
|
41
41
|
const localPreflightCheck = preset?.local_preflight_check !== false
|
|
42
42
|
const earlyExitConsensusEnabled = Boolean(preset?.early_exit_consensus_enabled)
|
|
43
43
|
const earlyExitThreshold = typeof preset?.early_exit_threshold === 'number' ? preset.early_exit_threshold : 0.95
|
|
44
|
+
const contextPruningEnabled = preset?.context_pruning_enabled !== false
|
|
45
|
+
const contextPruningDiffLines = typeof preset?.context_pruning_diff_lines === 'number' ? preset.context_pruning_diff_lines : 5
|
|
46
|
+
const semanticCacheEnabled = Boolean(preset?.semantic_cache_enabled)
|
|
47
|
+
const semanticCacheTtlHours = typeof preset?.semantic_cache_ttl_hours === 'number' ? preset.semantic_cache_ttl_hours : 168
|
|
44
48
|
|
|
45
49
|
const collectedCtx = await collectProjectContext(cwd, 16000)
|
|
46
50
|
const isRefinement = Boolean(collectedCtx?.files?.length > 0 && isRefinementTask(userPrompt, collectedCtx.files))
|
|
@@ -127,6 +131,8 @@ export async function stageProjectContext({
|
|
|
127
131
|
effectiveReferences, quotaSubstitutions, priorTurnBaseline, candidateSystemPrompt,
|
|
128
132
|
needsQuestions, enrichedMessages,
|
|
129
133
|
localPreflightCheck, earlyExitConsensusEnabled, earlyExitThreshold,
|
|
134
|
+
contextPruningEnabled, contextPruningDiffLines,
|
|
135
|
+
semanticCacheEnabled, semanticCacheTtlHours,
|
|
130
136
|
}
|
|
131
137
|
}
|
|
132
|
-
|
|
138
|
+
|
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'
|
|
@@ -65,6 +66,9 @@ export async function runMoAPipeline({
|
|
|
65
66
|
isFastMode: ctxStage.isFastMode,
|
|
66
67
|
collectedCtx: ctxStage.collectedCtx,
|
|
67
68
|
testGateEnabled, testCommand, execFn,
|
|
69
|
+
semanticCacheEnabled: ctxStage.semanticCacheEnabled,
|
|
70
|
+
semanticCacheTtlHours: ctxStage.semanticCacheTtlHours,
|
|
71
|
+
ctx,
|
|
68
72
|
})
|
|
69
73
|
if (!candStage.ok) {
|
|
70
74
|
if (candStage.abort) return { error: candStage.error }
|
|
@@ -218,12 +222,44 @@ export async function runMoAPipeline({
|
|
|
218
222
|
const rawFileDirective = (preset?.interactive_workbench_enabled)
|
|
219
223
|
? formatFileScoresDirective()
|
|
220
224
|
: null
|
|
221
|
-
|
|
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
|
|
222
257
|
|
|
223
258
|
// Stage 7: Synthesis Phase
|
|
224
259
|
const synthRes = await stageSynthesis({
|
|
225
|
-
userPrompt,
|
|
226
|
-
|
|
260
|
+
userPrompt,
|
|
261
|
+
referenceOutputs: synthReferenceOutputs,
|
|
262
|
+
successfulRefs: synthCandidateRefs,
|
|
227
263
|
judgeCriteria: ctxStage.judgeCriteria,
|
|
228
264
|
isCuratorSynthesis: ctxStage.isCuratorSynthesis, isBlindEvaluation: ctxStage.isBlindEvaluation,
|
|
229
265
|
priorTurnBaseline: ctxStage.priorTurnBaseline, compositeMergeDirective, fileScoringDirective,
|
|
@@ -260,4 +296,4 @@ export async function runMoAPipeline({
|
|
|
260
296
|
}
|
|
261
297
|
return promRes.payload
|
|
262
298
|
}
|
|
263
|
-
|
|
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
|
+
}
|
|
@@ -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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-moa",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.73",
|
|
4
4
|
"description": "Mixture of Agents (MoA) plugin for DeepSeek Harness with /moa slash command",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -72,4 +72,3 @@
|
|
|
72
72
|
"react-dom": "^18.3.1"
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
-
|