@goodandready/dsh-moa 0.2.40 → 0.2.42

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/history.js CHANGED
@@ -41,7 +41,6 @@ export function getDefaultHistoryFile() {
41
41
  return path.join(getDefaultHistoryDir(), 'moa-history.jsonl')
42
42
  }
43
43
 
44
-
45
44
  /** Max history file size before rotation (10 MB) */
46
45
  export const MAX_HISTORY_BYTES = 10 * 1024 * 1024
47
46
 
@@ -77,6 +76,7 @@ export function rotateHistoryFileIfNeeded(filePath = getDefaultHistoryFile(), ma
77
76
 
78
77
  /**
79
78
  * Prepares a normalized history record entry.
79
+ * Preserves candidate persona, temperature, fallback, testResult, and slot details (#169).
80
80
  */
81
81
  function normalizeRunRecord(record) {
82
82
  return {
@@ -86,19 +86,33 @@ function normalizeRunRecord(record) {
86
86
  preset: record.preset || 'default',
87
87
  isRefinement: Boolean(record.isRefinement),
88
88
  isFastMode: Boolean(record.isFastMode),
89
+ cwd: record.cwd ? path.resolve(record.cwd) : undefined,
89
90
  candidates: Array.isArray(record.candidates)
90
- ? record.candidates.map((c) => ({
91
- provider: c.provider || '',
92
- model: c.model || '',
91
+ ? record.candidates.map((c, idx) => ({
92
+ index: typeof c.index === 'number' ? c.index : (idx + 1),
93
+ provider: c.provider || c.slot?.provider || '',
94
+ model: c.model || c.slot?.model || '',
95
+ slot: c.slot ? { provider: c.slot.provider, model: c.slot.model } : (c.provider && c.model ? { provider: c.provider, model: c.model } : undefined),
96
+ role: c.role || c.role_persona || 'general',
97
+ temperature: typeof c.temperature === 'number' ? c.temperature : undefined,
98
+ wasFallback: Boolean(c.wasFallback || c.was_fallback),
99
+ originalModel: c.originalModel || c.original_model || null,
93
100
  filesCount: Array.isArray(c.files) ? c.files.length : (c.filesCount || 0),
101
+ files: Array.isArray(c.files) ? c.files : undefined,
94
102
  usage: c.usage || { inputTokens: 0, outputTokens: 0 },
95
103
  costUsd: Number(c.costUsd || 0),
104
+ testResult: c.testResult ? {
105
+ passed: Boolean(c.testResult.passed),
106
+ exitCode: c.testResult.exitCode ?? 0,
107
+ summary: c.testResult.summary || '',
108
+ durationMs: c.testResult.durationMs ?? 0,
109
+ } : null,
96
110
  }))
97
111
  : [],
98
112
  aggregator: record.aggregator
99
113
  ? {
100
- provider: record.aggregator.provider || '',
101
- model: record.aggregator.model || '',
114
+ provider: record.aggregator.provider || record.aggregator.slot?.provider || '',
115
+ model: record.aggregator.model || record.aggregator.slot?.model || '',
102
116
  usage: record.aggregator.usage || { inputTokens: 0, outputTokens: 0 },
103
117
  costUsd: Number(record.aggregator.costUsd || 0),
104
118
  }
@@ -162,6 +176,7 @@ export async function recordMoaRunAsync(record, filePath = getDefaultHistoryFile
162
176
  /**
163
177
  * Reads lines from the end of a file in reverse order in chunks.
164
178
  * Avoids loading multi-megabyte JSONL files into V8 heap strings.
179
+ * Buffer-safe: never splits multi-byte UTF-8 sequences at chunk boundaries (#184).
165
180
  */
166
181
  export function readTailLinesSync(filePath, countNeeded = 50) {
167
182
  const CHUNK_SIZE = 64 * 1024
@@ -169,7 +184,7 @@ export function readTailLinesSync(filePath, countNeeded = 50) {
169
184
  try {
170
185
  const stat = fs.fstatSync(fd)
171
186
  let pos = stat.size
172
- let remainder = ''
187
+ let remainderBuf = Buffer.alloc(0)
173
188
  const collectedLines = []
174
189
 
175
190
  while (pos > 0 && collectedLines.length < countNeeded) {
@@ -177,29 +192,107 @@ export function readTailLinesSync(filePath, countNeeded = 50) {
177
192
  pos -= bytesToRead
178
193
  const buf = Buffer.alloc(bytesToRead)
179
194
  fs.readSync(fd, buf, 0, bytesToRead, pos)
180
- const chunkStr = buf.toString('utf8') + remainder
181
- const lines = chunkStr.split('\n')
182
- if (pos > 0) {
183
- remainder = lines.shift()
184
- } else {
185
- remainder = ''
195
+ const totalBuf = remainderBuf.length > 0 ? Buffer.concat([buf, remainderBuf]) : buf
196
+
197
+ let endIdx = totalBuf.length
198
+ for (let i = totalBuf.length - 1; i >= 0; i--) {
199
+ if (totalBuf[i] === 0x0A) {
200
+ if (i + 1 < endIdx) {
201
+ const lineBuf = totalBuf.subarray(i + 1, endIdx)
202
+ const lineStr = lineBuf.toString('utf8').trim()
203
+ if (lineStr) {
204
+ collectedLines.push(lineStr)
205
+ if (collectedLines.length >= countNeeded) break
206
+ }
207
+ }
208
+ endIdx = i
209
+ }
210
+ }
211
+
212
+ if (collectedLines.length >= countNeeded) {
213
+ remainderBuf = Buffer.alloc(0)
214
+ break
186
215
  }
187
216
 
188
- for (let i = lines.length - 1; i >= 0; i--) {
189
- const trimmed = lines[i].trim()
190
- if (trimmed) {
191
- collectedLines.push(trimmed)
192
- if (collectedLines.length >= countNeeded) break
217
+ if (pos > 0) {
218
+ remainderBuf = totalBuf.subarray(0, endIdx)
219
+ } else {
220
+ if (endIdx > 0) {
221
+ const firstLineStr = totalBuf.subarray(0, endIdx).toString('utf8').trim()
222
+ if (firstLineStr && collectedLines.length < countNeeded) {
223
+ collectedLines.push(firstLineStr)
224
+ }
193
225
  }
226
+ remainderBuf = Buffer.alloc(0)
194
227
  }
195
228
  }
196
- if (remainder && remainder.trim() && collectedLines.length < countNeeded) {
197
- collectedLines.push(remainder.trim())
229
+
230
+ if (remainderBuf.length > 0 && collectedLines.length < countNeeded) {
231
+ const lineStr = remainderBuf.toString('utf8').trim()
232
+ if (lineStr) collectedLines.push(lineStr)
198
233
  }
234
+
199
235
  return collectedLines
200
236
  } finally {
201
- fs.closeSync(fd)
237
+ try { fs.closeSync(fd) } catch { /* best effort */ }
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Counts lines in a file by scanning for 0x0A newline bytes in chunks.
243
+ * Fast, memory-efficient, and caches result in _historyCache (#183).
244
+ */
245
+ export function countFileLinesSync(filePath, stat) {
246
+ if (
247
+ _historyCache &&
248
+ _historyCache.filePath === filePath &&
249
+ _historyCache.mtimeMs === stat.mtimeMs &&
250
+ _historyCache.size === stat.size &&
251
+ typeof _historyCache.totalCount === 'number'
252
+ ) {
253
+ return _historyCache.totalCount
254
+ }
255
+
256
+ let count = 0
257
+ const fd = fs.openSync(filePath, 'r')
258
+ try {
259
+ const CHUNK_SIZE = 64 * 1024
260
+ const buf = Buffer.alloc(CHUNK_SIZE)
261
+ let pos = 0
262
+ let lastByte = 0
263
+ while (pos < stat.size) {
264
+ const bytesToRead = Math.min(CHUNK_SIZE, stat.size - pos)
265
+ fs.readSync(fd, buf, 0, bytesToRead, pos)
266
+ for (let i = 0; i < bytesToRead; i++) {
267
+ if (buf[i] === 0x0A) count++
268
+ }
269
+ lastByte = buf[bytesToRead - 1]
270
+ pos += bytesToRead
271
+ }
272
+ if (stat.size > 0 && lastByte !== 0x0A) {
273
+ count++
274
+ }
275
+ } finally {
276
+ try { fs.closeSync(fd) } catch { /* best effort */ }
202
277
  }
278
+
279
+ if (
280
+ !_historyCache ||
281
+ _historyCache.filePath !== filePath ||
282
+ _historyCache.mtimeMs !== stat.mtimeMs ||
283
+ _historyCache.size !== stat.size
284
+ ) {
285
+ _historyCache = {
286
+ filePath,
287
+ mtimeMs: stat.mtimeMs,
288
+ size: stat.size,
289
+ leaderboards: new Map(),
290
+ totalCount: count,
291
+ }
292
+ } else {
293
+ _historyCache.totalCount = count
294
+ }
295
+ return count
203
296
  }
204
297
 
205
298
  /**
@@ -220,6 +313,23 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = getDefaultHisto
220
313
  const lines = raw.trim().split('\n').filter(Boolean)
221
314
  const total = lines.length
222
315
 
316
+ if (
317
+ !_historyCache ||
318
+ _historyCache.filePath !== filePath ||
319
+ _historyCache.mtimeMs !== stat.mtimeMs ||
320
+ _historyCache.size !== stat.size
321
+ ) {
322
+ _historyCache = {
323
+ filePath,
324
+ mtimeMs: stat.mtimeMs,
325
+ size: stat.size,
326
+ leaderboards: new Map(),
327
+ totalCount: total,
328
+ }
329
+ } else {
330
+ _historyCache.totalCount = total
331
+ }
332
+
223
333
  const sliced = lines
224
334
  .slice(Math.max(0, total - offset - limit), total - offset)
225
335
  .reverse()
@@ -250,7 +360,7 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = getDefaultHisto
250
360
 
251
361
  let total = _historyCache?.totalCount
252
362
  if (typeof total !== 'number') {
253
- total = tailLines.length >= needed ? Math.max(tailLines.length, Math.round(stat.size / 250)) : tailLines.length
363
+ total = tailLines.length < needed ? tailLines.length : countFileLinesSync(filePath, stat)
254
364
  }
255
365
 
256
366
  return { total, runs }
@@ -263,6 +373,7 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = getDefaultHisto
263
373
  /**
264
374
  * Calculates win-rate leaderboard and model performance statistics across history.
265
375
  * Employs in-memory caching keyed by file mtime and size for O(1) repeated queries.
376
+ * Robust matching for aliases, fallbacks, and winnerIndex (#185).
266
377
  */
267
378
  export function getMoaLeaderboard(filePath = getDefaultHistoryFile(), options = {}) {
268
379
  try {
@@ -307,10 +418,15 @@ export function getMoaLeaderboard(filePath = getDefaultHistoryFile(), options =
307
418
  if (presetFilter && presetFilter !== 'all' && item.preset !== presetFilter) continue
308
419
  filteredRuns++
309
420
 
310
- const winner = item.winnerModel ? String(item.winnerModel).trim() : null
421
+ const rawWinner = item.winnerModel ? String(item.winnerModel).trim() : null
422
+ const cleanWinner = rawWinner ? rawWinner.replace(/\s*\[fallback for .*\]$/i, '').trim() : null
423
+ const normWinner = cleanWinner && cleanWinner !== 'none' && cleanWinner !== 'unknown'
424
+ ? cleanWinner.replace(/[\/:]/g, ':').toLowerCase()
425
+ : null
311
426
 
312
427
  if (Array.isArray(item.candidates)) {
313
- for (const cand of item.candidates) {
428
+ for (let candIdx = 0; candIdx < item.candidates.length; candIdx++) {
429
+ const cand = item.candidates[candIdx]
314
430
  const key = cand.provider && cand.model ? `${cand.provider}:${cand.model}` : (cand.model || 'unknown')
315
431
  if (!modelStats.has(key)) {
316
432
  modelStats.set(key, {
@@ -326,12 +442,25 @@ export function getMoaLeaderboard(filePath = getDefaultHistoryFile(), options =
326
442
  const st = modelStats.get(key)
327
443
  st.runs += 1
328
444
 
329
- const inTok = cand.usage?.inputTokens || 0
330
- const outTok = cand.usage?.outputTokens || 0
445
+ const inTok = cand.usage?.inputTokens ?? cand.usage?.totalInputTokens ?? cand.usage?.prompt_tokens ?? 0
446
+ const outTok = cand.usage?.outputTokens ?? cand.usage?.totalOutputTokens ?? cand.usage?.completion_tokens ?? 0
331
447
  st.totalTokens += inTok + outTok
332
448
  st.totalCostUsd += Number(cand.costUsd || 0)
333
449
 
334
- if (winner && cand.model && (winner === key || winner === cand.model)) {
450
+ let isWin = false
451
+ if (normWinner) {
452
+ const normKey = key.replace(/[\/:]/g, ':').toLowerCase()
453
+ const normModel = String(cand.model || '').replace(/[\/:]/g, ':').toLowerCase()
454
+ if (normWinner === normKey || normWinner === normModel || normWinner.endsWith(`:${normModel}`)) {
455
+ isWin = true
456
+ }
457
+ }
458
+ if (!isWin && typeof item.winnerIndex === 'number' && item.winnerIndex > 0) {
459
+ if (cand.index === item.winnerIndex || candIdx === item.winnerIndex - 1) {
460
+ isWin = true
461
+ }
462
+ }
463
+ if (isWin) {
335
464
  st.wins += 1
336
465
  }
337
466
  }
@@ -433,22 +562,27 @@ export function exportMoaHistory(filePath = getDefaultHistoryFile(), options = {
433
562
  }
434
563
  }
435
564
 
565
+ /**
566
+ * Formats candidate outputs for history storage (#169).
567
+ */
436
568
  export function candidatesForHistory(referenceOutputs) {
437
- return (referenceOutputs || []).map((r) => ({
438
- provider: r.slot?.provider || "",
439
- model: r.slot?.model || "",
569
+ return (referenceOutputs || []).map((r, i) => ({
570
+ index: r.index ?? (i + 1),
571
+ provider: r.slot?.provider || r.provider || "",
572
+ model: r.slot?.model || r.model || "",
573
+ slot: r.slot ? { provider: r.slot.provider, model: r.slot.model } : undefined,
440
574
  role: r.role_persona || r.slot?.role_persona || r.slot?.role || "general",
441
575
  temperature: r.temperature,
442
576
  wasFallback: Boolean(r.was_fallback),
443
577
  originalModel: r.original_model || null,
444
- files: (r.files || []).map((f) => f.relativePath),
578
+ files: (r.files || []).map((f) => f.relativePath || f),
445
579
  usage: r.usage || { inputTokens: 0, outputTokens: 0 },
446
580
  costUsd: r.costUsd || 0,
447
581
  testResult: r.testResult ? {
448
- passed: r.testResult.passed,
449
- exitCode: r.testResult.exitCode,
450
- summary: r.testResult.summary,
451
- durationMs: r.testResult.durationMs,
582
+ passed: Boolean(r.testResult.passed),
583
+ exitCode: r.testResult.exitCode ?? 0,
584
+ summary: r.testResult.summary || '',
585
+ durationMs: r.testResult.durationMs ?? 0,
452
586
  } : null,
453
587
  }))
454
588
  }
package/lib/index.js CHANGED
@@ -12,6 +12,7 @@ import { createLlmCaller } from './moa-llm.js'
12
12
  import {
13
13
  parseMoACommand,
14
14
  streamMoATurn,
15
+ streamPromoteTurn,
15
16
  } from './moa-runner.js'
16
17
  import { registerMoaRoutes } from './routes.js'
17
18
  import { resolvePresetForPrompt } from './moa-router.js'
@@ -174,6 +175,20 @@ export function apply(ctx, config) {
174
175
  if (cfg.enabled !== false) {
175
176
  const effectiveDefault = cfg.default_preset || 'default'
176
177
  const parsed = parseMoACommand(userText, cfg.presets || [], effectiveDefault) || { prompt: userText, presetName: effectiveDefault, isExplicit: false }
178
+ if (parsed.isPromote) {
179
+ const sessCwd = event.cwd || event.session?.cwd || (event.session?.path ? path.dirname(event.session.path) : undefined) || process.cwd()
180
+ const turnContext = {
181
+ isPromoteTurn: true,
182
+ runId: parsed.runId,
183
+ candidateIndex: parsed.candidateIndex,
184
+ cwd: sessCwd,
185
+ webServerPort: ctx.webServer?.port,
186
+ timeMachineEngine: ctx.reflect?.get?.('timeMachineEngine', false),
187
+ }
188
+ if (event.signal) moaPendingTurns.set(event.signal, turnContext)
189
+ if (event.session?.id) setSessionTurn(event.session.id, turnContext)
190
+ return next()
191
+ }
177
192
  const isExplicitPreset = Boolean(parsed.isExplicit || userText.includes('--preset') || userText.includes('-p '))
178
193
  if (!isExplicitPreset && (cfg.smart_routing_enabled || cfg.presets?.some((p) => p.smart_routing_enabled))) {
179
194
  try {
@@ -234,11 +249,11 @@ export function apply(ctx, config) {
234
249
  const turnContext = (payload.signal ? moaPendingTurns.get(payload.signal) : null)
235
250
  || (payload.session?.id ? getSessionTurn(payload.session.id) : null)
236
251
 
237
- if (turnContext) {
252
+ if (turnContext && !turnContext.isPromoteTurn) {
238
253
  return {
239
254
  ...resolved,
240
- provider: turnContext.aggregator.provider,
241
- model: turnContext.aggregator.model,
255
+ provider: turnContext.aggregator?.provider || resolved.provider,
256
+ model: turnContext.aggregator?.model || resolved.model,
242
257
  }
243
258
  }
244
259
  return resolved
@@ -254,6 +269,9 @@ export function apply(ctx, config) {
254
269
  if (options.signal) moaPendingTurns.delete(options.signal)
255
270
  if (options.session?.id) consumeSessionTurn(options.session.id)
256
271
 
272
+ if (turnContext.isPromoteTurn) {
273
+ return streamPromoteTurn(turnContext, options)
274
+ }
257
275
  return streamMoATurn(turnContext, options)
258
276
  }
259
277
 
@@ -1,17 +1,50 @@
1
1
  import path from "node:path"
2
+ import fs from "node:fs/promises"
2
3
 
3
- export async function createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs) {
4
+ export async function createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs, options = {}) {
4
5
  if (!liveCanvas || typeof liveCanvas.createPreviewFromContent !== "function") return null
5
6
  const htmlRel = (promotedFiles || []).find((f) => f.endsWith(".html") || f.endsWith(".htm"))
6
7
  if (!htmlRel) return null
8
+
7
9
  let content = null
8
- for (const r of referenceOutputs || []) {
9
- const block = (r?.files || []).find((f) => f.relativePath === htmlRel)
10
- if (block?.content) {
11
- content = block.content
12
- break
10
+
11
+ // 1. Try reading directly from disk in cwd (the promoted winner or curator synthesis)
12
+ if (cwd) {
13
+ try {
14
+ const fullPath = path.resolve(cwd, htmlRel)
15
+ content = await fs.readFile(fullPath, "utf8")
16
+ } catch {
17
+ content = null
18
+ }
19
+ }
20
+
21
+ // 2. Fallback to synthesized files / winning files
22
+ if (!content) {
23
+ const winningFiles = options.winningFiles || options.synthesizedFiles
24
+ if (Array.isArray(winningFiles)) {
25
+ const block = winningFiles.find((f) => f.relativePath === htmlRel)
26
+ if (block?.content) content = block.content
27
+ }
28
+ }
29
+
30
+ // 3. Fallback to winning candidate by index
31
+ if (!content && typeof options.winningIndex === 'number' && referenceOutputs) {
32
+ const winnerRef = referenceOutputs[options.winningIndex - 1]
33
+ const block = (winnerRef?.files || []).find((f) => f.relativePath === htmlRel)
34
+ if (block?.content) content = block.content
35
+ }
36
+
37
+ // 4. Fallback to searching referenceOutputs
38
+ if (!content) {
39
+ for (const r of referenceOutputs || []) {
40
+ const block = (r?.files || []).find((f) => f.relativePath === htmlRel)
41
+ if (block?.content) {
42
+ content = block.content
43
+ break
44
+ }
13
45
  }
14
46
  }
47
+
15
48
  if (!content) return null
16
49
  try {
17
50
  return await liveCanvas.createPreviewFromContent({ content, title: htmlRel, filePath: path.join(cwd, htmlRel) })