@goodandready/dsh-moa 0.2.20 → 0.2.22
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/lib/client.js +14 -0
- package/lib/file-workspace.js +175 -153
- package/lib/history.js +119 -10
- package/lib/index.js +79 -19
- package/lib/moa-candidates.js +6 -1
- package/lib/routes.js +58 -33
- package/package.json +6 -1
package/lib/client.js
CHANGED
|
@@ -1954,6 +1954,20 @@ window.__ModuleLoader__.load({
|
|
|
1954
1954
|
reload()
|
|
1955
1955
|
}, [reload])
|
|
1956
1956
|
|
|
1957
|
+
React.useEffect(() => {
|
|
1958
|
+
const lbUrl = leaderboardPresetFilter !== 'all'
|
|
1959
|
+
? `/dsh-moa/leaderboard?preset=${encodeURIComponent(leaderboardPresetFilter)}`
|
|
1960
|
+
: '/dsh-moa/leaderboard'
|
|
1961
|
+
fetch(lbUrl, { cache: 'no-store' })
|
|
1962
|
+
.then((r) => r.json())
|
|
1963
|
+
.then((data) => {
|
|
1964
|
+
if (data && data.ok && Array.isArray(data.models)) {
|
|
1965
|
+
setLeaderboard(data.models.slice(0, 10))
|
|
1966
|
+
}
|
|
1967
|
+
})
|
|
1968
|
+
.catch(() => {})
|
|
1969
|
+
}, [leaderboardPresetFilter])
|
|
1970
|
+
|
|
1957
1971
|
const handleSave = () => {
|
|
1958
1972
|
setSaveStatus(t('actions.saving'))
|
|
1959
1973
|
|
package/lib/file-workspace.js
CHANGED
|
@@ -3,15 +3,31 @@ import vm from 'node:vm'
|
|
|
3
3
|
import fs from 'node:fs/promises'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Validates that an untrusted relative path is safe and does not traverse upwards.
|
|
8
|
+
*/
|
|
9
|
+
export function isSafeRelativePath(relPath) {
|
|
10
|
+
if (!relPath || typeof relPath !== 'string') return false
|
|
11
|
+
const trimmed = relPath.trim()
|
|
12
|
+
if (!trimmed || path.isAbsolute(trimmed) || trimmed.startsWith('/') || trimmed.startsWith('\\')) return false
|
|
13
|
+
const normalized = path.normalize(trimmed)
|
|
14
|
+
return Boolean(normalized && !normalized.startsWith('..') && !path.isAbsolute(normalized))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Asserts that a resolved path is strictly contained within baseDir.
|
|
19
|
+
*/
|
|
20
|
+
export function assertPathContained(baseDir, subPath) {
|
|
21
|
+
const resolvedBase = path.resolve(baseDir)
|
|
22
|
+
const resolvedTarget = path.resolve(resolvedBase, subPath)
|
|
23
|
+
if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(resolvedBase + path.sep)) {
|
|
24
|
+
throw new Error(`Path traversal violation: "${subPath}" escapes base directory "${baseDir}"`)
|
|
25
|
+
}
|
|
26
|
+
return resolvedTarget
|
|
27
|
+
}
|
|
28
|
+
|
|
6
29
|
/**
|
|
7
30
|
* Parses markdown code blocks that specify file paths.
|
|
8
|
-
* Supports patterns like:
|
|
9
|
-
* ```html file="index.html"
|
|
10
|
-
* ```javascript filepath=script.js
|
|
11
|
-
* // File: index.html
|
|
12
|
-
* <!-- File: styles.css -->
|
|
13
|
-
* ### File: index.html
|
|
14
|
-
* ```html
|
|
15
31
|
*/
|
|
16
32
|
export function extractFileBlocks(text) {
|
|
17
33
|
if (!text || typeof text !== 'string') return []
|
|
@@ -19,81 +35,48 @@ export function extractFileBlocks(text) {
|
|
|
19
35
|
const files = []
|
|
20
36
|
const seenPaths = new Set()
|
|
21
37
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const fenceRegex = /```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi
|
|
25
|
-
let match
|
|
26
|
-
while ((match = fenceRegex.exec(text)) !== null) {
|
|
27
|
-
const rawPath = match[1].trim()
|
|
28
|
-
const content = match[2]
|
|
29
|
-
if (rawPath && !seenPaths.has(rawPath)) {
|
|
38
|
+
const addFile = (rawPath, fileContent) => {
|
|
39
|
+
if (rawPath && isSafeRelativePath(rawPath) && !seenPaths.has(rawPath)) {
|
|
30
40
|
seenPaths.add(rawPath)
|
|
31
|
-
files.push({ relativePath:
|
|
41
|
+
files.push({ relativePath: rawPath, content: fileContent.trimEnd() + '\n' })
|
|
32
42
|
}
|
|
33
43
|
}
|
|
34
44
|
|
|
35
|
-
// Pattern
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
while ((match =
|
|
39
|
-
const rawPath = match[1].trim()
|
|
40
|
-
const content = match[2]
|
|
41
|
-
if (rawPath && !seenPaths.has(rawPath)) {
|
|
42
|
-
seenPaths.add(rawPath)
|
|
43
|
-
files.push({ relativePath: sanitizePath(rawPath), content })
|
|
44
|
-
}
|
|
45
|
-
}
|
|
45
|
+
// Pattern 1: fenced blocks with file="..." or path="..." in info string
|
|
46
|
+
const fenceRegex = /```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi
|
|
47
|
+
let match
|
|
48
|
+
while ((match = fenceRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
|
|
46
49
|
|
|
47
|
-
// Pattern
|
|
48
|
-
const commentRegex =
|
|
49
|
-
while ((match = commentRegex.exec(text)) !== null)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
files.push({ relativePath: sanitizePath(rawPath), content })
|
|
55
|
-
}
|
|
56
|
-
}
|
|
50
|
+
// Pattern 2: comment header: // File: path/to/file.ext or <!-- File: ... -->
|
|
51
|
+
const commentRegex = /(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi
|
|
52
|
+
while ((match = commentRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
|
|
53
|
+
|
|
54
|
+
// Pattern 3: markdown header: ### File: path/to/file.ext
|
|
55
|
+
const headerRegex = /#{1,4}\s+(?:File|Path):\s*([^\s\n\r]+)[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi
|
|
56
|
+
while ((match = headerRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
|
|
57
57
|
|
|
58
|
-
//
|
|
58
|
+
// Pattern 4: first line inside code block: // File: path/to/file.ext
|
|
59
|
+
const inlineRegex = /```[\w-]*[\r\n]+(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+([\s\S]*?)```/gi
|
|
60
|
+
while ((match = inlineRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
|
|
61
|
+
|
|
62
|
+
// Fallback: single anonymous code block if it contains complete HTML
|
|
59
63
|
if (files.length === 0) {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
while ((
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
html: 'index.html',
|
|
68
|
-
javascript: 'index.js',
|
|
69
|
-
js: 'index.js',
|
|
70
|
-
css: 'style.css',
|
|
71
|
-
python: 'main.py',
|
|
72
|
-
py: 'main.py',
|
|
73
|
-
json: 'data.json',
|
|
74
|
-
bash: 'run.sh',
|
|
75
|
-
sh: 'run.sh',
|
|
64
|
+
const htmlBlockRegex = /```(?:html)?[\r\n]+([\s\S]*?)```/gi
|
|
65
|
+
let htmlMatch
|
|
66
|
+
while ((htmlMatch = htmlBlockRegex.exec(text)) !== null) {
|
|
67
|
+
const candidateHtml = htmlMatch[1]
|
|
68
|
+
if (/<(!DOCTYPE\s+)?html/i.test(candidateHtml) || /<body/i.test(candidateHtml)) {
|
|
69
|
+
files.push({ relativePath: 'index.html', content: candidateHtml.trimEnd() + '\n' })
|
|
70
|
+
break
|
|
76
71
|
}
|
|
77
|
-
const defaultName = extMap[singleMatches[0].lang] || 'output.txt'
|
|
78
|
-
files.push({ relativePath: defaultName, content: singleMatches[0].content })
|
|
79
72
|
}
|
|
80
73
|
}
|
|
81
74
|
|
|
82
75
|
return files
|
|
83
76
|
}
|
|
84
77
|
|
|
85
|
-
function sanitizePath(filePath) {
|
|
86
|
-
const normalized = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '')
|
|
87
|
-
return normalized.replace(/^[\\\/]+/, '')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Collects readable text files from current workspace to feed as context for candidate models (#6).
|
|
92
|
-
* Ignores node_modules, .git, .moa, binary files, large bundles.
|
|
93
|
-
*/
|
|
94
78
|
/**
|
|
95
|
-
* Formats collected project files into structured
|
|
96
|
-
* suitable for inclusion in candidate system prompts.
|
|
79
|
+
* Formats collected project files into a structured markdown context string.
|
|
97
80
|
*/
|
|
98
81
|
export function formatProjectContext(projectFiles = [], options = {}) {
|
|
99
82
|
if (!Array.isArray(projectFiles) || projectFiles.length === 0) return ''
|
|
@@ -123,7 +106,6 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
|
|
|
123
106
|
|
|
124
107
|
const collected = []
|
|
125
108
|
let totalChars = 0
|
|
126
|
-
|
|
127
109
|
let skippedFiles = 0
|
|
128
110
|
const skippedList = []
|
|
129
111
|
|
|
@@ -132,17 +114,14 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
|
|
|
132
114
|
let list
|
|
133
115
|
try {
|
|
134
116
|
list = await fs.readdir(currentDir, { withFileTypes: true })
|
|
135
|
-
} catch
|
|
117
|
+
} catch {
|
|
136
118
|
skippedFiles++
|
|
137
119
|
skippedList.push(relPrefix || '.')
|
|
138
120
|
return
|
|
139
121
|
}
|
|
140
122
|
|
|
141
123
|
for (const item of list) {
|
|
142
|
-
// Skip all dotfiles, including .env and .env.* variants: their
|
|
143
|
-
// contents must never be shipped to third-party model providers.
|
|
144
124
|
if (item.name.startsWith('.')) continue
|
|
145
|
-
|
|
146
125
|
const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
|
|
147
126
|
const full = path.join(currentDir, item.name)
|
|
148
127
|
|
|
@@ -155,9 +134,7 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
|
|
|
155
134
|
}
|
|
156
135
|
|
|
157
136
|
if (item.isDirectory()) {
|
|
158
|
-
if (!ignoreDirs.has(item.name))
|
|
159
|
-
await scan(full, rel)
|
|
160
|
-
}
|
|
137
|
+
if (!ignoreDirs.has(item.name)) await scan(full, rel)
|
|
161
138
|
} else if (item.isFile()) {
|
|
162
139
|
const ext = path.extname(item.name).toLowerCase()
|
|
163
140
|
if (codeExts.has(ext)) {
|
|
@@ -173,7 +150,7 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
|
|
|
173
150
|
const truncated = content.length > budgetRemaining ? content.slice(0, budgetRemaining) + '\n... [truncated]' : content
|
|
174
151
|
totalChars += truncated.length
|
|
175
152
|
collected.push({ relativePath: rel, content: truncated, size: stat.size })
|
|
176
|
-
} catch
|
|
153
|
+
} catch {
|
|
177
154
|
skippedFiles++
|
|
178
155
|
skippedList.push(rel)
|
|
179
156
|
}
|
|
@@ -187,8 +164,7 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
|
|
|
187
164
|
}
|
|
188
165
|
|
|
189
166
|
/**
|
|
190
|
-
* Checks if a task prompt is an iterative refinement / modification
|
|
191
|
-
* of an existing project rather than a greenfield project creation (#4).
|
|
167
|
+
* Checks if a task prompt is an iterative refinement / modification.
|
|
192
168
|
*/
|
|
193
169
|
export function isRefinementTask(userPrompt = '', projectFiles = null) {
|
|
194
170
|
if (Array.isArray(projectFiles) && projectFiles.length === 0) return false
|
|
@@ -226,13 +202,16 @@ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [
|
|
|
226
202
|
if (!baseDir || !Array.isArray(files) || files.length === 0) return []
|
|
227
203
|
|
|
228
204
|
const targetStr = String(candidateIndex).trim()
|
|
229
|
-
const
|
|
230
|
-
const
|
|
205
|
+
const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
206
|
+
const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
|
|
207
|
+
const moaRoot = path.join(baseDir, '.moa')
|
|
208
|
+
const candidateDir = assertPathContained(moaRoot, sub)
|
|
231
209
|
await fs.mkdir(candidateDir, { recursive: true })
|
|
232
210
|
|
|
233
211
|
const written = []
|
|
234
212
|
for (const file of files) {
|
|
235
|
-
|
|
213
|
+
if (!isSafeRelativePath(file.relativePath)) continue
|
|
214
|
+
const fullPath = assertPathContained(candidateDir, file.relativePath)
|
|
236
215
|
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
|
237
216
|
await fs.writeFile(fullPath, file.content, 'utf8')
|
|
238
217
|
written.push({
|
|
@@ -245,10 +224,6 @@ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [
|
|
|
245
224
|
return written
|
|
246
225
|
}
|
|
247
226
|
|
|
248
|
-
/**
|
|
249
|
-
* Promotes winning candidate files to baseDir root,
|
|
250
|
-
* then cleans up the temporary .moa folder.
|
|
251
|
-
*/
|
|
252
227
|
/**
|
|
253
228
|
* Best-effort integration with dsh-time-machine to create a shadow Git checkpoint
|
|
254
229
|
* before candidate promotion overwrites files in the project workspace.
|
|
@@ -267,14 +242,8 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
|
|
|
267
242
|
const snap = await bestEffort('dsh-time-machine pre-promotion checkpoint', async () => {
|
|
268
243
|
const res = await fetch(endpoint, {
|
|
269
244
|
method: 'POST',
|
|
270
|
-
headers: {
|
|
271
|
-
|
|
272
|
-
'x-dsh-trusted': '1',
|
|
273
|
-
},
|
|
274
|
-
body: JSON.stringify({
|
|
275
|
-
label,
|
|
276
|
-
cwd: baseDir,
|
|
277
|
-
}),
|
|
245
|
+
headers: { 'Content-Type': 'application/json', 'x-dsh-trusted': '1' },
|
|
246
|
+
body: JSON.stringify({ label, cwd: baseDir }),
|
|
278
247
|
signal: AbortSignal.timeout(options.timeoutMs ?? 1500),
|
|
279
248
|
})
|
|
280
249
|
if (res.ok) {
|
|
@@ -290,9 +259,10 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
|
|
|
290
259
|
if (!baseDir || winningIndex === null || winningIndex === undefined) return []
|
|
291
260
|
|
|
292
261
|
const targetStr = String(winningIndex).trim()
|
|
293
|
-
const
|
|
294
|
-
const
|
|
262
|
+
const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
263
|
+
const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
|
|
295
264
|
const moaRoot = path.join(baseDir, '.moa')
|
|
265
|
+
const winningDir = assertPathContained(moaRoot, sub)
|
|
296
266
|
|
|
297
267
|
try {
|
|
298
268
|
const entries = await getFilesRecursively(winningDir)
|
|
@@ -303,27 +273,21 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
|
|
|
303
273
|
return []
|
|
304
274
|
}
|
|
305
275
|
|
|
306
|
-
// Best-effort Time Machine checkpoint before overwriting files in baseDir
|
|
307
276
|
const checkpoint = await createPrePromotionCheckpoint(baseDir, winningIndex, options)
|
|
308
277
|
|
|
309
278
|
const promoted = []
|
|
310
279
|
for (const rel of entries) {
|
|
311
|
-
|
|
312
|
-
const
|
|
280
|
+
if (!isSafeRelativePath(rel)) continue
|
|
281
|
+
const srcPath = assertPathContained(winningDir, rel)
|
|
282
|
+
const destPath = assertPathContained(baseDir, rel)
|
|
313
283
|
|
|
314
284
|
await fs.mkdir(path.dirname(destPath), { recursive: true })
|
|
315
285
|
await fs.copyFile(srcPath, destPath)
|
|
316
286
|
promoted.push(rel)
|
|
317
287
|
}
|
|
318
288
|
|
|
319
|
-
if (checkpoint)
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
if (!options.keepMoa) {
|
|
324
|
-
// Clean up temporary .moa folder
|
|
325
|
-
await fs.rm(moaRoot, { recursive: true, force: true })
|
|
326
|
-
}
|
|
289
|
+
if (checkpoint) promoted.checkpoint = checkpoint
|
|
290
|
+
if (!options.keepMoa) await fs.rm(moaRoot, { recursive: true, force: true })
|
|
327
291
|
|
|
328
292
|
return promoted
|
|
329
293
|
} catch (err) {
|
|
@@ -348,7 +312,7 @@ async function getFilesRecursively(dir) {
|
|
|
348
312
|
try {
|
|
349
313
|
const stat = await fs.stat(dir)
|
|
350
314
|
if (!stat.isDirectory()) return []
|
|
351
|
-
} catch
|
|
315
|
+
} catch {
|
|
352
316
|
return []
|
|
353
317
|
}
|
|
354
318
|
|
|
@@ -359,13 +323,10 @@ async function getFilesRecursively(dir) {
|
|
|
359
323
|
for (const item of list) {
|
|
360
324
|
const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
|
|
361
325
|
const full = path.join(current, item.name)
|
|
362
|
-
if (item.isDirectory())
|
|
363
|
-
|
|
364
|
-
} else if (item.isFile()) {
|
|
365
|
-
results.push(rel)
|
|
366
|
-
}
|
|
326
|
+
if (item.isDirectory()) await scan(full, rel)
|
|
327
|
+
else if (item.isFile()) results.push(rel)
|
|
367
328
|
}
|
|
368
|
-
} catch
|
|
329
|
+
} catch {
|
|
369
330
|
return
|
|
370
331
|
}
|
|
371
332
|
}
|
|
@@ -373,10 +334,8 @@ async function getFilesRecursively(dir) {
|
|
|
373
334
|
return results
|
|
374
335
|
}
|
|
375
336
|
|
|
376
|
-
|
|
377
337
|
/**
|
|
378
338
|
* Verifies the syntactic validity of generated code files.
|
|
379
|
-
* Uses Node.js in-memory vm.Script for JS (with ES module export/import transformation) and JSON.parse for JSON.
|
|
380
339
|
*/
|
|
381
340
|
export function verifyFileSyntax(files = []) {
|
|
382
341
|
if (!Array.isArray(files)) return []
|
|
@@ -424,33 +383,43 @@ export function verifyFileSyntax(files = []) {
|
|
|
424
383
|
export async function readCandidateFiles(baseDir, candidateTarget) {
|
|
425
384
|
if (!baseDir || candidateTarget === undefined || candidateTarget === null) return []
|
|
426
385
|
const targetStr = String(candidateTarget).trim()
|
|
427
|
-
const
|
|
428
|
-
const
|
|
386
|
+
const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
387
|
+
const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
|
|
388
|
+
const moaRoot = path.join(baseDir, '.moa')
|
|
389
|
+
|
|
390
|
+
let targetDir
|
|
391
|
+
try {
|
|
392
|
+
targetDir = assertPathContained(moaRoot, sub)
|
|
393
|
+
} catch {
|
|
394
|
+
return []
|
|
395
|
+
}
|
|
429
396
|
|
|
430
397
|
try {
|
|
431
398
|
const relFiles = await getFilesRecursively(targetDir)
|
|
432
399
|
const result = []
|
|
433
400
|
for (const rel of relFiles) {
|
|
401
|
+
if (!isSafeRelativePath(rel)) continue
|
|
434
402
|
try {
|
|
435
|
-
const fullPath =
|
|
403
|
+
const fullPath = assertPathContained(targetDir, rel)
|
|
436
404
|
const content = await fs.readFile(fullPath, 'utf8')
|
|
437
405
|
result.push({
|
|
438
406
|
relativePath: rel,
|
|
439
407
|
content,
|
|
440
408
|
size: Buffer.byteLength(content, 'utf8'),
|
|
441
409
|
})
|
|
442
|
-
} catch
|
|
410
|
+
} catch {
|
|
443
411
|
// file read error best-effort
|
|
444
412
|
}
|
|
445
413
|
}
|
|
446
414
|
return result
|
|
447
|
-
} catch
|
|
415
|
+
} catch {
|
|
448
416
|
return []
|
|
449
417
|
}
|
|
450
418
|
}
|
|
451
419
|
|
|
452
420
|
/**
|
|
453
|
-
* Computes a
|
|
421
|
+
* Computes a line-by-line diff between two text strings using LCS with
|
|
422
|
+
* common prefix and suffix trimming for optimal O(delta) performance.
|
|
454
423
|
* Returns an array of diff items: { type: 'same' | 'added' | 'removed', line: string, oldNum?: number, newNum?: number }
|
|
455
424
|
*/
|
|
456
425
|
export function computeLineDiff(oldText = '', newText = '') {
|
|
@@ -460,44 +429,97 @@ export function computeLineDiff(oldText = '', newText = '') {
|
|
|
460
429
|
const n = oldLines.length
|
|
461
430
|
const m = newLines.length
|
|
462
431
|
|
|
463
|
-
|
|
464
|
-
if (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
432
|
+
if (n === 0 && m === 0) return []
|
|
433
|
+
if (oldText === newText) {
|
|
434
|
+
return oldLines.map((line, idx) => ({
|
|
435
|
+
type: 'same',
|
|
436
|
+
line,
|
|
437
|
+
oldNum: idx + 1,
|
|
438
|
+
newNum: idx + 1,
|
|
439
|
+
}))
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 1. Trim common prefix
|
|
443
|
+
let start = 0
|
|
444
|
+
while (start < n && start < m && oldLines[start] === newLines[start]) {
|
|
445
|
+
start++
|
|
469
446
|
}
|
|
470
447
|
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
448
|
+
// 2. Trim common suffix
|
|
449
|
+
let oldEnd = n - 1
|
|
450
|
+
let newEnd = m - 1
|
|
451
|
+
while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) {
|
|
452
|
+
oldEnd--
|
|
453
|
+
newEnd--
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const prefixItems = []
|
|
457
|
+
for (let k = 0; k < start; k++) {
|
|
458
|
+
prefixItems.push({ type: 'same', line: oldLines[k], oldNum: k + 1, newNum: k + 1 })
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const suffixItems = []
|
|
462
|
+
const suffixLen = (n - 1) - oldEnd
|
|
463
|
+
for (let k = 0; k < suffixLen; k++) {
|
|
464
|
+
const oNum = oldEnd + 1 + k + 1
|
|
465
|
+
const nNum = newEnd + 1 + k + 1
|
|
466
|
+
suffixItems.push({ type: 'same', line: oldLines[oldEnd + 1 + k], oldNum: oNum, newNum: nNum })
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const subOld = oldLines.slice(start, oldEnd + 1)
|
|
470
|
+
const subNew = newLines.slice(start, newEnd + 1)
|
|
471
|
+
const subN = subOld.length
|
|
472
|
+
const subM = subNew.length
|
|
473
|
+
|
|
474
|
+
const middleItems = []
|
|
475
|
+
|
|
476
|
+
if (subN === 0) {
|
|
477
|
+
for (let j = 0; j < subM; j++) {
|
|
478
|
+
middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
|
|
479
|
+
}
|
|
480
|
+
} else if (subM === 0) {
|
|
481
|
+
for (let i = 0; i < subN; i++) {
|
|
482
|
+
middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
|
|
483
|
+
}
|
|
484
|
+
} else if (subN > 2000 || subM > 2000) {
|
|
485
|
+
for (let i = 0; i < subN; i++) {
|
|
486
|
+
middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
|
|
487
|
+
}
|
|
488
|
+
for (let j = 0; j < subM; j++) {
|
|
489
|
+
middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
const dp = Array.from({ length: subN + 1 }, () => new Uint16Array(subM + 1))
|
|
493
|
+
for (let i = 0; i < subN; i++) {
|
|
494
|
+
for (let j = 0; j < subM; j++) {
|
|
495
|
+
if (subOld[i] === subNew[j]) {
|
|
496
|
+
dp[i + 1][j + 1] = dp[i][j] + 1
|
|
497
|
+
} else {
|
|
498
|
+
dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1])
|
|
499
|
+
}
|
|
479
500
|
}
|
|
480
501
|
}
|
|
481
|
-
}
|
|
482
502
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
j
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
j
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
503
|
+
let i = subN
|
|
504
|
+
let j = subM
|
|
505
|
+
const stack = []
|
|
506
|
+
|
|
507
|
+
while (i > 0 || j > 0) {
|
|
508
|
+
if (i > 0 && j > 0 && subOld[i - 1] === subNew[j - 1]) {
|
|
509
|
+
stack.push({ type: 'same', line: subOld[i - 1], oldNum: start + i, newNum: start + j })
|
|
510
|
+
i--
|
|
511
|
+
j--
|
|
512
|
+
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
513
|
+
stack.push({ type: 'added', line: subNew[j - 1], newNum: start + j })
|
|
514
|
+
j--
|
|
515
|
+
} else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
|
|
516
|
+
stack.push({ type: 'removed', line: subOld[i - 1], oldNum: start + i })
|
|
517
|
+
i--
|
|
518
|
+
}
|
|
499
519
|
}
|
|
520
|
+
|
|
521
|
+
middleItems.push(...stack.reverse())
|
|
500
522
|
}
|
|
501
523
|
|
|
502
|
-
return
|
|
524
|
+
return [...prefixItems, ...middleItems, ...suffixItems]
|
|
503
525
|
}
|
package/lib/history.js
CHANGED
|
@@ -15,6 +15,13 @@ const DEFAULT_HISTORY_FILE = path.join(DEFAULT_HISTORY_DIR, 'moa-history.jsonl')
|
|
|
15
15
|
/** Max history file size before rotation (10 MB) */
|
|
16
16
|
export const MAX_HISTORY_BYTES = 10 * 1024 * 1024
|
|
17
17
|
|
|
18
|
+
/** In-memory cache for leaderboard and parsed analytics */
|
|
19
|
+
let _historyCache = null
|
|
20
|
+
|
|
21
|
+
export function invalidateHistoryCache() {
|
|
22
|
+
_historyCache = null
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
function getHistoryFilePath(customDir) {
|
|
19
26
|
if (customDir) {
|
|
20
27
|
return path.join(customDir, '.moa-history.jsonl')
|
|
@@ -36,6 +43,7 @@ export function rotateHistoryFileIfNeeded(filePath = DEFAULT_HISTORY_FILE, maxBy
|
|
|
36
43
|
try { fs.unlinkSync(backupPath) } catch (unlinkErr) { /* backup unlink best-effort */ }
|
|
37
44
|
}
|
|
38
45
|
fs.renameSync(filePath, backupPath)
|
|
46
|
+
invalidateHistoryCache()
|
|
39
47
|
return true
|
|
40
48
|
}
|
|
41
49
|
} catch (err) {
|
|
@@ -97,6 +105,7 @@ export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
|
|
|
97
105
|
const entry = normalizeRunRecord(record)
|
|
98
106
|
const line = JSON.stringify(entry) + '\n'
|
|
99
107
|
fs.appendFileSync(filePath, line, 'utf8')
|
|
108
|
+
invalidateHistoryCache()
|
|
100
109
|
return entry
|
|
101
110
|
} catch (err) {
|
|
102
111
|
console.warn('[dsh-moa] Failed to record run to history:', err)
|
|
@@ -117,6 +126,7 @@ export async function recordMoaRunAsync(record, filePath = DEFAULT_HISTORY_FILE)
|
|
|
117
126
|
const entry = normalizeRunRecord(record)
|
|
118
127
|
const line = JSON.stringify(entry) + '\n'
|
|
119
128
|
await fs.promises.appendFile(filePath, line, 'utf8')
|
|
129
|
+
invalidateHistoryCache()
|
|
120
130
|
return entry
|
|
121
131
|
} catch (err) {
|
|
122
132
|
console.warn('[dsh-moa] Failed to async-record run to history:', err)
|
|
@@ -124,6 +134,49 @@ export async function recordMoaRunAsync(record, filePath = DEFAULT_HISTORY_FILE)
|
|
|
124
134
|
}
|
|
125
135
|
}
|
|
126
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Reads lines from the end of a file in reverse order in chunks.
|
|
139
|
+
* Avoids loading multi-megabyte JSONL files into V8 heap strings.
|
|
140
|
+
*/
|
|
141
|
+
export function readTailLinesSync(filePath, countNeeded = 50) {
|
|
142
|
+
const CHUNK_SIZE = 64 * 1024
|
|
143
|
+
const fd = fs.openSync(filePath, 'r')
|
|
144
|
+
try {
|
|
145
|
+
const stat = fs.fstatSync(fd)
|
|
146
|
+
let pos = stat.size
|
|
147
|
+
let remainder = ''
|
|
148
|
+
const collectedLines = []
|
|
149
|
+
|
|
150
|
+
while (pos > 0 && collectedLines.length < countNeeded) {
|
|
151
|
+
const bytesToRead = Math.min(CHUNK_SIZE, pos)
|
|
152
|
+
pos -= bytesToRead
|
|
153
|
+
const buf = Buffer.alloc(bytesToRead)
|
|
154
|
+
fs.readSync(fd, buf, 0, bytesToRead, pos)
|
|
155
|
+
const chunkStr = buf.toString('utf8') + remainder
|
|
156
|
+
const lines = chunkStr.split('\n')
|
|
157
|
+
if (pos > 0) {
|
|
158
|
+
remainder = lines.shift()
|
|
159
|
+
} else {
|
|
160
|
+
remainder = ''
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
164
|
+
const trimmed = lines[i].trim()
|
|
165
|
+
if (trimmed) {
|
|
166
|
+
collectedLines.push(trimmed)
|
|
167
|
+
if (collectedLines.length >= countNeeded) break
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (remainder && remainder.trim() && collectedLines.length < countNeeded) {
|
|
172
|
+
collectedLines.push(remainder.trim())
|
|
173
|
+
}
|
|
174
|
+
return collectedLines
|
|
175
|
+
} finally {
|
|
176
|
+
fs.closeSync(fd)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
127
180
|
/**
|
|
128
181
|
* Reads history runs with pagination (latest first).
|
|
129
182
|
*/
|
|
@@ -133,13 +186,34 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = DEFAULT_HISTORY
|
|
|
133
186
|
return { total: 0, runs: [] }
|
|
134
187
|
}
|
|
135
188
|
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
189
|
+
const stat = fs.statSync(filePath)
|
|
190
|
+
const needed = offset + limit
|
|
191
|
+
|
|
192
|
+
// For files under 64KB, straightforward full-read is fast and keeps exact total
|
|
193
|
+
if (stat.size <= 65536) {
|
|
194
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
195
|
+
const lines = raw.trim().split('\n').filter(Boolean)
|
|
196
|
+
const total = lines.length
|
|
197
|
+
|
|
198
|
+
const sliced = lines
|
|
199
|
+
.slice(Math.max(0, total - offset - limit), total - offset)
|
|
200
|
+
.reverse()
|
|
201
|
+
.map((line) => {
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(line)
|
|
204
|
+
} catch {
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
.filter(Boolean)
|
|
209
|
+
|
|
210
|
+
return { total, runs: sliced }
|
|
211
|
+
}
|
|
139
212
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
213
|
+
// For larger files: read only tail lines backwards to avoid splitting 10MB in memory
|
|
214
|
+
const tailLines = readTailLinesSync(filePath, needed)
|
|
215
|
+
const pageLines = tailLines.slice(offset, offset + limit)
|
|
216
|
+
const runs = pageLines
|
|
143
217
|
.map((line) => {
|
|
144
218
|
try {
|
|
145
219
|
return JSON.parse(line)
|
|
@@ -149,7 +223,12 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = DEFAULT_HISTORY
|
|
|
149
223
|
})
|
|
150
224
|
.filter(Boolean)
|
|
151
225
|
|
|
152
|
-
|
|
226
|
+
let total = _historyCache?.totalCount
|
|
227
|
+
if (typeof total !== 'number') {
|
|
228
|
+
total = tailLines.length >= needed ? Math.max(tailLines.length, Math.round(stat.size / 250)) : tailLines.length
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { total, runs }
|
|
153
232
|
} catch (err) {
|
|
154
233
|
console.warn('[dsh-moa] Failed to read history:', err)
|
|
155
234
|
return { total: 0, runs: [] }
|
|
@@ -158,6 +237,7 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = DEFAULT_HISTORY
|
|
|
158
237
|
|
|
159
238
|
/**
|
|
160
239
|
* Calculates win-rate leaderboard and model performance statistics across history.
|
|
240
|
+
* Employs in-memory caching keyed by file mtime and size for O(1) repeated queries.
|
|
161
241
|
*/
|
|
162
242
|
export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE, options = {}) {
|
|
163
243
|
try {
|
|
@@ -165,13 +245,35 @@ export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE, options = {})
|
|
|
165
245
|
return { totalRuns: 0, models: [] }
|
|
166
246
|
}
|
|
167
247
|
|
|
248
|
+
const stat = fs.statSync(filePath)
|
|
249
|
+
const presetFilter = typeof options === 'string' ? options : (options?.presetFilter || options?.preset || null)
|
|
250
|
+
const filterKey = presetFilter || 'all'
|
|
251
|
+
|
|
252
|
+
if (
|
|
253
|
+
_historyCache &&
|
|
254
|
+
_historyCache.filePath === filePath &&
|
|
255
|
+
_historyCache.mtimeMs === stat.mtimeMs &&
|
|
256
|
+
_historyCache.size === stat.size
|
|
257
|
+
) {
|
|
258
|
+
if (_historyCache.leaderboards.has(filterKey)) {
|
|
259
|
+
return _historyCache.leaderboards.get(filterKey)
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
_historyCache = {
|
|
263
|
+
filePath,
|
|
264
|
+
mtimeMs: stat.mtimeMs,
|
|
265
|
+
size: stat.size,
|
|
266
|
+
leaderboards: new Map(),
|
|
267
|
+
totalCount: null,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
168
271
|
const raw = fs.readFileSync(filePath, 'utf8')
|
|
169
272
|
const lines = raw.trim().split('\n').filter(Boolean)
|
|
170
273
|
const totalRuns = lines.length
|
|
274
|
+
_historyCache.totalCount = totalRuns
|
|
171
275
|
|
|
172
276
|
const modelStats = new Map()
|
|
173
|
-
const presetFilter = typeof options === 'string' ? options : (options?.presetFilter || options?.preset || null)
|
|
174
|
-
|
|
175
277
|
let filteredRuns = 0
|
|
176
278
|
for (const line of lines) {
|
|
177
279
|
try {
|
|
@@ -198,6 +300,7 @@ export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE, options = {})
|
|
|
198
300
|
}
|
|
199
301
|
const st = modelStats.get(key)
|
|
200
302
|
st.runs += 1
|
|
303
|
+
|
|
201
304
|
const inTok = cand.usage?.inputTokens || 0
|
|
202
305
|
const outTok = cand.usage?.outputTokens || 0
|
|
203
306
|
st.totalTokens += inTok + outTok
|
|
@@ -222,7 +325,13 @@ export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE, options = {})
|
|
|
222
325
|
}))
|
|
223
326
|
.sort((a, b) => b.wins - a.wins || b.winRate - a.winRate)
|
|
224
327
|
|
|
225
|
-
|
|
328
|
+
const result = {
|
|
329
|
+
totalRuns: presetFilter && presetFilter !== 'all' ? filteredRuns : totalRuns,
|
|
330
|
+
models,
|
|
331
|
+
preset: presetFilter,
|
|
332
|
+
}
|
|
333
|
+
_historyCache.leaderboards.set(filterKey, result)
|
|
334
|
+
return result
|
|
226
335
|
} catch (err) {
|
|
227
336
|
console.warn('[dsh-moa] Failed to calculate leaderboard:', err)
|
|
228
337
|
return { totalRuns: 0, models: [] }
|
package/lib/index.js
CHANGED
|
@@ -11,6 +11,18 @@ import { refreshCatalogInBackground } from './pricing.js'
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import z from '@deepseek-ai/schemastery'
|
|
14
|
+
|
|
15
|
+
// Ensure .volatile() helper exists on Schemastery schema prototype
|
|
16
|
+
try {
|
|
17
|
+
const schemaProto = Object.getPrototypeOf(z.boolean())
|
|
18
|
+
if (schemaProto && typeof schemaProto.volatile !== 'function') {
|
|
19
|
+
schemaProto.volatile = function () {
|
|
20
|
+
return typeof this.extra === 'function' ? this.extra('volatile', true) : this
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// safe fallback
|
|
25
|
+
}
|
|
14
26
|
import path from 'node:path'
|
|
15
27
|
import os from 'node:os'
|
|
16
28
|
import {
|
|
@@ -26,7 +38,7 @@ import { registerPluginUpdater, isSafeWriteRequest } from './updater.js'
|
|
|
26
38
|
|
|
27
39
|
export const name = '@goodandready/dsh-moa'
|
|
28
40
|
export { collectProjectContext, formatProjectContext } from './file-workspace.js'
|
|
29
|
-
export const inject = ['webServer', 'llm', '
|
|
41
|
+
export const inject = ['webServer', 'llm', 'sessions', 'tools']
|
|
30
42
|
|
|
31
43
|
export const NS = 'dsh-moa'
|
|
32
44
|
|
|
@@ -65,9 +77,9 @@ export const PresetSchema = z.object({
|
|
|
65
77
|
})
|
|
66
78
|
|
|
67
79
|
export const Config = z.object({
|
|
68
|
-
enabled: z.boolean().default(true),
|
|
69
|
-
default_preset: z.string().default('default'),
|
|
70
|
-
prices: z.dict(PriceRow).default({}),
|
|
80
|
+
enabled: z.boolean().default(true).volatile(),
|
|
81
|
+
default_preset: z.string().default('default').volatile(),
|
|
82
|
+
prices: z.dict(PriceRow).default({}).volatile(),
|
|
71
83
|
presets: z.array(PresetSchema).default([
|
|
72
84
|
{
|
|
73
85
|
name: 'default',
|
|
@@ -110,31 +122,77 @@ export const Config = z.object({
|
|
|
110
122
|
max_tokens: 8192,
|
|
111
123
|
judge_criteria: '',
|
|
112
124
|
},
|
|
113
|
-
]),
|
|
125
|
+
]).volatile(),
|
|
114
126
|
})
|
|
115
127
|
|
|
116
128
|
|
|
129
|
+
export function plainConfig(value) {
|
|
130
|
+
if (value && typeof value === 'object' && typeof value.get === 'function') {
|
|
131
|
+
return plainConfig(value.get())
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(value)) {
|
|
134
|
+
return value.map(plainConfig)
|
|
135
|
+
}
|
|
136
|
+
if (value !== null && typeof value === 'object') {
|
|
137
|
+
return Object.fromEntries(
|
|
138
|
+
Object.entries(value).map(([key, child]) => [key, plainConfig(child)])
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
return value
|
|
142
|
+
}
|
|
143
|
+
|
|
117
144
|
export function apply(ctx, config) {
|
|
118
145
|
refreshCatalogInBackground()
|
|
119
146
|
|
|
120
|
-
let
|
|
121
|
-
|
|
122
|
-
const live = () =>
|
|
147
|
+
let localConfig = plainConfig(config ?? {})
|
|
148
|
+
const getConfig = () => localConfig
|
|
149
|
+
const live = () => {
|
|
150
|
+
try {
|
|
151
|
+
const raw = getConfig() ?? {}
|
|
152
|
+
const validated = Config(plainConfig(raw))
|
|
153
|
+
return plainConfig(validated) ?? localConfig
|
|
154
|
+
} catch {
|
|
155
|
+
return localConfig
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let settingsService = null
|
|
123
160
|
|
|
161
|
+
// Optional policy hook for DSH 0.1.7 settings forms:
|
|
162
|
+
// Suppress auto-generated settings form since dsh-moa provides its own
|
|
163
|
+
// custom settings surface in settings.plugin.item.
|
|
124
164
|
ctx.inject(['settings'], (sctx) => {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
165
|
+
settingsService = sctx.settings
|
|
166
|
+
if (sctx.settings && typeof sctx.settings.configure === 'function') {
|
|
167
|
+
try {
|
|
168
|
+
const dispose = sctx.settings.configure({ auto: false }, ctx.fiber)
|
|
169
|
+
if (typeof sctx.effect === 'function') {
|
|
170
|
+
sctx.effect(() => () => {
|
|
171
|
+
dispose()
|
|
172
|
+
settingsService = null
|
|
173
|
+
}, 'dsh-moa: settings policy')
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// Safe fallback if configure is already registered or throws
|
|
177
|
+
}
|
|
135
178
|
}
|
|
136
179
|
})
|
|
137
180
|
|
|
181
|
+
const saveConfig = async (newFields) => {
|
|
182
|
+
const plain = plainConfig(newFields)
|
|
183
|
+
localConfig = { ...localConfig, ...plain }
|
|
184
|
+
try {
|
|
185
|
+
if (settingsService && typeof settingsService.replace === 'function') {
|
|
186
|
+
await settingsService.replace(NS, plain)
|
|
187
|
+
} else if (settingsService && typeof settingsService.update === 'function') {
|
|
188
|
+
await settingsService.update(NS, plain)
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
console.warn('[dsh-moa] Settings persistence warning:', err?.message || err)
|
|
192
|
+
}
|
|
193
|
+
return localConfig
|
|
194
|
+
}
|
|
195
|
+
|
|
138
196
|
/**
|
|
139
197
|
* Unified LLM dispatch function using ctx.llm.prepareCall / stream
|
|
140
198
|
*/
|
|
@@ -226,7 +284,9 @@ export function apply(ctx, config) {
|
|
|
226
284
|
live,
|
|
227
285
|
callLlm,
|
|
228
286
|
liveCanvas,
|
|
229
|
-
|
|
287
|
+
saveConfig,
|
|
288
|
+
Config,
|
|
289
|
+
plainConfig,
|
|
230
290
|
})
|
|
231
291
|
|
|
232
292
|
// Session turn interceptor: route turn to real aggregator model & stream MoA synthesis
|
package/lib/moa-candidates.js
CHANGED
|
@@ -104,7 +104,12 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
104
104
|
)
|
|
105
105
|
|
|
106
106
|
const timeoutPromise = new Promise((_, reject) => {
|
|
107
|
-
const timer = setTimeout(() =>
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
bestEffort('candidate timeout abort', () => {
|
|
109
|
+
abortControllers[i].abort(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s`))
|
|
110
|
+
})
|
|
111
|
+
reject(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s`))
|
|
112
|
+
}, timeoutMs)
|
|
108
113
|
timer.unref?.()
|
|
109
114
|
abortControllers[i].signal.addEventListener('abort', () => clearTimeout(timer), { once: true })
|
|
110
115
|
})
|
package/lib/routes.js
CHANGED
|
@@ -8,10 +8,13 @@ import {
|
|
|
8
8
|
getMoaRunById,
|
|
9
9
|
exportMoaHistory,
|
|
10
10
|
} from './history.js'
|
|
11
|
-
import { promoteCandidateWorkspace, readCandidateFiles, computeLineDiff } from './file-workspace.js'
|
|
11
|
+
import { promoteCandidateWorkspace, readCandidateFiles, computeLineDiff, isSafeRelativePath } from './file-workspace.js'
|
|
12
12
|
import { isSafeWriteRequest } from './updater.js'
|
|
13
13
|
import { runMoAPipeline } from './moa-runner.js'
|
|
14
14
|
|
|
15
|
+
let _modelsCache = { timestamp: 0, models: [] }
|
|
16
|
+
const MODELS_CACHE_TTL = 60 * 1000
|
|
17
|
+
|
|
15
18
|
function writeJson(res, status, body) {
|
|
16
19
|
res.writeHead(status, {
|
|
17
20
|
'Content-Type': 'application/json; charset=utf-8',
|
|
@@ -35,21 +38,24 @@ function readBody(req) {
|
|
|
35
38
|
})
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
export function registerMoaRoutes(ctx, { live, callLlm, liveCanvas,
|
|
39
|
-
const getScope = () => (typeof getSettingsScope === 'function' ? getSettingsScope() : null)
|
|
41
|
+
export function registerMoaRoutes(ctx, { live, callLlm, liveCanvas, saveConfig, Config, plainConfig }) {
|
|
40
42
|
|
|
41
43
|
ctx.effect(() => {
|
|
42
44
|
return ctx.webServer.register({
|
|
43
45
|
kind: 'exact',
|
|
44
46
|
path: '/dsh-moa/status',
|
|
45
47
|
handler: (_req, res) => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
try {
|
|
49
|
+
const cfg = live()
|
|
50
|
+
writeJson(res, 200, {
|
|
51
|
+
ok: true,
|
|
52
|
+
enabled: cfg.enabled !== false,
|
|
53
|
+
defaultPreset: cfg.default_preset || 'default',
|
|
54
|
+
presetsCount: (cfg.presets || []).length,
|
|
55
|
+
})
|
|
56
|
+
} catch (err) {
|
|
57
|
+
writeJson(res, 500, { ok: false, error: err?.message || String(err) })
|
|
58
|
+
}
|
|
53
59
|
},
|
|
54
60
|
})
|
|
55
61
|
}, 'dsh-moa: status route')
|
|
@@ -60,6 +66,11 @@ ctx.effect(() => {
|
|
|
60
66
|
kind: 'exact',
|
|
61
67
|
path: '/dsh-moa/models',
|
|
62
68
|
handler: async (_req, res) => {
|
|
69
|
+
if (_modelsCache.models.length > 0 && Date.now() - _modelsCache.timestamp < MODELS_CACHE_TTL) {
|
|
70
|
+
writeJson(res, 200, { ok: true, models: _modelsCache.models, cached: true })
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
63
74
|
const result = []
|
|
64
75
|
const seen = new Set()
|
|
65
76
|
|
|
@@ -87,18 +98,23 @@ ctx.effect(() => {
|
|
|
87
98
|
try {
|
|
88
99
|
if (typeof ctx.llm.listConfigurableProviders === 'function') {
|
|
89
100
|
const cProvs = ctx.llm.listConfigurableProviders() || []
|
|
90
|
-
|
|
101
|
+
const queries = cProvs.map(async (cp) => {
|
|
91
102
|
const cpId = cp.provider || cp.id
|
|
92
|
-
if (cpId
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
if (!cpId || typeof ctx.llm.listModels !== 'function') return
|
|
104
|
+
try {
|
|
105
|
+
const mList = await Promise.race([
|
|
106
|
+
ctx.llm.listModels(cpId),
|
|
107
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000)),
|
|
108
|
+
])
|
|
109
|
+
for (const m of (mList || [])) {
|
|
110
|
+
const mId = typeof m === 'string' ? m : (m?.id || m?.model || m?.name)
|
|
111
|
+
if (mId) add(cpId, mId)
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
/* provider query timeout or error handled best-effort */
|
|
100
115
|
}
|
|
101
|
-
}
|
|
116
|
+
})
|
|
117
|
+
await Promise.allSettled(queries)
|
|
102
118
|
}
|
|
103
119
|
} catch (e) {
|
|
104
120
|
console.warn('[dsh-moa] ctx.llm.listConfigurableProviders error:', e)
|
|
@@ -107,7 +123,12 @@ ctx.effect(() => {
|
|
|
107
123
|
|
|
108
124
|
// Query settings for configured providers
|
|
109
125
|
try {
|
|
110
|
-
|
|
126
|
+
let s = null
|
|
127
|
+
try {
|
|
128
|
+
s = ctx.get ? ctx.get('settings') : null
|
|
129
|
+
} catch {
|
|
130
|
+
s = null
|
|
131
|
+
}
|
|
111
132
|
const getSetting = (k) => {
|
|
112
133
|
try { return s?.get?.(k) } catch { return undefined }
|
|
113
134
|
}
|
|
@@ -136,6 +157,7 @@ ctx.effect(() => {
|
|
|
136
157
|
console.warn('[dsh-moa] Error reading settings:', e)
|
|
137
158
|
}
|
|
138
159
|
|
|
160
|
+
_modelsCache = { timestamp: Date.now(), models: result }
|
|
139
161
|
writeJson(res, 200, { ok: true, models: result })
|
|
140
162
|
},
|
|
141
163
|
})
|
|
@@ -182,19 +204,17 @@ ctx.effect(() => {
|
|
|
182
204
|
return
|
|
183
205
|
}
|
|
184
206
|
|
|
185
|
-
// Persist via
|
|
186
|
-
if (
|
|
187
|
-
await
|
|
188
|
-
} else {
|
|
189
|
-
// In-memory fallback if settings service is unavailable
|
|
190
|
-
getConfig = () => validated
|
|
207
|
+
// Persist via saveConfig (in-memory and DSH profile settings)
|
|
208
|
+
if (typeof saveConfig === 'function') {
|
|
209
|
+
await saveConfig(validated)
|
|
191
210
|
}
|
|
192
211
|
|
|
212
|
+
const unwrapped = typeof plainConfig === 'function' ? plainConfig(validated) : validated
|
|
193
213
|
writeJson(res, 200, {
|
|
194
214
|
ok: true,
|
|
195
|
-
enabled:
|
|
196
|
-
defaultPreset:
|
|
197
|
-
presets:
|
|
215
|
+
enabled: unwrapped.enabled !== false,
|
|
216
|
+
defaultPreset: unwrapped.default_preset,
|
|
217
|
+
presets: unwrapped.presets,
|
|
198
218
|
})
|
|
199
219
|
} catch (err) {
|
|
200
220
|
writeJson(res, 400, { ok: false, error: err?.message || String(err) })
|
|
@@ -309,9 +329,14 @@ ctx.effect(() => {
|
|
|
309
329
|
try {
|
|
310
330
|
const u = new URL(req.url, 'http://localhost')
|
|
311
331
|
const cwd = u.searchParams.get('cwd') || process.cwd()
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
332
|
+
const sanitizeTarget = (val, def) => {
|
|
333
|
+
const clean = String(val || '').trim().replace(/[^a-zA-Z0-9_-]/g, '')
|
|
334
|
+
return clean || def
|
|
335
|
+
}
|
|
336
|
+
const from = sanitizeTarget(u.searchParams.get('from'), '1')
|
|
337
|
+
const to = sanitizeTarget(u.searchParams.get('to'), 'curator-synthesis')
|
|
338
|
+
const rawFile = u.searchParams.get('file') || ''
|
|
339
|
+
const file = (rawFile && isSafeRelativePath(rawFile)) ? rawFile : ''
|
|
315
340
|
|
|
316
341
|
const fromFiles = await readCandidateFiles(cwd, from)
|
|
317
342
|
const toFiles = await readCandidateFiles(cwd, to)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-moa",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.22",
|
|
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",
|
|
@@ -59,5 +59,10 @@
|
|
|
59
59
|
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
60
60
|
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
|
61
61
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@deepseek-ai/dsh-settings": {
|
|
65
|
+
"optional": true
|
|
66
|
+
}
|
|
62
67
|
}
|
|
63
68
|
}
|