@dirwin517/bot-farm 0.1.0

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.
Files changed (55) hide show
  1. package/.idea/bot-farm.iml +9 -0
  2. package/.idea/misc.xml +6 -0
  3. package/.idea/modules.xml +8 -0
  4. package/.idea/vcs.xml +6 -0
  5. package/LICENSE +21 -0
  6. package/README.md +584 -0
  7. package/package.json +13 -0
  8. package/src/api.mjs +567 -0
  9. package/src/cli.mjs +241 -0
  10. package/src/craft.mjs +172 -0
  11. package/src/db.mjs +93 -0
  12. package/src/farmhands.mjs +111 -0
  13. package/src/git.mjs +578 -0
  14. package/src/identity.mjs +199 -0
  15. package/src/legacy-defaults.mjs +44 -0
  16. package/src/mcp.mjs +681 -0
  17. package/src/mesh.mjs +222 -0
  18. package/src/packets.mjs +125 -0
  19. package/src/patches.mjs +141 -0
  20. package/src/pipelines.mjs +2145 -0
  21. package/src/pricing.mjs +91 -0
  22. package/src/projects.mjs +148 -0
  23. package/src/public/classic.html +1810 -0
  24. package/src/public/index.html +3284 -0
  25. package/src/questions.mjs +69 -0
  26. package/src/quota.mjs +76 -0
  27. package/src/registry.mjs +88 -0
  28. package/src/replay.mjs +151 -0
  29. package/src/rooms.mjs +320 -0
  30. package/src/server.mjs +765 -0
  31. package/src/store.mjs +546 -0
  32. package/src/supervisor.mjs +1803 -0
  33. package/src/tasks.mjs +191 -0
  34. package/src/template.mjs +86 -0
  35. package/src/tools.mjs +241 -0
  36. package/src/workspaces.mjs +605 -0
  37. package/src/ws.mjs +130 -0
  38. package/src/yaml.mjs +266 -0
  39. package/test/board.mjs +108 -0
  40. package/test/craft.mjs +281 -0
  41. package/test/extend.mjs +145 -0
  42. package/test/flow.mjs +165 -0
  43. package/test/handoff.mjs +228 -0
  44. package/test/linked.mjs +83 -0
  45. package/test/loop.mjs +168 -0
  46. package/test/mock-opencode.mjs +217 -0
  47. package/test/parallel.mjs +135 -0
  48. package/test/patches.mjs +93 -0
  49. package/test/repos.mjs +118 -0
  50. package/test/roles.mjs +157 -0
  51. package/test/shot.mjs +65 -0
  52. package/test/smoke.mjs +70 -0
  53. package/test/transport.mjs +131 -0
  54. package/test/workspaces.mjs +228 -0
  55. package/test/workspaces.mjs.tmp +0 -0
