@goodandready/dsh-moa 0.2.9 → 0.2.11
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 +66 -37
- package/docs/README.ru.md +66 -37
- package/docs/README.zh.md +70 -27
- package/docs/design/DESIGN.md +16 -6
- package/docs/plans/47-client-js-split-plan.md +57 -0
- package/docs/plans/55-resilience-plan.md +33 -0
- package/lib/client.js +260 -85
- package/lib/file-workspace.js +3 -1
- package/lib/history.js +88 -33
- package/lib/index.js +70 -13
- package/lib/live-canvas.js +34 -0
- package/lib/moa-parser.js +181 -0
- package/lib/moa-prompts.js +251 -0
- package/lib/moa-runner.js +477 -473
- package/package.json +1 -1
package/lib/history.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MoA Run History & Analytics Module
|
|
3
3
|
* Records every MoA pipeline execution, tracks model win rates and costs,
|
|
4
|
-
* and provides history search and
|
|
4
|
+
* and provides history search, file rotation, and non-blocking I/O.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import fs from 'node:fs'
|
|
@@ -12,6 +12,9 @@ import crypto from 'node:crypto'
|
|
|
12
12
|
const DEFAULT_HISTORY_DIR = path.join(os.homedir(), '.dsh')
|
|
13
13
|
const DEFAULT_HISTORY_FILE = path.join(DEFAULT_HISTORY_DIR, 'moa-history.jsonl')
|
|
14
14
|
|
|
15
|
+
/** Max history file size before rotation (10 MB) */
|
|
16
|
+
export const MAX_HISTORY_BYTES = 10 * 1024 * 1024
|
|
17
|
+
|
|
15
18
|
export function getHistoryFilePath(customDir) {
|
|
16
19
|
if (customDir) {
|
|
17
20
|
return path.join(customDir, '.moa-history.jsonl')
|
|
@@ -20,7 +23,67 @@ export function getHistoryFilePath(customDir) {
|
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
|
-
*
|
|
26
|
+
* Checks if the history file exceeds the size threshold and rotates it.
|
|
27
|
+
* Moves current file to `${filePath}.1`, removing any older `.1` backup.
|
|
28
|
+
*/
|
|
29
|
+
export function rotateHistoryFileIfNeeded(filePath = DEFAULT_HISTORY_FILE, maxBytes = MAX_HISTORY_BYTES) {
|
|
30
|
+
try {
|
|
31
|
+
if (!fs.existsSync(filePath)) return false
|
|
32
|
+
const stat = fs.statSync(filePath)
|
|
33
|
+
if (stat.size >= maxBytes) {
|
|
34
|
+
const backupPath = `${filePath}.1`
|
|
35
|
+
if (fs.existsSync(backupPath)) {
|
|
36
|
+
try { fs.unlinkSync(backupPath) } catch {}
|
|
37
|
+
}
|
|
38
|
+
fs.renameSync(filePath, backupPath)
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.warn('[dsh-moa] History rotation warning:', err?.message || err)
|
|
43
|
+
}
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Prepares a normalized history record entry.
|
|
49
|
+
*/
|
|
50
|
+
function normalizeRunRecord(record) {
|
|
51
|
+
return {
|
|
52
|
+
id: record.id || crypto.randomUUID(),
|
|
53
|
+
timestamp: record.timestamp || new Date().toISOString(),
|
|
54
|
+
prompt: record.prompt || '',
|
|
55
|
+
preset: record.preset || 'default',
|
|
56
|
+
isRefinement: Boolean(record.isRefinement),
|
|
57
|
+
isFastMode: Boolean(record.isFastMode),
|
|
58
|
+
candidates: Array.isArray(record.candidates)
|
|
59
|
+
? record.candidates.map((c) => ({
|
|
60
|
+
provider: c.provider || '',
|
|
61
|
+
model: c.model || '',
|
|
62
|
+
filesCount: Array.isArray(c.files) ? c.files.length : (c.filesCount || 0),
|
|
63
|
+
usage: c.usage || { inputTokens: 0, outputTokens: 0 },
|
|
64
|
+
costUsd: Number(c.costUsd || 0),
|
|
65
|
+
}))
|
|
66
|
+
: [],
|
|
67
|
+
aggregator: record.aggregator
|
|
68
|
+
? {
|
|
69
|
+
provider: record.aggregator.provider || '',
|
|
70
|
+
model: record.aggregator.model || '',
|
|
71
|
+
usage: record.aggregator.usage || { inputTokens: 0, outputTokens: 0 },
|
|
72
|
+
costUsd: Number(record.aggregator.costUsd || 0),
|
|
73
|
+
}
|
|
74
|
+
: null,
|
|
75
|
+
winnerIndex: typeof record.winnerIndex === 'number' ? record.winnerIndex : -1,
|
|
76
|
+
winnerModel: record.winnerModel || '',
|
|
77
|
+
promotedFiles: Array.isArray(record.promotedFiles) ? record.promotedFiles : [],
|
|
78
|
+
totalTokens: Number(record.totalTokens || 0),
|
|
79
|
+
totalCostUsd: Number(record.totalCostUsd || 0),
|
|
80
|
+
durationMs: Number(record.durationMs || 0),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Appends a completed MoA run record to the history file synchronously (preserves sync API).
|
|
86
|
+
* Performs automatic size-based rotation.
|
|
24
87
|
*/
|
|
25
88
|
export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
|
|
26
89
|
try {
|
|
@@ -29,37 +92,9 @@ export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
|
|
|
29
92
|
fs.mkdirSync(dir, { recursive: true })
|
|
30
93
|
}
|
|
31
94
|
|
|
32
|
-
|
|
33
|
-
id: record.id || crypto.randomUUID(),
|
|
34
|
-
timestamp: record.timestamp || new Date().toISOString(),
|
|
35
|
-
prompt: record.prompt || '',
|
|
36
|
-
preset: record.preset || 'default',
|
|
37
|
-
isRefinement: Boolean(record.isRefinement),
|
|
38
|
-
candidates: Array.isArray(record.candidates)
|
|
39
|
-
? record.candidates.map((c) => ({
|
|
40
|
-
provider: c.provider || '',
|
|
41
|
-
model: c.model || '',
|
|
42
|
-
filesCount: Array.isArray(c.files) ? c.files.length : (c.filesCount || 0),
|
|
43
|
-
usage: c.usage || { inputTokens: 0, outputTokens: 0 },
|
|
44
|
-
costUsd: Number(c.costUsd || 0),
|
|
45
|
-
}))
|
|
46
|
-
: [],
|
|
47
|
-
aggregator: record.aggregator
|
|
48
|
-
? {
|
|
49
|
-
provider: record.aggregator.provider || '',
|
|
50
|
-
model: record.aggregator.model || '',
|
|
51
|
-
usage: record.aggregator.usage || { inputTokens: 0, outputTokens: 0 },
|
|
52
|
-
costUsd: Number(record.aggregator.costUsd || 0),
|
|
53
|
-
}
|
|
54
|
-
: null,
|
|
55
|
-
winnerIndex: typeof record.winnerIndex === 'number' ? record.winnerIndex : -1,
|
|
56
|
-
winnerModel: record.winnerModel || '',
|
|
57
|
-
promotedFiles: Array.isArray(record.promotedFiles) ? record.promotedFiles : [],
|
|
58
|
-
totalTokens: Number(record.totalTokens || 0),
|
|
59
|
-
totalCostUsd: Number(record.totalCostUsd || 0),
|
|
60
|
-
durationMs: Number(record.durationMs || 0),
|
|
61
|
-
}
|
|
95
|
+
rotateHistoryFileIfNeeded(filePath)
|
|
62
96
|
|
|
97
|
+
const entry = normalizeRunRecord(record)
|
|
63
98
|
const line = JSON.stringify(entry) + '\n'
|
|
64
99
|
fs.appendFileSync(filePath, line, 'utf8')
|
|
65
100
|
return entry
|
|
@@ -69,6 +104,26 @@ export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
|
|
|
69
104
|
}
|
|
70
105
|
}
|
|
71
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Asynchronously appends a completed MoA run record without blocking the event loop.
|
|
109
|
+
*/
|
|
110
|
+
export async function recordMoaRunAsync(record, filePath = DEFAULT_HISTORY_FILE) {
|
|
111
|
+
try {
|
|
112
|
+
const dir = path.dirname(filePath)
|
|
113
|
+
await fs.promises.mkdir(dir, { recursive: true }).catch(() => {})
|
|
114
|
+
|
|
115
|
+
rotateHistoryFileIfNeeded(filePath)
|
|
116
|
+
|
|
117
|
+
const entry = normalizeRunRecord(record)
|
|
118
|
+
const line = JSON.stringify(entry) + '\n'
|
|
119
|
+
await fs.promises.appendFile(filePath, line, 'utf8')
|
|
120
|
+
return entry
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.warn('[dsh-moa] Failed to async-record run to history:', err)
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
72
127
|
/**
|
|
73
128
|
* Reads history runs with pagination (latest first).
|
|
74
129
|
*/
|
|
@@ -144,7 +199,7 @@ export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE) {
|
|
|
144
199
|
st.totalTokens += inTok + outTok
|
|
145
200
|
st.totalCostUsd += Number(cand.costUsd || 0)
|
|
146
201
|
|
|
147
|
-
if (winner && (winner === key || winner === cand.model
|
|
202
|
+
if (winner && cand.model && (winner === key || winner === cand.model)) {
|
|
148
203
|
st.wins += 1
|
|
149
204
|
}
|
|
150
205
|
}
|
package/lib/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { refreshCatalogInBackground } from './pricing.js'
|
|
|
6
6
|
* 1. User issues /moa <prompt>
|
|
7
7
|
* 2. Parallel candidate proposers write to isolated workspaces (.moa/candidate-X/)
|
|
8
8
|
* 3. Aggregator evaluates and synthesizes the optimal solution
|
|
9
|
-
* 4. Winner candidate files are promoted to the project root
|
|
9
|
+
* 4. Winner candidate files are promoted to the project root
|
|
10
10
|
* 5. Native cost tracking, history logging and leaderboard analytics
|
|
11
11
|
*/
|
|
12
12
|
|
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
getMoaRunById,
|
|
26
26
|
} from './history.js'
|
|
27
27
|
|
|
28
|
+
import { createLiveCanvasClient } from './live-canvas.js'
|
|
29
|
+
|
|
28
30
|
export const name = 'dsh-moa'
|
|
29
31
|
export const inject = ['webServer', 'llm', 'settings', 'sessions', 'tools']
|
|
30
32
|
|
|
@@ -45,13 +47,19 @@ export const PresetSchema = z.object({
|
|
|
45
47
|
name: z.string(),
|
|
46
48
|
enabled: z.boolean().default(true),
|
|
47
49
|
ask_clarifying_questions: z.boolean().default(true),
|
|
50
|
+
curator_synthesis: z.boolean().default(false),
|
|
51
|
+
stream_aggregator: z.boolean().default(true),
|
|
52
|
+
quorum_enabled: z.boolean().default(false),
|
|
53
|
+
grace_period_sec: z.number().default(10),
|
|
48
54
|
reference_models: z.array(ModelSlotSchema).default([]),
|
|
49
55
|
aggregator: ModelSlotSchema.default({ provider: 'codex', model: 'gpt-5.6-sol' }),
|
|
56
|
+
aggregator_fallbacks: z.array(ModelSlotSchema).default([]),
|
|
50
57
|
reference_temperature: z.number().default(0.6),
|
|
51
58
|
aggregator_temperature: z.number().default(0.4),
|
|
59
|
+
reference_timeout_sec: z.number().default(60),
|
|
60
|
+
aggregator_timeout_sec: z.number().default(180),
|
|
52
61
|
max_tokens: z.number().default(4096),
|
|
53
62
|
judge_criteria: z.string().default(''),
|
|
54
|
-
judge_mode: z.string().default('auto'),
|
|
55
63
|
})
|
|
56
64
|
|
|
57
65
|
export const Config = z.object({
|
|
@@ -72,7 +80,6 @@ export const Config = z.object({
|
|
|
72
80
|
aggregator_temperature: 0.4,
|
|
73
81
|
max_tokens: 4096,
|
|
74
82
|
judge_criteria: '',
|
|
75
|
-
judge_mode: 'auto',
|
|
76
83
|
},
|
|
77
84
|
{
|
|
78
85
|
name: 'fast',
|
|
@@ -86,7 +93,6 @@ export const Config = z.object({
|
|
|
86
93
|
aggregator_temperature: 0.2,
|
|
87
94
|
max_tokens: 4096,
|
|
88
95
|
judge_criteria: '',
|
|
89
|
-
judge_mode: 'auto',
|
|
90
96
|
},
|
|
91
97
|
{
|
|
92
98
|
name: 'deep-reasoning',
|
|
@@ -100,8 +106,7 @@ export const Config = z.object({
|
|
|
100
106
|
reference_temperature: 0.6,
|
|
101
107
|
aggregator_temperature: 0.2,
|
|
102
108
|
max_tokens: 8192,
|
|
103
|
-
judge_criteria: '
|
|
104
|
-
judge_mode: 'auto',
|
|
109
|
+
judge_criteria: '',
|
|
105
110
|
},
|
|
106
111
|
]),
|
|
107
112
|
})
|
|
@@ -153,7 +158,7 @@ export function apply(ctx, config) {
|
|
|
153
158
|
/**
|
|
154
159
|
* Unified LLM dispatch function using ctx.llm.prepareCall / stream
|
|
155
160
|
*/
|
|
156
|
-
const callLlm = async ({ provider, model, messages, temperature = 0.6, maxTokens = 4096 }) => {
|
|
161
|
+
const callLlm = async ({ provider, model, messages, temperature = 0.6, maxTokens = 4096, timeoutMs, onStreamDelta }) => {
|
|
157
162
|
if (!ctx.llm) {
|
|
158
163
|
throw new Error('ctx.llm is not available in cordis context')
|
|
159
164
|
}
|
|
@@ -177,8 +182,9 @@ export function apply(ctx, config) {
|
|
|
177
182
|
throw new Error(`LLM provider/model ${provider}:${model} could not be prepared`)
|
|
178
183
|
}
|
|
179
184
|
|
|
185
|
+
const effectiveTimeoutMs = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : 120000
|
|
180
186
|
const abortCtrl = new AbortController()
|
|
181
|
-
const timeoutId = setTimeout(() => abortCtrl.abort(new Error(
|
|
187
|
+
const timeoutId = setTimeout(() => abortCtrl.abort(new Error(`LLM call timeout after ${Math.round(effectiveTimeoutMs / 1000)}s`)), effectiveTimeoutMs)
|
|
182
188
|
timeoutId.unref?.()
|
|
183
189
|
|
|
184
190
|
try {
|
|
@@ -195,6 +201,9 @@ export function apply(ctx, config) {
|
|
|
195
201
|
for await (const chunk of stream) {
|
|
196
202
|
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
197
203
|
fullText += chunk.text
|
|
204
|
+
if (typeof onStreamDelta === 'function') {
|
|
205
|
+
onStreamDelta(chunk.text)
|
|
206
|
+
}
|
|
198
207
|
} else if (chunk.type === 'block-end' && chunk.block?.text) {
|
|
199
208
|
if (!fullText) fullText = chunk.block.text
|
|
200
209
|
} else if (chunk.type === 'usage' && chunk.usage) {
|
|
@@ -217,6 +226,11 @@ export function apply(ctx, config) {
|
|
|
217
226
|
}
|
|
218
227
|
}
|
|
219
228
|
|
|
229
|
+
// Optional Live Canvas preview client: the harness port is resolved per
|
|
230
|
+
// call and any failure degrades to "no preview link" (live-canvas is not
|
|
231
|
+
// required to be installed).
|
|
232
|
+
const liveCanvas = createLiveCanvasClient({ getPort: () => ctx.webServer?.port })
|
|
233
|
+
|
|
220
234
|
// Route: /dsh-moa/status
|
|
221
235
|
ctx.effect(() => {
|
|
222
236
|
return ctx.webServer.register({
|
|
@@ -342,23 +356,35 @@ export function apply(ctx, config) {
|
|
|
342
356
|
const raw = await readBody(req)
|
|
343
357
|
const payload = JSON.parse(raw)
|
|
344
358
|
const newConfig = {
|
|
345
|
-
enabled: cfg.enabled !== false,
|
|
359
|
+
enabled: typeof payload.enabled === 'boolean' ? payload.enabled : cfg.enabled !== false,
|
|
346
360
|
default_preset: payload.default_preset || cfg.default_preset || 'default',
|
|
347
361
|
presets: Array.isArray(payload.presets) ? payload.presets : (cfg.presets || []),
|
|
348
362
|
}
|
|
349
363
|
|
|
364
|
+
// Validate the merged payload through the plugin schema before it
|
|
365
|
+
// reaches the settings storage: malformed payloads must be
|
|
366
|
+
// rejected with 400 instead of being persisted.
|
|
367
|
+
let validated
|
|
368
|
+
try {
|
|
369
|
+
validated = Config(structuredClone(newConfig))
|
|
370
|
+
} catch (verr) {
|
|
371
|
+
writeJson(res, 400, { ok: false, error: 'Invalid presets payload: ' + (verr?.message || String(verr)) })
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
|
|
350
375
|
// Persist via Cordis Settings API
|
|
351
376
|
if (settingsScope && typeof settingsScope.replace === 'function') {
|
|
352
|
-
await settingsScope.replace(
|
|
377
|
+
await settingsScope.replace(validated)
|
|
353
378
|
} else {
|
|
354
379
|
// In-memory fallback if settings service is unavailable
|
|
355
|
-
getConfig = () =>
|
|
380
|
+
getConfig = () => validated
|
|
356
381
|
}
|
|
357
382
|
|
|
358
383
|
writeJson(res, 200, {
|
|
359
384
|
ok: true,
|
|
360
|
-
|
|
361
|
-
|
|
385
|
+
enabled: validated.enabled !== false,
|
|
386
|
+
defaultPreset: validated.default_preset,
|
|
387
|
+
presets: validated.presets,
|
|
362
388
|
})
|
|
363
389
|
} catch (err) {
|
|
364
390
|
writeJson(res, 400, { ok: false, error: err?.message || String(err) })
|
|
@@ -414,6 +440,29 @@ export function apply(ctx, config) {
|
|
|
414
440
|
})
|
|
415
441
|
}, 'dsh-moa: leaderboard route')
|
|
416
442
|
|
|
443
|
+
// Route: /dsh-moa/runs/<id> (run replay by id)
|
|
444
|
+
ctx.effect(() => {
|
|
445
|
+
return ctx.webServer.register({
|
|
446
|
+
kind: 'prefix',
|
|
447
|
+
path: '/dsh-moa/runs/',
|
|
448
|
+
handler: (req, res) => {
|
|
449
|
+
if (req.method !== 'GET') {
|
|
450
|
+
writeJson(res, 405, { ok: false, error: 'GET required' })
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
const urlPath = req.url.split('?')[0]
|
|
454
|
+
const parts = urlPath.split('/').filter(Boolean)
|
|
455
|
+
const runId = parts[2] || ''
|
|
456
|
+
const run = runId ? getMoaRunById(runId) : null
|
|
457
|
+
if (!run) {
|
|
458
|
+
writeJson(res, 404, { ok: false, error: 'Run not found' })
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
writeJson(res, 200, { ok: true, run })
|
|
462
|
+
},
|
|
463
|
+
})
|
|
464
|
+
}, 'dsh-moa: run by id route')
|
|
465
|
+
|
|
417
466
|
// Route: /dsh-moa/run
|
|
418
467
|
ctx.effect(() => {
|
|
419
468
|
return ctx.webServer.register({
|
|
@@ -435,6 +484,10 @@ export function apply(ctx, config) {
|
|
|
435
484
|
}
|
|
436
485
|
|
|
437
486
|
const cfg = live()
|
|
487
|
+
if (cfg.enabled === false) {
|
|
488
|
+
writeJson(res, 400, { ok: false, error: 'MoA is disabled in settings' })
|
|
489
|
+
return
|
|
490
|
+
}
|
|
438
491
|
const presetName = payload.preset || cfg.default_preset || 'default'
|
|
439
492
|
const preset = (cfg.presets || []).find((p) => p.name === presetName) || cfg.presets?.[0]
|
|
440
493
|
|
|
@@ -444,6 +497,8 @@ export function apply(ctx, config) {
|
|
|
444
497
|
preset,
|
|
445
498
|
callLlm,
|
|
446
499
|
cwd: payload.cwd || process.cwd(),
|
|
500
|
+
prices: cfg.prices || {},
|
|
501
|
+
liveCanvas,
|
|
447
502
|
})
|
|
448
503
|
|
|
449
504
|
writeJson(res, 200, { ok: true, ...result })
|
|
@@ -511,6 +566,8 @@ export function apply(ctx, config) {
|
|
|
511
566
|
cwd: sessCwd,
|
|
512
567
|
aggregator: { provider: aggProvider, model: aggModel },
|
|
513
568
|
callLlm,
|
|
569
|
+
prices: cfg.prices || {},
|
|
570
|
+
liveCanvas,
|
|
514
571
|
}
|
|
515
572
|
|
|
516
573
|
if (event.signal) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional Live Canvas preview client (@goodandready/dsh-live-canvas).
|
|
3
|
+
*
|
|
4
|
+
* The plugin posts the promoted HTML to live-canvas's own REST contract
|
|
5
|
+
* (POST /dsh-live-canvas/api/preview, served by the same harness webServer),
|
|
6
|
+
* so no cross-plugin service coupling or module imports are needed. Any
|
|
7
|
+
* failure — live-canvas not installed, endpoint missing, timeout, malformed
|
|
8
|
+
* response — degrades to null and the MoA answer simply carries no preview
|
|
9
|
+
* link, without errors in the harness log.
|
|
10
|
+
*/
|
|
11
|
+
export function createLiveCanvasClient({ getPort, fetchImpl = globalThis.fetch, timeoutMs = 3000 } = {}) {
|
|
12
|
+
if (typeof fetchImpl !== 'function') return null
|
|
13
|
+
return {
|
|
14
|
+
async createPreviewFromContent({ content, title, filePath } = {}) {
|
|
15
|
+
if (!content || typeof content !== 'string') return null
|
|
16
|
+
const port = typeof getPort === 'function' ? getPort() : getPort
|
|
17
|
+
if (!port) return null
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetchImpl(`http://127.0.0.1:${port}/dsh-live-canvas/api/preview`, {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'Content-Type': 'application/json' },
|
|
22
|
+
body: JSON.stringify({ content, title, filePath }),
|
|
23
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
24
|
+
})
|
|
25
|
+
if (!res.ok) return null
|
|
26
|
+
const data = await res.json()
|
|
27
|
+
if (!data || data.success !== true || !data.canvasId || !data.previewUrl) return null
|
|
28
|
+
return { canvasId: data.canvasId, previewUrl: data.previewUrl, title: title || 'Live Preview' }
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// lib/moa-parser.js
|
|
2
|
+
// Parsers for model outputs, winner selection, commands, and code blocks for @goodandready/dsh-moa.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extracts candidate winner index from judge output.
|
|
6
|
+
* Looks for machine marker WINNER_CANDIDATE_INDEX: N first, then conversational fallbacks.
|
|
7
|
+
*/
|
|
8
|
+
export function parseWinnerIndex(judgeText, defaultIndex = 1, candidateCount = Infinity) {
|
|
9
|
+
if (!judgeText || typeof judgeText !== 'string') return defaultIndex
|
|
10
|
+
const inRange = (idx) => !isNaN(idx) && idx >= 1 && idx <= candidateCount
|
|
11
|
+
const match = /WINNER_CANDIDATE_INDEX:\s*(\d+)/i.exec(judgeText)
|
|
12
|
+
if (match) {
|
|
13
|
+
const idx = parseInt(match[1], 10)
|
|
14
|
+
if (inRange(idx)) return idx
|
|
15
|
+
}
|
|
16
|
+
const candMatch = /(?:Кандидат|Candidate|Reference)\s*(\d+)\b/i.exec(judgeText)
|
|
17
|
+
if (candMatch) {
|
|
18
|
+
const idx = parseInt(candMatch[1], 10)
|
|
19
|
+
if (inRange(idx)) return idx
|
|
20
|
+
}
|
|
21
|
+
return defaultIndex
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parses the recommended assembler model index and label from curator output.
|
|
26
|
+
*/
|
|
27
|
+
export function parseRecommendedAssembler(judgeText, defaultIndex = 1, candidateCount = Infinity) {
|
|
28
|
+
if (!judgeText || typeof judgeText !== 'string') return { index: defaultIndex, label: '' }
|
|
29
|
+
const inRange = (idx) => !isNaN(idx) && idx >= 1 && idx <= candidateCount
|
|
30
|
+
const match = /RECOMMENDED_ASSEMBLER:\s*(\d+)(?:\s*\(([^)]+)\))?/i.exec(judgeText)
|
|
31
|
+
if (match) {
|
|
32
|
+
const idx = parseInt(match[1], 10)
|
|
33
|
+
if (inRange(idx)) {
|
|
34
|
+
return { index: idx, label: match[2]?.trim() || '' }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { index: defaultIndex, label: '' }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parses a `/moa [preset] <prompt>` command invocation.
|
|
42
|
+
*/
|
|
43
|
+
export function parseMoACommand(text, presets = []) {
|
|
44
|
+
if (typeof text !== 'string' || !text.startsWith('/moa')) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const remainder = text.slice(4).trim()
|
|
49
|
+
if (!remainder) {
|
|
50
|
+
return {
|
|
51
|
+
presetName: 'default',
|
|
52
|
+
prompt: '',
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const matchPreset = /^--preset(?:=|\s+)([a-zA-Z0-9_-]+)\s*(.*)/s.exec(remainder)
|
|
57
|
+
if (matchPreset) {
|
|
58
|
+
return {
|
|
59
|
+
presetName: matchPreset[1],
|
|
60
|
+
prompt: matchPreset[2] || '',
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const parts = remainder.split(/\s+/)
|
|
65
|
+
const firstWord = parts[0]
|
|
66
|
+
if (Array.isArray(presets)) {
|
|
67
|
+
const matched = presets.find((p) => p.name === firstWord)
|
|
68
|
+
if (matched) {
|
|
69
|
+
return {
|
|
70
|
+
presetName: matched.name,
|
|
71
|
+
prompt: parts.slice(1).join(' ').trim(),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
presetName: 'default',
|
|
78
|
+
prompt: remainder,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Replaces large code blocks with concise file/code summaries to avoid token waste and huge chat dumps.
|
|
84
|
+
*/
|
|
85
|
+
export function stripOrSummarizeCode(text) {
|
|
86
|
+
if (!text || typeof text !== 'string') return ''
|
|
87
|
+
return text.replace(/```([a-zA-Z0-9_\-\.\/]*)\s*([\w\.\/\-]+\.[a-zA-Z0-9]+)?\n([\s\S]*?)```/g, (match, lang, fileTag, code) => {
|
|
88
|
+
const lines = code.trim().split('\n')
|
|
89
|
+
if (lines.length <= 3 && !/html|jsx|tsx|vue|svelte|css|js|ts/i.test(lang)) {
|
|
90
|
+
return match
|
|
91
|
+
}
|
|
92
|
+
const fileHint = fileTag || (code.match(/^\s*(?:\/\/|#|<!--|\/\*)\s*(?:file|filepath|path):\s*([^\s*]+)/im)?.[1])
|
|
93
|
+
const label = fileHint ? `file \`${fileHint}\`` : (lang ? `code \`${lang}\`` : 'code')
|
|
94
|
+
return `\n> 📄 *[${label} - ${lines.length} lines saved to disk]*\n`
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Formats user-facing markdown response for synthesis, questions, and failure kinds.
|
|
100
|
+
*/
|
|
101
|
+
export function formatMoAResponse({ moaResult, presetName }) {
|
|
102
|
+
const parts = []
|
|
103
|
+
const pName = presetName || moaResult?.presetName || 'default'
|
|
104
|
+
const judge = moaResult?.aggregator || 'unknown'
|
|
105
|
+
const refs = moaResult?.references || []
|
|
106
|
+
|
|
107
|
+
if (moaResult?.kind === 'questions') {
|
|
108
|
+
parts.push('## 🧠 Mixture of Agents — Requirements Clarification')
|
|
109
|
+
parts.push(`*Judge (${judge}) and the advisors analyzed the task:*\n`)
|
|
110
|
+
parts.push(moaResult.content)
|
|
111
|
+
return parts.join('\n')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (moaResult?.kind === 'failure') {
|
|
115
|
+
parts.push('## ⚠️ Mixture of Agents — Execution Failed')
|
|
116
|
+
parts.push(moaResult.content)
|
|
117
|
+
return parts.join('\n')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const modeBadge = moaResult?.isFastMode
|
|
121
|
+
? '⚡ Fast Mode'
|
|
122
|
+
: (moaResult?.isCuratorSynthesis ? `🧠 Curator: ${judge}` : `Judge: ${judge}`)
|
|
123
|
+
parts.push(`## 🧠 Mixture of Agents (Preset: ${pName} | ${modeBadge})`)
|
|
124
|
+
parts.push('')
|
|
125
|
+
|
|
126
|
+
if (moaResult?.isRefinement) {
|
|
127
|
+
parts.push('> 🔄 **Mode**: Iterative project refinement (Refinement)')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const hasPromoted = moaResult?.promotedFiles && moaResult.promotedFiles.length > 0
|
|
131
|
+
if (hasPromoted) {
|
|
132
|
+
parts.push(`> 📦 **Files created in the project**: \`${moaResult.promotedFiles.join('`, `')}\``)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (moaResult?.recommendedAssembler?.label) {
|
|
136
|
+
parts.push(`> 🎯 **Recommended Master Assembler**: Candidate ${moaResult.recommendedAssembler.index} (\`${moaResult.recommendedAssembler.label}\`)`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (moaResult?.liveCanvas?.previewUrl) {
|
|
140
|
+
parts.push(`> 🎨 **Live Canvas**: [🚀 Открыть ${moaResult.liveCanvas.title || 'превью'} в Live Canvas](${moaResult.liveCanvas.previewUrl}) | [↗ Открыть в новой вкладке](${moaResult.liveCanvas.previewUrl})`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Cost tracking card
|
|
144
|
+
if (moaResult?.usage) {
|
|
145
|
+
const u = moaResult.usage
|
|
146
|
+
const costStr = u.totalCostUsd > 0 ? `~\$${u.totalCostUsd.toFixed(4)}` : 'Free'
|
|
147
|
+
const tokStr = u.totalTokens >= 1000 ? `${(u.totalTokens / 1000).toFixed(1)}k` : `${u.totalTokens}`
|
|
148
|
+
parts.push(`> 💰 **Run cost**: ${costStr} (${tokStr} tokens total)`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
parts.push('')
|
|
152
|
+
|
|
153
|
+
if (!moaResult?.isFastMode) {
|
|
154
|
+
parts.push(`### ⚖️ Judge verdict and final synthesis (Synthesis: ${judge})`)
|
|
155
|
+
parts.push('')
|
|
156
|
+
const cleanJudgeContent = hasPromoted
|
|
157
|
+
? stripOrSummarizeCode(moaResult?.content || '')
|
|
158
|
+
: (moaResult?.content || '(нет ответа)')
|
|
159
|
+
parts.push(cleanJudgeContent)
|
|
160
|
+
parts.push('')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (Array.isArray(refs) && refs.length > 0) {
|
|
164
|
+
const title = moaResult?.isFastMode ? '### 🚀 Candidate generation result:' : `### 👥 Advisor responses (${refs.length}):`
|
|
165
|
+
parts.push(title)
|
|
166
|
+
parts.push('')
|
|
167
|
+
refs.forEach((ref, i) => {
|
|
168
|
+
const statusIcon = ref.ok ? '✅' : '⚠️'
|
|
169
|
+
const fileBadge = ref.files?.length ? ` (${ref.files.length} файл(ов))` : ''
|
|
170
|
+
const costBadge = ref.costUsd > 0 ? ` [~\$${ref.costUsd.toFixed(4)}]` : ''
|
|
171
|
+
parts.push(`#### ${statusIcon} Model ${i + 1}: ${ref.label}${fileBadge}${costBadge}`)
|
|
172
|
+
parts.push('')
|
|
173
|
+
parts.push(stripOrSummarizeCode(ref.text))
|
|
174
|
+
parts.push('')
|
|
175
|
+
parts.push('---')
|
|
176
|
+
parts.push('')
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return parts.join('\n')
|
|
181
|
+
}
|