@tduyng/pi-prewalk 0.0.0-stage → 0.0.2

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/src/logic.ts ADDED
@@ -0,0 +1,384 @@
1
+ import { tmpdir } from 'node:os'
2
+ import { resolve, sep } from 'node:path'
3
+ import type { PrewalkMode } from './config.js'
4
+
5
+ export const STATE_TYPE = 'prewalk'
6
+
7
+ export const SKILL_BLOCK_PREFIX = '<skill '
8
+
9
+ export const PLAN_TOOL_NAME = 'prewalk_plan'
10
+
11
+ export const MAX_PLAN_STEPS = 20
12
+
13
+ export const MAX_PLAN_FACTS = 100
14
+
15
+ export const PLANNING_BRIEF = `You are the planner for this task. A cheaper, weaker model implements your plan from this same context. It will not re-derive anything and it cannot recover from a vague step.
16
+ 1. Explore the relevant code until you understand the change fully. Prefer rg/fd with narrow scope over broad reads. Use web_search for facts the codebase does not hold, never to look up the fix, and record what you learn in the plan's facts.
17
+ 2. Call ${PLAN_TOOL_NAME} once, carrying everything the implementer would otherwise have to work out again: the facts you established (with file:line), and for each step its file, the exact anchor text to edit at, what changes, and the command that verifies it. If a step needs a judgement call the implementer cannot make, make it now or split the step.
18
+ 3. Then land the first step's edit yourself and run its verification. Work autonomously until the task is complete. Do not stop to ask for plan approval.
19
+ 4. A task longer than ${MAX_PLAN_STEPS} steps plans its first phase only. When the executor finishes, the task comes back to you for review in this same context, and you plan the next phase then.`
20
+
21
+ export const EDIT_TOOLS = new Set(['edit', 'write', 'multiedit', 'notebookedit'])
22
+
23
+ export const READ_ONLY_TOOLS = new Set(['read', 'grep', 'find', 'ls', 'glob'])
24
+
25
+ export type PlanStep = {
26
+ file: string
27
+ change: string
28
+ anchor?: string
29
+ verify?: string
30
+ }
31
+
32
+ export type PrewalkPlan = {
33
+ summary: string
34
+ facts: string[]
35
+ steps: PlanStep[]
36
+ check: string
37
+ outOfScope: string[]
38
+ }
39
+
40
+ export type ModelRef = { provider: string; id: string }
41
+
42
+ const MIN_SUFFIX = 3
43
+
44
+ export function makeSpecPredicate(models: readonly ModelRef[]): (token: string) => boolean {
45
+ return (token) => {
46
+ const lower = token.toLowerCase()
47
+ if (models.some((m) => modelRef(m).toLowerCase() === lower || m.id.toLowerCase() === lower)) {
48
+ return true
49
+ }
50
+ if (lower.length < MIN_SUFFIX) return false
51
+ return models.filter((m) => m.id.toLowerCase().endsWith(lower)).length === 1
52
+ }
53
+ }
54
+
55
+ export type PrewalkState = {
56
+ executor: string
57
+ planner: string | null
58
+ swapped: boolean
59
+ forced: ForceRoute | null
60
+ inFlight: boolean
61
+ plan: PrewalkPlan | null
62
+ planDemanded: boolean
63
+ stepIndex: number
64
+ /** Failed tool results in a row on the step in flight, which is what a wall looks like. */
65
+ failures: number
66
+ /** Upgrades spent on this task, so a wall cannot ping-pong between the models. */
67
+ escalations: number
68
+ /** The last failure, trimmed, which is what the planner reads when it takes the task back. */
69
+ wall: string | null
70
+ reviewing: boolean
71
+ }
72
+
73
+ export const MAX_WALL_CHARS = 400
74
+
75
+ export type EscalationBar = { failures: number; upgrades: number }
76
+
77
+ export const ESCALATION_BAR: EscalationBar = { failures: 3, upgrades: 2 }
78
+
79
+ export const PLANNER_GRACE_CALLS = 3
80
+
81
+ /** Below this a prompt is a redirect rather than a task: `go on` stays, `fix tests` routes. */
82
+ export const MIN_PROMPT_CHARS = 8
83
+
84
+ export function shouldAsk(text: string): boolean {
85
+ return text.trim().length >= MIN_PROMPT_CHARS
86
+ }
87
+
88
+ export type PrewalkRole = 'planner' | 'executor'
89
+
90
+ export type ForceRoute = 'plan' | 'run'
91
+
92
+ export type ParsedPrewalkArgs =
93
+ | { kind: 'bare' }
94
+ | { kind: 'status' }
95
+ | { kind: 'stop' }
96
+ | { kind: 'models' }
97
+ | { kind: 'mode'; mode: PrewalkMode }
98
+ | { kind: 'role'; role: PrewalkRole; chain: string[] }
99
+ | { kind: 'force'; route: ForceRoute; task: string }
100
+ | { kind: 'start'; planner: string | null; executor: string | null; task: string }
101
+
102
+ const STOP_WORDS = new Set(['stop', 'cancel', 'clear', 'off', 'done'])
103
+ const STATUS_WORDS = new Set(['status', 'state', '?'])
104
+ const MODEL_LIST_WORDS = new Set(['models', 'list', 'ls'])
105
+ const MODE_WORDS = new Set(['mode', 'routing'])
106
+ const ROLE_WORDS = new Set<string>(['planner', 'executor'])
107
+ const ARM_WORDS = new Set(['on', 'auto', 'arm'])
108
+ const AUTO_WORDS = new Set(['auto', 'on', 'routed'])
109
+ const PLAN_MODE_WORDS = new Set(['plan', 'classic'])
110
+ const OFF_MODE_WORDS = new Set(['off', 'none', 'cheap'])
111
+ const ROUTE_WORDS = new Map<string, ForceRoute>([
112
+ ['plan', 'plan'],
113
+ ['run', 'run'],
114
+ ['cheap', 'run']
115
+ ])
116
+
117
+ function parseMode(text: string): PrewalkMode | null {
118
+ const word = splitFirstToken(text.trim().toLowerCase())[0]
119
+ if (OFF_MODE_WORDS.has(word)) return 'off'
120
+ if (AUTO_WORDS.has(word) || PLAN_MODE_WORDS.has(word)) return 'plan'
121
+ return null
122
+ }
123
+
124
+ function leadingModelSpecs(text: string, isModelSpec: (token: string) => boolean): string[] {
125
+ const chain: string[] = []
126
+ let rest = text
127
+ while (rest !== '') {
128
+ const [token, after] = splitFirstToken(rest)
129
+ if (!isModelSpec(token)) break
130
+ chain.push(token)
131
+ rest = after
132
+ }
133
+ return chain
134
+ }
135
+
136
+ function splitFirstToken(text: string): [string, string] {
137
+ const space = text.search(/\s/)
138
+ if (space === -1) return [text, '']
139
+ return [text.slice(0, space), text.slice(space).trim()]
140
+ }
141
+
142
+ export function parseArgs(args: string, isModelSpec: (token: string) => boolean): ParsedPrewalkArgs {
143
+ const trimmed = args.trim()
144
+ if (trimmed === '') return { kind: 'bare' }
145
+ const lower = trimmed.toLowerCase()
146
+ if (STOP_WORDS.has(lower)) return { kind: 'stop' }
147
+ if (STATUS_WORDS.has(lower)) return { kind: 'status' }
148
+ let rest = trimmed
149
+ const [head, afterHead] = splitFirstToken(trimmed)
150
+ if (ARM_WORDS.has(head.toLowerCase())) rest = afterHead
151
+ if (rest === '') return { kind: 'start', planner: null, executor: null, task: '' }
152
+ const [first, afterFirst] = splitFirstToken(rest)
153
+ if (!isModelSpec(first)) {
154
+ const word = first.toLowerCase()
155
+ if (MODEL_LIST_WORDS.has(word)) return { kind: 'models' }
156
+ const forced = ROUTE_WORDS.get(word)
157
+ if (forced !== undefined) return { kind: 'force', route: forced, task: afterFirst }
158
+ if (ROLE_WORDS.has(word)) {
159
+ const role: PrewalkRole = word === 'planner' ? 'planner' : 'executor'
160
+ return { kind: 'role', role, chain: leadingModelSpecs(afterFirst, isModelSpec) }
161
+ }
162
+ if (MODE_WORDS.has(word)) {
163
+ const mode = parseMode(afterFirst)
164
+ return mode === null ? { kind: 'status' } : { kind: 'mode', mode }
165
+ }
166
+ return { kind: 'start', planner: null, executor: null, task: rest }
167
+ }
168
+ const [second, afterSecond] = splitFirstToken(afterFirst)
169
+ if (second !== '' && isModelSpec(second)) {
170
+ return { kind: 'start', planner: first, executor: second, task: afterSecond }
171
+ }
172
+ return { kind: 'start', planner: null, executor: first, task: afterFirst }
173
+ }
174
+
175
+ export type UserTextMessage = {
176
+ role: string
177
+ toolName?: string
178
+ content?: string | Array<{ type: string; text?: string }>
179
+ }
180
+
181
+ export function contextForModel<T extends UserTextMessage>(state: PrewalkState, messages: T[]): T[] {
182
+ if (state.plan !== null) replaceLatestPlanResult(messages, renderHandoff(state.plan))
183
+ return messages
184
+ }
185
+
186
+ export function wallBrief(plan: PrewalkPlan, stepIndex: number, failures: number): string {
187
+ const total = plan.steps.length
188
+ const step = Math.min(stepIndex + 1, total)
189
+ return `${NUDGE_PREFIX} ${failures} failures on step ${step}/${total}. The planner model takes this task back from the next request until its next edit: find the cause and fix it, or call ${PLAN_TOOL_NAME} with the steps that are left.`
190
+ }
191
+
192
+ export function reviewPrompt(plan: PrewalkPlan, stepIndex: number): string {
193
+ const total = plan.steps.length
194
+ return [
195
+ `${NUDGE_PREFIX} Review. The executor stopped with ${Math.min(stepIndex, total)}/${total} plan steps matched. Its edits are in the transcript above, so do not re-read files you can see there.`,
196
+ `1. Run \`git diff --stat\` and the final check \`${plan.check}\`.`,
197
+ '2. Check the work against the goal and fix any defect yourself.',
198
+ `3. If the goal needs work the plan did not cover, call ${PLAN_TOOL_NAME} with the remaining steps and land the first one.`,
199
+ '4. If the work is correct and complete, reply in one line.'
200
+ ].join('\n')
201
+ }
202
+
203
+ export function isScratchPath(path: string, cwd: string): boolean {
204
+ if (path === '') return false
205
+ const full = resolve(cwd, path)
206
+ const under = (root: string) => full === root || full.startsWith(root + sep)
207
+ return under(resolve(tmpdir())) && !under(resolve(cwd))
208
+ }
209
+
210
+ const HANDOFF_RULES = [
211
+ "Work the steps in order: make the edit, run that step's verify, then move to the next.",
212
+ 'Read a file only to get the exact text you are about to edit, or when an anchor does not match. Do not re-explore; the findings above already settled what the code does.',
213
+ 'Change nothing the steps do not name. No renames, no refactors, no extra error handling, no new files.',
214
+ 'If a step keeps failing, report the exact error rather than switching to a different approach. Repeated failures on one step hand the task back to the planner.',
215
+ 'When every step is done, run the final check and report what it printed.'
216
+ ]
217
+
218
+ export function renderHandoff(plan: PrewalkPlan): string {
219
+ const lines = [
220
+ '[prewalk plan] The exploration above settled this plan. It is authoritative: follow it, do not redesign it.',
221
+ '',
222
+ `Goal: ${plan.summary}`
223
+ ]
224
+ if (plan.facts.length > 0) {
225
+ lines.push('', 'Established by the exploration (do not re-derive):')
226
+ for (const fact of plan.facts) lines.push(`- ${fact}`)
227
+ }
228
+ lines.push('', 'Steps, in order:')
229
+ plan.steps.forEach((step, i) => {
230
+ lines.push(`${i + 1}. ${step.file}: ${step.change}`)
231
+ if (step.anchor) lines.push(` anchor: ${step.anchor}`)
232
+ if (step.verify) lines.push(` verify: ${step.verify}`)
233
+ })
234
+ if (plan.outOfScope.length > 0) {
235
+ lines.push('', `Out of scope, do not touch: ${plan.outOfScope.join('; ')}`)
236
+ }
237
+ lines.push('', `Final check: ${plan.check}`, '', 'How to work this plan:')
238
+ for (const rule of HANDOFF_RULES) lines.push(`- ${rule}`)
239
+ return lines.join('\n')
240
+ }
241
+
242
+ function replaceLatestPlanResult(messages: readonly UserTextMessage[], text: string): void {
243
+ const latest = messages.findLast((m) => m.toolName === PLAN_TOOL_NAME)
244
+ if (latest) latest.content = Array.isArray(latest.content) ? [{ type: 'text', text }] : text
245
+ }
246
+
247
+ export type SwapDecision = 'ignore' | 'demand-plan' | 'swap'
248
+
249
+ export function swapDecision(
250
+ state: PrewalkState,
251
+ toolName: string,
252
+ inert = false,
253
+ demandedThisTurn = false
254
+ ): SwapDecision {
255
+ if (state.swapped || state.reviewing || inert) return 'ignore'
256
+ const name = toolName.toLowerCase()
257
+ if (!EDIT_TOOLS.has(name) && name !== 'bash') return 'ignore'
258
+ if (state.plan === null && (!state.planDemanded || demandedThisTurn)) return 'demand-plan'
259
+ return 'swap'
260
+ }
261
+
262
+ export const PLAN_DEMAND_REASON = `Before this edit, call ${PLAN_TOOL_NAME} once. A cheaper model takes over the moment the plan lands and implements it without re-reading what you read, so the plan carries what you know: the findings it cannot re-derive (with file:line), and for each step its file, the exact anchor text, the change, and the command that verifies it. Then repeat this edit.`
263
+
264
+ function samePath(stepFile: string, editedPath: string): boolean {
265
+ const a = stepFile.replace(/\\/g, '/').replace(/^\.\//, '')
266
+ const b = editedPath.replace(/\\/g, '/')
267
+ return a === b || b.endsWith(`/${a}`) || a.endsWith(`/${b}`)
268
+ }
269
+
270
+ export function planProgress(plan: PrewalkPlan, stepIndex: number, matched: number) {
271
+ const total = plan.steps.length
272
+ const index = matched === -1 ? stepIndex : matched + 1
273
+ const next = plan.steps[index]
274
+ if (!next) {
275
+ return {
276
+ index,
277
+ footer: `[prewalk] ${total}/${total} steps done. Run the final check (${plan.check}) and report what it prints.`
278
+ }
279
+ }
280
+ const head =
281
+ matched === -1
282
+ ? `[prewalk] still on step ${stepIndex + 1}/${total}:`
283
+ : `[prewalk] step ${matched + 1}/${total} done. Next, step ${index + 1}/${total}:`
284
+ const parts = [`${head} ${next.file}: ${next.change}`]
285
+ if (next.anchor) parts.push(`anchor: ${next.anchor}`)
286
+ if (next.verify) parts.push(`verify: ${next.verify}`)
287
+ return { index, footer: parts.join('\n') }
288
+ }
289
+
290
+ export function stepForPath(plan: PrewalkPlan, stepIndex: number, path: string): number {
291
+ for (let i = stepIndex; i < plan.steps.length; i++) {
292
+ const step = plan.steps[i]
293
+ if (step && samePath(step.file, path)) return i
294
+ }
295
+ return -1
296
+ }
297
+
298
+ const COMMAND_TOKEN = /[^\s'"`;|&<>()]+/g
299
+
300
+ export function stepForCommand(plan: PrewalkPlan, stepIndex: number, command: string): number {
301
+ const tokens: string[] = command.match(COMMAND_TOKEN) ?? []
302
+ for (const token of tokens) {
303
+ const full = stepForPath(plan, stepIndex, token)
304
+ if (full !== -1) return full
305
+ }
306
+ for (let i = stepIndex; i < plan.steps.length; i++) {
307
+ const step = plan.steps[i]
308
+ if (!step) continue
309
+ const base = step.file.replace(/\\/g, '/').split('/').pop() ?? ''
310
+ if (base !== '' && tokens.includes(base)) return i
311
+ }
312
+ return -1
313
+ }
314
+
315
+ export const NUDGE_PREFIX = '[prewalk]'
316
+
317
+ export function continuationNudge(plan: PrewalkPlan, stepIndex: number): string {
318
+ const total = plan.steps.length
319
+ const next = plan.steps[stepIndex]
320
+ if (!next) {
321
+ return `${NUDGE_PREFIX} All ${total} steps done. Run the final check (\`${plan.check}\`) and report what it prints.`
322
+ }
323
+ const verify = next.verify ? ` Run \`${next.verify}\` to verify.` : ''
324
+ return `${NUDGE_PREFIX} The plan is not finished (${stepIndex}/${total} steps done). Continue with step ${stepIndex + 1}/${total}: ${next.file}: ${next.change}.${verify}`
325
+ }
326
+
327
+ export function modelRef(model: ModelRef): string {
328
+ return `${model.provider}/${model.id}`
329
+ }
330
+
331
+ export function resolveModelSpec<T extends ModelRef>(
332
+ models: readonly T[],
333
+ spec: string
334
+ ): { model: T } | { error: string } {
335
+ const lower = spec.toLowerCase()
336
+ const exact = models.filter((m) => modelRef(m).toLowerCase() === lower || m.id.toLowerCase() === lower)
337
+ if (exact.length === 1 && exact[0]) return { model: exact[0] }
338
+ if (exact.length > 1) return { error: ambiguous(exact, spec) }
339
+ const suffix = models.filter((m) => m.id.toLowerCase().endsWith(lower))
340
+ if (suffix.length === 1 && suffix[0]) return { model: suffix[0] }
341
+ if (suffix.length > 1) return { error: ambiguous(suffix, spec) }
342
+ return { error: `no model matching "${spec}"` }
343
+ }
344
+
345
+ function ambiguous(matches: readonly ModelRef[], spec: string): string {
346
+ const providers = [...new Set(matches.map((m) => m.provider))].join(', ')
347
+ return `"${spec}" matches ${matches.length} models (${providers}). Use provider/id.`
348
+ }
349
+
350
+ export function shortName(ref: string): string {
351
+ const slash = ref.indexOf('/')
352
+ return slash === -1 ? ref : ref.slice(slash + 1)
353
+ }
354
+
355
+ function badgeName(ref: string): string {
356
+ const slash = ref.lastIndexOf('/')
357
+ return slash === -1 ? ref : ref.slice(slash + 1)
358
+ }
359
+
360
+ export function statusBadge(state: PrewalkState): string {
361
+ const pair = `${badgeName(state.planner ?? '?')}->${badgeName(state.executor)}`
362
+ if (!state.inFlight && state.plan === null) return `prewalk route ${pair}`
363
+ if (state.plan === null) return `prewalk ${state.swapped ? 'run' : 'plan'} ${pair}`
364
+ const total = state.plan.steps.length
365
+ if (state.reviewing) return `prewalk review ${state.stepIndex}/${total}`
366
+ if (!state.swapped) return `prewalk replan ${state.stepIndex}/${total}`
367
+ return state.stepIndex >= total ? `prewalk done ${total}/${total}` : `prewalk exec ${state.stepIndex}/${total}`
368
+ }
369
+
370
+ export function statusLabel(state: PrewalkState): string {
371
+ const planner = state.planner ? shortName(state.planner) : 'the current model'
372
+ const executor = shortName(state.executor)
373
+ if (state.plan === null) {
374
+ if (!state.inFlight) return `Prewalk routing between ${planner} and ${executor}, waiting for a task`
375
+ return state.swapped
376
+ ? `Prewalk running this task on ${executor}, no plan written`
377
+ : `Prewalk planning on ${planner}, swap to ${executor} on first edit`
378
+ }
379
+ const total = state.plan.steps.length
380
+ return state.stepIndex >= total
381
+ ? `Prewalk done, ${total}/${total} steps, on ${executor}`
382
+ : `Prewalk ${state.stepIndex}/${total} on ${executor}`
383
+ }
384
+
@@ -0,0 +1,127 @@
1
+ import type { SessionEntry } from '@earendil-works/pi-coding-agent'
2
+ import { Validator } from 'ata-validator'
3
+ import { t } from 'ata-validator/t'
4
+
5
+ import { PLAN_TOOL_NAME, STATE_TYPE } from './logic.js'
6
+
7
+ export type RouteName = 'plan' | 'run'
8
+
9
+ export interface RouteRecord {
10
+ route: RouteName
11
+ /** True when a person chose the route, which makes it a control rather than evidence. */
12
+ forced: boolean
13
+ turns: number
14
+ cost: number
15
+ edits: number
16
+ /** The plan's own `check` ran in a shell call, which only a planned task can have. */
17
+ checked: boolean
18
+ models: string[]
19
+ }
20
+
21
+ const BashCall = t.object({ command: t.string() })
22
+ const isBashCall = new Validator(BashCall).isValidObject
23
+
24
+ const StoredPlan = t.object({ check: t.string() })
25
+
26
+ const StoredState = t.object({
27
+ forced: t.optional(t.union([t.literal('plan'), t.literal('run')])),
28
+ plan: t.optional(StoredPlan)
29
+ })
30
+ const isStoredState = new Validator(StoredState).isValidObject
31
+
32
+ const EditTools = new Set(['edit', 'write', 'multiedit', 'notebookedit'])
33
+
34
+ type Span = RouteRecord & { check: string | null }
35
+
36
+ function openSpan(route: RouteName | null, forced: boolean): Span {
37
+ return {
38
+ route: route ?? 'run',
39
+ forced,
40
+ turns: 0,
41
+ cost: 0,
42
+ edits: 0,
43
+ checked: false,
44
+ models: [],
45
+ check: null
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Every task prewalk routed, read back off the session transcript.
51
+ *
52
+ * A task is the span from one user prompt to the next. The route comes from the
53
+ * state entry in force when the prompt landed.
54
+ */
55
+ export function routesOf(entries: readonly SessionEntry[]): RouteRecord[] {
56
+ const records: RouteRecord[] = []
57
+ let armed = false
58
+ let byHand: RouteName | null = null
59
+ let span: Span | null = null
60
+
61
+ const close = (): void => {
62
+ if (span === null) return
63
+ const { check: _check, ...record } = span
64
+ records.push(record)
65
+ span = null
66
+ }
67
+
68
+ for (const entry of entries) {
69
+ if (entry.type === 'custom' && entry.customType === STATE_TYPE && isStoredState(entry.data)) {
70
+ armed = true
71
+ if (entry.data.forced !== undefined) byHand = entry.data.forced
72
+ if (span !== null && entry.data.plan !== undefined) span.check = entry.data.plan.check
73
+ continue
74
+ }
75
+ if (entry.type !== 'message') continue
76
+ if (entry.message.role === 'user') {
77
+ close()
78
+ span = armed ? openSpan(byHand, byHand !== null) : null
79
+ byHand = null
80
+ continue
81
+ }
82
+ if (entry.message.role !== 'assistant' || span === null) continue
83
+
84
+ const message = entry.message
85
+ span.turns += 1
86
+ span.cost += message.usage.cost.total
87
+ if (span.models.at(-1) !== message.model) span.models.push(message.model)
88
+ for (const block of message.content) {
89
+ if (block.type !== 'toolCall') continue
90
+ if (EditTools.has(block.name)) span.edits += 1
91
+ if (block.name === PLAN_TOOL_NAME) span.route = 'plan'
92
+ if (block.name !== 'bash' || span.check === null) continue
93
+ if (isBashCall(block.arguments) && block.arguments.command.includes(span.check)) {
94
+ span.checked = true
95
+ }
96
+ }
97
+ }
98
+ close()
99
+ return records
100
+ }
101
+
102
+ export interface RouteSummary {
103
+ route: RouteName
104
+ tasks: number
105
+ byHand: number
106
+ edits: number
107
+ checked: number
108
+ totalCost: number
109
+ medianCost: number
110
+ }
111
+
112
+ export function summarizeRoutes(records: readonly RouteRecord[]): RouteSummary[] {
113
+ const routes: RouteName[] = ['plan', 'run']
114
+ return routes.map((route) => {
115
+ const forRoute = records.filter((record) => record.route === route)
116
+ const costs = forRoute.map((record) => record.cost).toSorted((a, b) => a - b)
117
+ return {
118
+ route,
119
+ tasks: forRoute.length,
120
+ byHand: forRoute.filter((record) => record.forced).length,
121
+ edits: forRoute.reduce((sum, record) => sum + record.edits, 0),
122
+ checked: forRoute.filter((record) => record.checked).length,
123
+ totalCost: forRoute.reduce((sum, record) => sum + record.cost, 0),
124
+ medianCost: costs.length === 0 ? 0 : (costs[Math.floor(costs.length / 2)] ?? 0)
125
+ }
126
+ })
127
+ }