@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.
- package/lib/file-workspace.js +35 -27
- package/lib/history.js +1 -0
- package/lib/index.js +12 -1
- package/lib/moa-budget.js +134 -30
- package/lib/moa-runner.js +99 -106
- package/lib/moa-test-gate.js +210 -33
- package/lib/routes.js +80 -110
- package/package.json +1 -1
package/lib/file-workspace.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
else if (subM === 0)
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
|
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
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
|
-
|
|
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
|
-
//
|
|
148
|
+
// Calculate downstream cost with specific candidate count
|
|
82
149
|
function calcDownstreamUsd(numCandidates) {
|
|
83
|
-
|
|
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
|
-
//
|
|
102
|
-
|
|
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((
|
|
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
|
|
134
|
-
const
|
|
135
|
-
|
|
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 =
|
|
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
|
|
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
|
+
}
|