package/src/store.mjs ADDED
@@ -0,0 +1,546 @@
1
+ // Session registry + rolling per-minute metrics.
2
+ //
3
+ // Metrics come from the message projection rather than from event payloads:
4
+ // event names churn between opencode releases, but every assistant message
5
+ // carries its own usage and every tool call is a part with a stable id. We let
6
+ // the event stream tell us *that* something changed and then diff the messages.
7
+
8
+ import { colorFor } from "./identity.mjs"
9
+ import { readFile, writeFile, mkdir } from "node:fs/promises"
10
+ import { dirname } from "node:path"
11
+ import { DEFAULT_POLICY } from "./mesh.mjs"
12
+ import { estimate } from "./pricing.mjs"
13
+
14
+ const WINDOW = 60 // minutes of history kept per session
15
+
16
+ const nowMinute = () => Math.floor(Date.now() / 60000)
17
+
18
+ class Series {
19
+ constructor(points = []) {
20
+ this.points = points // [{ m, tok, tool }] ascending, max WINDOW
21
+ }
22
+ add(minute, tok, tool) {
23
+ const last = this.points[this.points.length - 1]
24
+ if (last && last.m === minute) {
25
+ last.tok += tok
26
+ last.tool += tool
27
+ } else {
28
+ this.points.push({ m: minute, tok, tool })
29
+ while (this.points.length > WINDOW * 2) this.points.shift()
30
+ }
31
+ }
32
+ // Dense arrays of the last `n` minutes, ending at the current minute.
33
+ dense(n = WINDOW) {
34
+ const end = nowMinute()
35
+ const tok = new Array(n).fill(0)
36
+ const tool = new Array(n).fill(0)
37
+ for (const p of this.points) {
38
+ const i = n - 1 - (end - p.m)
39
+ if (i >= 0 && i < n) {
40
+ tok[i] += p.tok
41
+ tool[i] += p.tool
42
+ }
43
+ }
44
+ return { tok, tool }
45
+ }
46
+ rate(minutes = 5) {
47
+ const end = nowMinute()
48
+ let tok = 0
49
+ let tool = 0
50
+ for (const p of this.points) if (end - p.m < minutes) { tok += p.tok; tool += p.tool }
51
+ return { tokPerMin: Math.round(tok / minutes), toolPerMin: +(tool / minutes).toFixed(1) }
52
+ }
53
+ }
54
+
55
+ export class Session {
56
+ constructor(info) {
57
+ this.id = info.id
58
+ this.title = info.title ?? "untitled"
59
+ this.directory = info.directory ?? info.location?.directory ?? info.worktree ?? null
60
+ this.projectID = info.projectID ?? info.project ?? null
61
+ this.parentID = info.parentID ?? null
62
+ this.agent = info.agent ?? null
63
+ this.model = modelLabel(info)
64
+ this.created = info.time?.created ?? info.created ?? Date.now()
65
+ this.updated = info.time?.updated ?? Date.now()
66
+ this.status = "idle" // idle | busy | waiting | error
67
+ this.busySince = null
68
+ this.currentTool = null
69
+ this.lastError = null
70
+ this.lastText = ""
71
+ this.pendingPermissions = 0
72
+ this.label = null // user-assigned name, pm2 style
73
+ this.branch = null
74
+ this.git = null
75
+ this.handle = null // stable mesh name, assigned by Mesh.handles()
76
+ this.policy = { ...DEFAULT_POLICY }
77
+ this.inbox = []
78
+ this.outbox = []
79
+ this.parentID = info.parentID ?? null
80
+ this.depth = 0
81
+ this.origin = null // handle of the agent that opened this session
82
+ this.repo = null
83
+ this.mcpToken = null
84
+ this.project = "default"
85
+ this.persona = null
86
+ this.role = null
87
+ this.adjectives = null
88
+ this.allowedTools = null
89
+ this.disabledTools = []
90
+ this.totals = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0, tools: 0, toolErrors: 0 }
91
+ this.contextTokens = 0
92
+ this.series = new Series()
93
+ this.seenTools = new Map() // partId -> status we last counted
94
+ this.msgTokens = new Map() // messageId -> last counted total
95
+ this.toolLog = [] // recent tool calls for the detail view
96
+ this.dirty = true
97
+ }
98
+
99
+ setStatus(next) {
100
+ if (next === this.status) return
101
+ if (next === "busy") this.busySince = Date.now()
102
+ if (this.status === "busy") this.busySince = null
103
+ this.status = next
104
+ }
105
+
106
+ applyInfo(info) {
107
+ if (info.title) this.title = info.title
108
+ const dir = info.directory ?? info.location?.directory
109
+ if (dir) this.directory = dir
110
+ if (info.agent) this.agent = info.agent
111
+ const m = modelLabel(info)
112
+ if (m) this.model = m
113
+ this.updated = info.time?.updated ?? this.updated
114
+ }
115
+
116
+ record({ tokens = 0, tools = 0 }) {
117
+ if (tokens || tools) this.series.add(nowMinute(), tokens, tools)
118
+ }
119
+
120
+ snapshot(overrides = {}) {
121
+ const { tok, tool } = this.series.dense()
122
+ return {
123
+ id: this.id,
124
+ name: this.label ?? this.title,
125
+ handle: this.handle,
126
+ color: colorFor(this.id),
127
+ role: this.role,
128
+ persona: this.persona,
129
+ personaTitle: this.personaTitle ?? null,
130
+ configuredModel: this.override?.model ?? this.configuredModel ?? null,
131
+ agentName: this.agentName ?? null,
132
+ variant: this.variant ?? this.override?.variant ?? this.variantWanted ?? null,
133
+ override: this.override ?? null,
134
+ mode: this.mode ?? this.agentName ?? null,
135
+ invocations: this.invocations ?? 0,
136
+ mcp: this.mcp ?? null,
137
+ title: this.title,
138
+ policy: this.policy,
139
+ unread: this.inbox.filter((m) => !m.read).length,
140
+ inboxTotal: this.inbox.length,
141
+ lastPeer: this.inbox.at(-1)?.from ?? null,
142
+ parentID: this.parentID,
143
+ origin: this.origin,
144
+ depth: this.depth,
145
+ directory: this.directory,
146
+ branch: this.branch,
147
+ agent: this.agent,
148
+ model: this.model,
149
+ status: this.status,
150
+ busySince: this.busySince,
151
+ currentTool: this.currentTool,
152
+ lastError: this.lastError,
153
+ lastText: this.lastText.slice(0, 320),
154
+ pendingPermissions: this.pendingPermissions,
155
+ permissions: this.permissionList ?? [],
156
+ questions: this.questionList ?? [],
157
+ created: this.created,
158
+ updated: this.updated,
159
+ totals: this.totals,
160
+ cost: this.cost(overrides),
161
+ tokens: this.totals.input + this.totals.output + this.totals.reasoning + this.totals.cacheRead + this.totals.cacheWrite,
162
+ project: this.project,
163
+ contextTokens: this.contextTokens,
164
+ git: this.git,
165
+ ...this.series.rate(5),
166
+ spark: { tok, tool },
167
+ tools: this.toolLog.slice(-8),
168
+ }
169
+ }
170
+
171
+ /** Provider cost when we get one, our own arithmetic when we don't. */
172
+ cost(overrides = {}) {
173
+ return estimate({ model: this.model, totals: this.totals, reported: this.totals.cost, overrides })
174
+ }
175
+
176
+ persist() {
177
+ return {
178
+ id: this.id,
179
+ label: this.label,
180
+ project: this.project,
181
+ persona: this.persona,
182
+ role: this.role,
183
+ adjectives: this.adjectives,
184
+ disabledTools: this.disabledTools,
185
+ allowedTools: this.allowedTools,
186
+ personaTitle: this.personaTitle ?? null,
187
+ configuredModel: this.configuredModel ?? null,
188
+ agentName: this.agentName ?? null,
189
+ variantWanted: this.variantWanted ?? null,
190
+ override: this.override ?? null,
191
+ variant: this.variant ?? null,
192
+ invocations: this.invocations ?? 0,
193
+ engine: this.engine ?? null,
194
+ directory: this.directory ?? null,
195
+ handle: this.handle,
196
+ policy: this.policy,
197
+ parentID: this.parentID,
198
+ depth: this.depth,
199
+ origin: this.origin,
200
+ repo: this.repo,
201
+ mcpToken: this.mcpToken,
202
+ inbox: this.inbox.slice(-40),
203
+ outbox: this.outbox.slice(-40),
204
+ toolLog: this.toolLog.slice(-40),
205
+ branch: this.branch,
206
+ totals: this.totals,
207
+ points: this.series.points,
208
+ msgTokens: [...this.msgTokens],
209
+ seenTools: [...this.seenTools],
210
+ }
211
+ }
212
+
213
+ restore(saved) {
214
+ this.label = saved.label ?? null
215
+ this.project = saved.project ?? saved.group ?? "default"
216
+ this.persona = saved.persona ?? null
217
+ this.role = saved.role ?? saved.persona ?? null
218
+ this.adjectives = saved.adjectives ?? null
219
+ this.disabledTools = saved.disabledTools ?? []
220
+ this.allowedTools = saved.allowedTools ?? null
221
+ this.personaTitle = saved.personaTitle ?? null
222
+ this.configuredModel = saved.configuredModel ?? null
223
+ this.agentName = saved.agentName ?? null
224
+ this.engine = saved.engine ?? null
225
+ if (!this.directory && saved.directory) this.directory = saved.directory
226
+ this.variantWanted = saved.variantWanted ?? null
227
+ this.override = saved.override ?? null
228
+ this.variant = saved.variant ?? null
229
+ this.invocations = saved.invocations ?? 0
230
+ this.handle = saved.handle ?? null
231
+ this.policy = { ...DEFAULT_POLICY, ...(saved.policy ?? {}) }
232
+ this.parentID = saved.parentID ?? this.parentID
233
+ this.depth = saved.depth ?? 0
234
+ this.origin = saved.origin ?? null
235
+ this.repo = saved.repo ?? null
236
+ this.mcpToken = saved.mcpToken ?? null
237
+ this.inbox = saved.inbox ?? []
238
+ this.outbox = saved.outbox ?? []
239
+ // seenTools survives a restart, so the tool log has to survive with it —
240
+ // otherwise old calls are skipped as "already counted" and never logged.
241
+ this.toolLog = saved.toolLog ?? []
242
+ this.branch = saved.branch ?? null
243
+ Object.assign(this.totals, saved.totals ?? {})
244
+ this.series = new Series(saved.points ?? [])
245
+ this.msgTokens = new Map(saved.msgTokens ?? [])
246
+ this.seenTools = new Map(saved.seenTools ?? [])
247
+ }
248
+ }
249
+
250
+ export function modelLabel(info) {
251
+ if (!info) return null
252
+ // v1 puts providerID/modelID flat on assistant messages; v2 nests them, and
253
+ // has used both { modelID } and { id } inside.
254
+ if (typeof info.modelID === "string") return [info.providerID, info.modelID].filter(Boolean).join("/")
255
+ const m = info.model ?? info.assistant?.model ?? info.metadata?.model
256
+ if (!m) return null
257
+ if (typeof m === "string") return m
258
+ const id = m.modelID ?? m.id ?? m.model ?? m.name
259
+ if (!id || typeof id !== "string") return null
260
+ return [m.providerID ?? m.provider, id].filter((x) => typeof x === "string" && x).join("/")
261
+ }
262
+
263
+ /** Reasoning level / model variant ("high", "low", "max"…), wherever this server puts it. */
264
+ export function variantOf(info) {
265
+ const v = info?.variant ?? info?.model?.variant ?? info?.reasoningEffort ?? info?.reasoning_effort ?? info?.options?.reasoningEffort ?? info?.effort ?? info?.thinking?.effort
266
+ return typeof v === "string" && v ? v : null
267
+ }
268
+
269
+ /**
270
+ * Tool calls have been a part of type "tool" with { tool, state: { status } }
271
+ * in v1, and other names elsewhere. Normalise everything we have seen to
272
+ * { name, status, input, output, start, end }, or null if it is not a tool.
273
+ */
274
+ export function toolOf(part) {
275
+ if (!part || typeof part !== "object") return null
276
+ if (!/^(tool|tool-call|tool_call|tool-invocation|tool_use|tool-use)$/.test(part.type ?? "")) return null
277
+ const st = typeof part.state === "object" && part.state ? part.state : {}
278
+ let status = st.status ?? (typeof part.state === "string" ? part.state : null) ?? part.status ?? "pending"
279
+ if (/^(done|success|succeeded|result|output-available|complete)$/.test(status)) status = "completed"
280
+ if (/^(failed|failure|output-error)$/.test(status)) status = "error"
281
+ if (/^(input-streaming|input-available|call)$/.test(status)) status = "running"
282
+ return {
283
+ name: part.tool ?? part.name ?? part.toolName ?? part.tool_name ?? "tool",
284
+ status,
285
+ input: st.input ?? part.input ?? part.args ?? null,
286
+ output: st.output ?? part.output ?? part.result ?? null,
287
+ error: st.error ?? part.error ?? null,
288
+ title: st.title ?? null,
289
+ start: st.time?.start ?? part.time?.start ?? null,
290
+ end: st.time?.end ?? part.time?.end ?? null,
291
+ }
292
+ }
293
+
294
+ /** Parts can be on the message, on its info, or called content. */
295
+ export function partsOf(raw) {
296
+ const info = raw?.info ?? raw ?? {}
297
+ const parts = raw?.parts ?? info.parts ?? raw?.content ?? info.content
298
+ return Array.isArray(parts) ? parts : []
299
+ }
300
+
301
+ export class Store {
302
+ constructor(statePath) {
303
+ this.sessions = new Map()
304
+ this.statePath = statePath
305
+ this.serverStatus = "connecting"
306
+ this.events = [] // recent activity feed
307
+ }
308
+
309
+ upsert(info) {
310
+ let s = this.sessions.get(info.id)
311
+ if (!s) {
312
+ s = new Session(info)
313
+ const saved = this.savedState?.sessions?.[info.id]
314
+ if (saved) s.restore(saved)
315
+ this.sessions.set(info.id, s)
316
+ } else s.applyInfo(info)
317
+ return s
318
+ }
319
+
320
+ get(id) {
321
+ return this.sessions.get(id)
322
+ }
323
+
324
+ list() {
325
+ return [...this.sessions.values()]
326
+ }
327
+
328
+ note(text, sessionId = null, level = "info") {
329
+ this.events.unshift({ at: Date.now(), text, sessionId, level })
330
+ if (this.events.length > 120) this.events.pop()
331
+ }
332
+
333
+ totals() {
334
+ const t = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0, tools: 0, toolErrors: 0 }
335
+ const all = new Series()
336
+ let busy = 0
337
+ let usd = 0
338
+ let unpriced = 0
339
+ const unknown = new Set()
340
+ const overrides = this.pricing?.() ?? {}
341
+ for (const s of this.sessions.values()) {
342
+ for (const k of Object.keys(t)) t[k] += s.totals[k] ?? 0
343
+ const c = s.cost(overrides)
344
+ usd += c.usd
345
+ if (c.unknownModel) { unpriced++; unknown.add(c.unknownModel) }
346
+ if (s.status === "busy") busy++
347
+ for (const p of s.series.points) all.add(p.m, p.tok, p.tool)
348
+ }
349
+ const { tok, tool } = all.dense()
350
+ return { ...t, tokens: t.input + t.output + t.reasoning + t.cacheRead + t.cacheWrite, usd, unpriced, unknownModels: [...unknown], busy, sessions: this.sessions.size, spark: { tok, tool }, ...all.rate(5) }
351
+ }
352
+
353
+ snapshot() {
354
+ return {
355
+ at: Date.now(),
356
+ serverStatus: this.serverStatus,
357
+ rooms: this.roomsSnapshot?.() ?? [],
358
+ ...(this.extra?.() ?? {}),
359
+ totals: this.totals(),
360
+ sessions: this.list().map((s) => s.snapshot(this.pricing?.() ?? {})).sort(sortSessions),
361
+ events: this.events.slice(0, 40),
362
+ }
363
+ }
364
+
365
+ async load() {
366
+ try {
367
+ this.savedState = JSON.parse(await readFile(this.statePath, "utf8"))
368
+ } catch {
369
+ this.savedState = { sessions: {} }
370
+ }
371
+ }
372
+
373
+ async save() {
374
+ const out = { sessions: {}, rooms: this.roomsPersist?.() ?? [] }
375
+ for (const s of this.sessions.values()) out.sessions[s.id] = s.persist()
376
+ await mkdir(dirname(this.statePath), { recursive: true })
377
+ await writeFile(this.statePath, JSON.stringify(out))
378
+ }
379
+ }
380
+
381
+ const RANK = { busy: 0, waiting: 1, error: 2, idle: 3 }
382
+ function sortSessions(a, b) {
383
+ const r = (RANK[a.status] ?? 9) - (RANK[b.status] ?? 9)
384
+ return r !== 0 ? r : b.updated - a.updated
385
+ }
386
+
387
+ /**
388
+ * Diff a freshly fetched page of messages into the session's counters.
389
+ * Handles both the v1 ({ info, parts }) and v2 (flat message with parts) shapes.
390
+ */
391
+ export function ingestMessages(session, messages) {
392
+ let tokenDelta = 0
393
+ const modelDeltas = new Map() // model -> token deltas this pass, for per-model quotas
394
+ let toolDelta = 0
395
+ let latestContext = session.contextTokens
396
+ let runningTool = null
397
+ let gotText = false
398
+ let seenModel = false
399
+ let seenVariant = false
400
+
401
+ for (const raw of messages) {
402
+ const info = raw.info ?? raw
403
+ const parts = partsOf(raw)
404
+ const id = info.id
405
+ if (!id) continue
406
+
407
+ // What each turn actually ran on: the newest message wins, since the
408
+ // model and its reasoning level can change mid-session.
409
+ if (!seenModel) {
410
+ const m = modelLabel(info)
411
+ if (m && (info.role === "assistant" || info.type === "assistant")) {
412
+ session.model = m
413
+ seenModel = true
414
+ }
415
+ }
416
+ const v = variantOf(info)
417
+ if (v && !seenVariant) { session.variant = v; seenVariant = true }
418
+ if ((info.mode || info.agent) && typeof (info.mode ?? info.agent) === "string") session.mode ??= info.mode ?? info.agent
419
+ if (info.role === "assistant" || info.type === "assistant") {
420
+ const u = usageOf(info, parts)
421
+ const total = u.input + u.output + u.reasoning + u.cacheRead + u.cacheWrite
422
+ const prev = session.msgTokens.get(id) ?? { total: 0, input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
423
+ if (total > prev.total) {
424
+ tokenDelta += total - prev.total
425
+ const m = modelLabel(info) ?? session.model ?? "unknown"
426
+ const md = modelDeltas.get(m) ?? { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
427
+ for (const k of ["input", "output", "reasoning", "cacheRead", "cacheWrite", "cost"]) {
428
+ const d = Math.max(0, u[k] - (prev[k] ?? 0))
429
+ session.totals[k] += d
430
+ md[k] += d
431
+ }
432
+ modelDeltas.set(m, md)
433
+ session.msgTokens.set(id, { total, ...u })
434
+ }
435
+ // context window occupancy = newest assistant message's input side
436
+ if (u.input + u.cacheRead > 0) latestContext = Math.max(latestContext, u.input + u.cacheRead)
437
+ if (info.error) {
438
+ const e = info.error
439
+ const msg = typeof e === "string" ? e : e.data?.message ?? e.message ?? e.name ?? "error"
440
+ const inner = /"message"\s*:\s*"([^"]+)"/.exec(msg)
441
+ session.lastError = String(inner ? `${e.name ?? "error"}: ${inner[1]}` : msg).slice(0, 300)
442
+ }
443
+ }
444
+
445
+ for (const part of parts) {
446
+ if (!gotText && part.type === "text" && part.text && (info.role === "assistant" || info.type === "assistant")) {
447
+ session.lastText = tail(part.text)
448
+ gotText = true
449
+ }
450
+ const tool = toolOf(part)
451
+ if (!tool) continue
452
+ const { status, name } = tool
453
+ const key = part.id ?? part.callID ?? part.toolCallId ?? `${id}:${name}`
454
+ const seen = session.seenTools.get(key)
455
+ if (status === "running" || status === "streaming") {
456
+ if (!runningTool) runningTool = { name, since: tool.start ?? Date.now() }
457
+ }
458
+ if (seen === status) continue
459
+ session.seenTools.set(key, status)
460
+ if (status === "completed" || status === "error") {
461
+ toolDelta++
462
+ session.totals.tools++
463
+ if (status === "error") session.totals.toolErrors++
464
+ const start = tool.start
465
+ const end = tool.end ?? Date.now()
466
+ session.toolLog.push({
467
+ name,
468
+ status,
469
+ ms: start ? Math.max(0, end - start) : null,
470
+ at: end,
471
+ summary: summarizeToolInput(tool.input),
472
+ sig: inputSig(tool.input),
473
+ })
474
+ if (session.toolLog.length > 60) session.toolLog.shift()
475
+ }
476
+ }
477
+ }
478
+
479
+ session.contextTokens = latestContext
480
+ session.currentTool = runningTool
481
+ session.record({ tokens: tokenDelta, tools: toolDelta })
482
+ return { tokenDelta, toolDelta, modelDeltas: [...modelDeltas].map(([model, totals]) => ({ model, totals })) }
483
+ }
484
+
485
+ function usageOf(info, parts) {
486
+ const t = info.tokens ?? info.usage ?? info.metadata?.usage ?? {}
487
+ let out = {
488
+ input: num(t.input ?? t.prompt ?? t.inputTokens),
489
+ output: num(t.output ?? t.completion ?? t.outputTokens),
490
+ reasoning: num(t.reasoning ?? t.reasoningTokens),
491
+ cacheRead: num(t.cache?.read ?? t.cacheRead ?? t.cache_read),
492
+ cacheWrite: num(t.cache?.write ?? t.cacheWrite ?? t.cache_write),
493
+ cost: num(info.cost),
494
+ }
495
+ if (out.input + out.output === 0) {
496
+ // fall back to step-finish parts, which carry usage in some versions
497
+ for (const p of parts) {
498
+ if (p.type === "step-finish" || p.type === "stepFinish") {
499
+ const u = p.tokens ?? p.usage ?? {}
500
+ out.input += num(u.input)
501
+ out.output += num(u.output)
502
+ out.reasoning += num(u.reasoning)
503
+ out.cacheRead += num(u.cache?.read ?? u.cacheRead)
504
+ out.cacheWrite += num(u.cache?.write ?? u.cacheWrite)
505
+ out.cost += num(p.cost)
506
+ }
507
+ }
508
+ }
509
+ return out
510
+ }
511
+
512
+ /**
513
+ * The end of a message is what you are continuing from; the beginning is
514
+ * usually preamble. Take the closing sentences, not the opening ones.
515
+ */
516
+ function tail(text, sentences = 2) {
517
+ const clean = String(text).replace(/```[\s\S]*?```/g, " [code] ").replace(/\s+/g, " ").trim()
518
+ const parts = clean.split(/(?<=[.!?])\s+/)
519
+ const out = parts.slice(-sentences).join(" ")
520
+ return out.length > 320 ? "…" + out.slice(-320) : out
521
+ }
522
+
523
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0)
524
+
525
+ export function summarizeToolInput(input) {
526
+ if (!input || typeof input !== "object") return ""
527
+ // The usual fields first; any tool's (an MCP server's) first short string otherwise.
528
+ const v = input.command ?? input.filePath ?? input.path ?? input.pattern ?? input.description ?? input.query
529
+ ?? Object.values(input).find((x) => typeof x === "string" && x.trim())
530
+ return typeof v === "string" ? v.slice(0, 90) : ""
531
+ }
532
+
533
+ /**
534
+ * The whole input, as a short fingerprint: two calls are "the same call" only
535
+ * when every argument matches, not just the tool or the summary.
536
+ */
537
+ export function inputSig(input) {
538
+ if (input === undefined) return null
539
+ const canon = (v) => Array.isArray(v) ? `[${v.map(canon).join(",")}]`
540
+ : v && typeof v === "object" ? `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(",")}}`
541
+ : JSON.stringify(v ?? null)
542
+ const text = canon(input)
543
+ let h = 5381
544
+ for (let i = 0; i < text.length; i++) h = ((h * 33) ^ text.charCodeAt(i)) >>> 0
545
+ return `${text.length}:${h.toString(36)}`
546
+ }