@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 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
- callLlm,
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 }, callLlm, onProgress)
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
- callLlm,
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
+
@@ -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
+
@@ -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
- const fileScoringDirective = [rawFileDirective, visualResult?.directive, adversarialTddResult?.directive, syntaxDirective].filter(Boolean).join("\n\n") || null
225
+
226
+ // Pre-Judge Stage 6.8: Cross-Candidate Unchanged Base Context Pruning (#305)
227
+ let synthCandidateRefs = syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs
228
+ let synthReferenceOutputs = referenceOutputs
229
+ let pruningDirective = null
230
+ if (ctxStage.contextPruningEnabled && ctxStage.collectedCtx?.files?.length > 0) {
231
+ const pruneRes = pruneCandidateEnsemble(synthCandidateRefs, ctxStage.collectedCtx.files, {
232
+ enabled: true,
233
+ contextLines: ctxStage.contextPruningDiffLines,
234
+ })
235
+ if (pruneRes.isPruned) {
236
+ synthCandidateRefs = pruneRes.candidates
237
+ const prunedByLabel = new Map(synthCandidateRefs.map((c) => [c.label, c.text]))
238
+ synthReferenceOutputs = referenceOutputs.map((r) => {
239
+ if (prunedByLabel.has(r.label)) {
240
+ return { ...r, text: prunedByLabel.get(r.label) }
241
+ }
242
+ return r
243
+ })
244
+ pruningDirective = formatPruningDirective(pruneRes.prunedFiles, pruneRes.totalSavedTokens)
245
+ emitEvent(ctx, MOA_EVENTS.CONTEXT_PRUNING, {
246
+ runId,
247
+ savedTokens: pruneRes.totalSavedTokens,
248
+ prunedFiles: pruneRes.prunedFiles,
249
+ })
250
+ if (typeof onProgress === 'function') {
251
+ onProgress(`✂️ *Context Pruning: Reduced ~${pruneRes.totalSavedTokens} prompt tokens across ${pruneRes.prunedFiles.length} file block(s)*\n`)
252
+ }
253
+ }
254
+ }
255
+
256
+ const fileScoringDirective = [rawFileDirective, visualResult?.directive, adversarialTddResult?.directive, syntaxDirective, pruningDirective].filter(Boolean).join("\n\n") || null
222
257
 
223
258
  // Stage 7: Synthesis Phase
224
259
  const synthRes = await stageSynthesis({
225
- userPrompt, referenceOutputs,
226
- successfulRefs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
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.72",
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
-