@goodandready/dsh-moa 0.2.44 → 0.2.46

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.
@@ -254,6 +254,18 @@ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [
254
254
  }
255
255
  }
256
256
 
257
+ export function isLoopbackUrl(urlStr) {
258
+ if (!urlStr || typeof urlStr !== 'string') return false
259
+ try {
260
+ const u = new URL(urlStr)
261
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
262
+ const h = u.hostname.replace(/^\[|\]$/g, '')
263
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || /^127\.\d+\.\d+\.\d+$/.test(h)
264
+ } catch {
265
+ return false
266
+ }
267
+ }
268
+
257
269
  /**
258
270
  * Best-effort integration with dsh-time-machine to create a shadow Git checkpoint
259
271
  * before candidate promotion overwrites files in the project workspace.
@@ -261,8 +273,7 @@ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [
261
273
  export async function createPrePromotionCheckpoint(baseDir, candidateTarget, options = {}) {
262
274
  if (!baseDir || options.checkpoint === false) return null
263
275
  if (typeof options.checkpointFn === 'function') {
264
- const res = await bestEffort('custom checkpointFn', () => options.checkpointFn(baseDir, candidateTarget))
265
- return res || null
276
+ return (await bestEffort('custom checkpointFn', () => options.checkpointFn(baseDir, candidateTarget))) || null
266
277
  }
267
278
 
268
279
  const label = options.checkpointLabel || `moa-pre-promotion: candidate-${candidateTarget}`
@@ -279,6 +290,11 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
279
290
  return null
280
291
  }
281
292
 
293
+ if (options.timeMachineUrl && !isLoopbackUrl(options.timeMachineUrl)) {
294
+ logger.warn(`[dsh-moa] Blocked non-loopback timeMachineUrl: ${options.timeMachineUrl}`)
295
+ return null
296
+ }
297
+
282
298
  const endpoint = options.timeMachineUrl || `http://127.0.0.1:${port}/dsh-time-machine/create`
283
299
  const snap = await bestEffort('dsh-time-machine pre-promotion checkpoint', async () => {
284
300
  const res = await fetch(endpoint, {
@@ -286,6 +302,7 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
286
302
  headers: { 'Content-Type': 'application/json' },
287
303
  body: JSON.stringify({ label, cwd: baseDir }),
288
304
  signal: AbortSignal.timeout(options.timeoutMs ?? 1500),
305
+ redirect: 'error',
289
306
  })
290
307
  if (res.ok) {
291
308
  const data = await res.json()
@@ -327,9 +344,7 @@ async function doPromoteCandidateWorkspace(baseDir, winningIndex, options = { ke
327
344
  try {
328
345
  const entries = await getFilesRecursively(winningDir)
329
346
  if (entries.length === 0) {
330
- if (!options.keepMoa) {
331
- await bestEffort('clean empty candidate folder', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
332
- }
347
+ if (!options.keepMoa) await bestEffort('clean empty candidate folder', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
333
348
  return []
334
349
  }
335
350
 
@@ -490,8 +505,7 @@ export async function readCandidateFiles(baseDir, candidateTarget, options = {})
490
505
  if (!baseDir || candidateTarget === undefined || candidateTarget === null) return []
491
506
  let targetDir
492
507
  try {
493
- const resolved = resolveCandidateFolder(baseDir, candidateTarget, options)
494
- targetDir = resolved.candidateDir
508
+ targetDir = resolveCandidateFolder(baseDir, candidateTarget, options).candidateDir
495
509
  } catch {
496
510
  return []
497
511
  }
@@ -508,9 +522,7 @@ export async function readCandidateFiles(baseDir, candidateTarget, options = {})
508
522
  const st = await fs.lstat(fullPath)
509
523
  if (st.isSymbolicLink()) {
510
524
  const realFile = await fs.realpath(fullPath)
511
- if (realFile !== realTarget && !realFile.startsWith(realTarget + path.sep)) {
512
- continue
513
- }
525
+ if (realFile !== realTarget && !realFile.startsWith(realTarget + path.sep)) continue
514
526
  }
515
527
  const content = await fs.readFile(fullPath, 'utf8')
516
528
  result.push({ relativePath: rel, content, size: Buffer.byteLength(content, 'utf8') })
@@ -545,25 +557,22 @@ export function computeLineDiff(oldText = '', newText = '') {
545
557
  newEnd--
546
558
  }
547
559
 
548
- const prefixItems = []
549
- for (let k = 0; k < start; k++) prefixItems.push({ type: 'same', line: oldLines[k], oldNum: k + 1, newNum: k + 1 })
550
-
551
- const suffixItems = []
560
+ const prefixItems = oldLines.slice(0, start).map((line, k) => ({ type: 'same', line, oldNum: k + 1, newNum: k + 1 }))
552
561
  const suffixLen = (n - 1) - oldEnd
553
- for (let k = 0; k < suffixLen; k++) {
554
- suffixItems.push({ type: 'same', line: oldLines[oldEnd + 1 + k], oldNum: oldEnd + 2 + k, newNum: newEnd + 2 + k })
555
- }
562
+ const suffixItems = oldLines.slice(oldEnd + 1, oldEnd + 1 + suffixLen).map((line, k) => ({ type: 'same', line, oldNum: oldEnd + 2 + k, newNum: newEnd + 2 + k }))
556
563
 
557
564
  const subOld = oldLines.slice(start, oldEnd + 1)
558
565
  const subNew = newLines.slice(start, newEnd + 1)
559
566
  const subN = subOld.length, subM = subNew.length
560
- const middleItems = []
561
-
562
- if (subN === 0) for (let j = 0; j < subM; j++) middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
563
- else if (subM === 0) for (let i = 0; i < subN; i++) middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
564
- else if (subN > 2000 || subM > 2000) {
565
- for (let i = 0; i < subN; i++) middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
566
- for (let j = 0; j < subM; j++) middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
567
+ let middleItems
568
+ if (subN === 0) {
569
+ middleItems = subNew.map((line, j) => ({ type: 'added', line, newNum: start + j + 1 }))
570
+ } else if (subM === 0) {
571
+ middleItems = subOld.map((line, i) => ({ type: 'removed', line, oldNum: start + i + 1 }))
572
+ } else if (subN > 2000 || subM > 2000) {
573
+ middleItems = new Array(subN + subM)
574
+ for (let i = 0; i < subN; i++) middleItems[i] = { type: 'removed', line: subOld[i], oldNum: start + i + 1 }
575
+ for (let j = 0; j < subM; j++) middleItems[subN + j] = { type: 'added', line: subNew[j], newNum: start + j + 1 }
567
576
  } else {
568
577
  const dp = Array.from({ length: subN + 1 }, () => new Uint16Array(subM + 1))
569
578
  for (let i = 0; i < subN; i++) {
@@ -579,8 +588,7 @@ export function computeLineDiff(oldText = '', newText = '') {
579
588
  stack.push({ type: 'removed', line: subOld[--i], oldNum: start + i + 1 })
580
589
  }
581
590
  }
582
- middleItems.push(...stack.reverse())
591
+ middleItems = stack.reverse()
583
592
  }
584
-
585
- return [...prefixItems, ...middleItems, ...suffixItems]
593
+ return prefixItems.concat(middleItems, suffixItems)
586
594
  }
package/lib/history.js CHANGED
@@ -125,6 +125,7 @@ function normalizeRunRecord(record) {
125
125
  durationMs: Number(record.durationMs || 0),
126
126
  benchmarkReport: record.benchmarkReport || null,
127
127
  consensus: record.consensus || null,
128
+ error: record.error ? String(record.error) : null,
128
129
  }
129
130
  }
130
131
 
package/lib/index.js CHANGED
@@ -95,10 +95,21 @@ export function apply(ctx, config) {
95
95
 
96
96
  const saveConfig = async (newFields) => {
97
97
  const plain = plainConfig(newFields)
98
+ let persisted = false
98
99
  if (settingsService && typeof settingsService.replace === 'function') {
99
100
  await settingsService.replace(NS, plain)
101
+ persisted = true
100
102
  } else if (settingsService && typeof settingsService.update === 'function') {
101
103
  await settingsService.update(NS, plain)
104
+ persisted = true
105
+ }
106
+ if (config && typeof config === 'object') {
107
+ for (const [k, v] of Object.entries(plain)) {
108
+ if (config[k] && typeof config[k] === 'object' && typeof config[k].get === 'function') continue
109
+ const desc = Object.getOwnPropertyDescriptor(config, k)
110
+ if (desc && desc.get && !desc.set) continue
111
+ try { config[k] = v } catch {}
112
+ }
102
113
  }
103
114
  let liveCandidate = ctx.fiber?.config
104
115
  if (!liveCandidate && ('config' in ctx)) {
@@ -110,7 +121,7 @@ export function apply(ctx, config) {
110
121
  baseAtOverride[key] = JSON.stringify(currentLive[key])
111
122
  }
112
123
  localOverrides = { ...(localOverrides || {}), ...plain }
113
- return live()
124
+ return { ok: true, persisted, ...live() }
114
125
  }
115
126
 
116
127
  const callLlm = createLlmCaller(ctx)
package/lib/moa-budget.js CHANGED
@@ -29,6 +29,72 @@ export function estimateCandidateRunCost(slots = [], prices = {}, promptTokens =
29
29
  }
30
30
  }
31
31
 
32
+ /**
33
+ * Estimates downstream synthesis costs for Round 2 and Judge panel.
34
+ */
35
+ export function estimateDownstreamCost(aggregator, judgeModels = [], numCandidates = 1, prices = {}, expectedOutputTokens = 2048, promptTokens = 500, peerCritiqueEnabled = false) {
36
+ let downstream = 0
37
+ const aggInput = promptTokens + (expectedOutputTokens * numCandidates) + 200
38
+ const aggOutput = Math.min(expectedOutputTokens, 2048)
39
+
40
+ if (aggregator?.provider && aggregator?.model) {
41
+ const aggCost = estimateTokenCost(aggregator, { inputTokens: aggInput, outputTokens: aggOutput }, prices)
42
+ downstream += aggCost?.costUsd || 0
43
+ }
44
+ if (Array.isArray(judgeModels) && judgeModels.length > 0) {
45
+ for (const jm of judgeModels) {
46
+ if (jm?.provider && jm?.model) {
47
+ const jCost = estimateTokenCost(jm, { inputTokens: aggInput, outputTokens: aggOutput }, prices)
48
+ downstream += jCost?.costUsd || 0
49
+ }
50
+ }
51
+ }
52
+ return downstream
53
+ }
54
+
55
+ export function estimateRound2Cost(candidates = [], prices = {}, expectedOutputTokens = 2048, promptTokens = 500) {
56
+ let r2Total = 0
57
+ const numCandidates = candidates.length
58
+ if (numCandidates <= 1) return 0
59
+ // Each candidate reviews proposals from opponents (numCandidates - 1)
60
+ const r2Input = promptTokens + (expectedOutputTokens * (numCandidates - 1)) + 150
61
+ for (const c of candidates) {
62
+ const slot = c.slot || { provider: c.provider, model: c.model }
63
+ const cost = estimateTokenCost(slot, { inputTokens: r2Input, outputTokens: expectedOutputTokens }, prices)?.costUsd || 0
64
+ r2Total += cost
65
+ }
66
+ return r2Total
67
+ }
68
+
69
+ /**
70
+ * Phase-by-phase runtime budget check (#158).
71
+ * Checks if current spent + next phase estimate would exceed budget.
72
+ */
73
+ export function checkPhaseBudget({
74
+ phaseName = '',
75
+ currentSpentUsd = 0,
76
+ nextPhaseCostUsd = 0,
77
+ maxBudgetUsd = 0,
78
+ action = 'abort',
79
+ enabled = true,
80
+ }) {
81
+ if (!enabled || typeof maxBudgetUsd !== 'number' || maxBudgetUsd <= 0) {
82
+ return { allowed: true }
83
+ }
84
+ const projectedTotal = Number((currentSpentUsd + nextPhaseCostUsd).toFixed(6))
85
+ if (projectedTotal <= maxBudgetUsd) {
86
+ return { allowed: true, projectedTotal }
87
+ }
88
+ return {
89
+ allowed: false,
90
+ action,
91
+ currentSpentUsd,
92
+ projectedTotal,
93
+ maxBudgetUsd,
94
+ reason: `Projected cost ($${projectedTotal.toFixed(4)}) before ${phaseName} exceeds max budget ($${maxBudgetUsd.toFixed(4)}). Current spent: $${currentSpentUsd.toFixed(4)}.`,
95
+ }
96
+ }
97
+
32
98
  /**
33
99
  * Applies budget guardrails before initiating parallel candidate execution.
34
100
  * Evaluates candidate runs, peer critique (Round 2) and downstream synthesis phases.
@@ -56,7 +122,7 @@ export function applyBudgetGuardrails({
56
122
  }
57
123
  }
58
124
 
59
- // Calculate full input tokens from userPrompt length + messages context
125
+ // Calculate full input tokens from userPrompt length + messages context + system prompt overhead
60
126
  let contextChars = Number(promptLength) || 1000
61
127
  if (typeof projectContext === 'string' && projectContext.length > 0 && !String(promptLength).includes(String(projectContext.length))) {
62
128
  contextChars += projectContext.length
@@ -72,36 +138,27 @@ export function applyBudgetGuardrails({
72
138
  }
73
139
  }
74
140
  }
75
- const promptTokens = Math.max(50, Math.round(contextChars / 4))
141
+ // System prompt and framing template overhead (~600 chars = ~150 tokens)
142
+ const promptTokens = Math.max(50, Math.round((contextChars + 600) / 4))
76
143
  const expectedOutputTokens = typeof maxTokens === 'number' && maxTokens > 0 ? maxTokens : 2048
77
144
 
78
145
  // Base candidate cost per slot (Round 1)
79
146
  const { totalEstimatedUsd, slotEstimates } = estimateCandidateRunCost(references, prices, promptTokens, expectedOutputTokens)
80
147
 
81
- // Estimate cost for downstream phases (Aggregator / Judge panel)
148
+ // Calculate downstream cost with specific candidate count
82
149
  function calcDownstreamUsd(numCandidates) {
83
- let downstream = 0
84
- if (aggregator?.provider && aggregator?.model) {
85
- const aggInput = promptTokens + expectedOutputTokens * numCandidates
86
- const aggCost = estimateTokenCost(aggregator, { inputTokens: aggInput, outputTokens: expectedOutputTokens }, prices)
87
- downstream += aggCost?.costUsd || 0
88
- }
89
- if (Array.isArray(judgeModels) && judgeModels.length > 0) {
90
- for (const jm of judgeModels) {
91
- if (jm?.provider && jm?.model) {
92
- const jInput = promptTokens + expectedOutputTokens * numCandidates
93
- const jCost = estimateTokenCost(jm, { inputTokens: jInput, outputTokens: expectedOutputTokens }, prices)
94
- downstream += jCost?.costUsd || 0
95
- }
96
- }
97
- }
98
- return downstream
150
+ return estimateDownstreamCost(aggregator, judgeModels, numCandidates, prices, expectedOutputTokens, promptTokens, peerCritiqueEnabled)
99
151
  }
100
152
 
101
- // Multiplier if peer critique (Round 2) is enabled: each candidate runs another turn with input from all peers
102
- const roundMultiplier = peerCritiqueEnabled ? 2 : 1
153
+ // Calculate Round 2 (Peer Critique) cost if enabled
154
+ function calcRound2Usd(candidates) {
155
+ if (!peerCritiqueEnabled || candidates.length <= 1) return 0
156
+ return estimateRound2Cost(candidates, prices, expectedOutputTokens, promptTokens)
157
+ }
158
+
159
+ const r2Usd = calcRound2Usd(references)
103
160
  const downstreamUsd = calcDownstreamUsd(references.length)
104
- const fullEstimatedUsd = Number(((totalEstimatedUsd * roundMultiplier) + downstreamUsd).toFixed(6))
161
+ const fullEstimatedUsd = Number((totalEstimatedUsd + r2Usd + downstreamUsd).toFixed(6))
105
162
 
106
163
  if (fullEstimatedUsd <= maxBudgetUsd) {
107
164
  return {
@@ -124,22 +181,25 @@ export function applyBudgetGuardrails({
124
181
  }
125
182
 
126
183
  // Action is 'trim': Sort by estimated cost ascending (cheapest first)
127
- // Factor downstream synthesis cost into candidate selection (#158)
184
+ // Factor downstream synthesis cost and peer critique cost into candidate selection (#158)
128
185
  const sorted = [...slotEstimates].sort((a, b) => a.costUsd - b.costUsd)
129
186
  const kept = []
130
- let cumulativeCandidateCost = 0
131
187
 
132
188
  for (const item of sorted) {
133
- const candidateCost = item.costUsd * roundMultiplier
134
- const nextDownstream = calcDownstreamUsd(kept.length + 1)
135
- if (cumulativeCandidateCost + candidateCost + nextDownstream <= maxBudgetUsd) {
189
+ const candidatePool = [...kept, item.slot]
190
+ const candR1 = candidatePool.reduce((acc, c) => {
191
+ const e = slotEstimates.find((s) => s.slot === c)
192
+ return acc + (e?.costUsd || 0)
193
+ }, 0)
194
+ const candR2 = calcRound2Usd(candidatePool)
195
+ const nextDownstream = calcDownstreamUsd(candidatePool.length)
196
+ if (candR1 + candR2 + nextDownstream <= maxBudgetUsd) {
136
197
  kept.push(item.slot)
137
- cumulativeCandidateCost += candidateCost
138
198
  }
139
199
  }
140
200
 
141
201
  if (kept.length === 0) {
142
- const minCandidateCost = (sorted[0]?.costUsd || 0) * roundMultiplier
202
+ const minCandidateCost = sorted[0]?.costUsd || 0
143
203
  const minTotal = minCandidateCost + calcDownstreamUsd(1)
144
204
  return {
145
205
  allowed: false,
@@ -153,7 +213,13 @@ export function applyBudgetGuardrails({
153
213
  }
154
214
  }
155
215
 
156
- const finalEstimatedUsd = Number((cumulativeCandidateCost + calcDownstreamUsd(kept.length)).toFixed(6))
216
+ const finalR1 = kept.reduce((acc, c) => {
217
+ const e = slotEstimates.find((s) => s.slot === c)
218
+ return acc + (e?.costUsd || 0)
219
+ }, 0)
220
+ const finalR2 = calcRound2Usd(kept)
221
+ const finalEstimatedUsd = Number((finalR1 + finalR2 + calcDownstreamUsd(kept.length)).toFixed(6))
222
+
157
223
  return {
158
224
  allowed: true,
159
225
  references: kept,
@@ -165,3 +231,41 @@ export function applyBudgetGuardrails({
165
231
  reason: `Trimmed candidate pool from ${references.length} to ${kept.length} models to stay within budget ($${maxBudgetUsd.toFixed(4)}).`,
166
232
  }
167
233
  }
234
+
235
+ /**
236
+ * Constructs a structured budget abort failure payload (#158).
237
+ */
238
+ export function createBudgetAbortPayload({
239
+ reason,
240
+ primaryJudge,
241
+ referenceOutputs = [],
242
+ currentSpentUsd = 0,
243
+ preset = {},
244
+ isRefinement = false,
245
+ isFastMode = false,
246
+ collectedCtx = {},
247
+ startTime = Date.now(),
248
+ }) {
249
+ const aggLabel = (primaryJudge?.provider && primaryJudge?.model) ? `${primaryJudge.provider}:${primaryJudge.model}` : "none";
250
+ return {
251
+ kind: "failure",
252
+ content: `🛑 Budget Guardrail Abort: ${reason}`,
253
+ aggregator: aggLabel,
254
+ references: referenceOutputs,
255
+ presetName: preset?.name || "default",
256
+ isRefinement,
257
+ isFastMode,
258
+ winningIndex: 0,
259
+ winnerModel: "none",
260
+ promotedFiles: [],
261
+ usage: {
262
+ totalTokens: referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0),
263
+ totalCostUsd: Number(currentSpentUsd.toFixed(5)),
264
+ candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
265
+ aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
266
+ },
267
+ skippedFiles: collectedCtx?.skippedFiles || 0,
268
+ skippedList: collectedCtx?.skippedList || [],
269
+ durationMs: Date.now() - startTime,
270
+ };
271
+ }