@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.
- package/.idea/bot-farm.iml +9 -0
- package/.idea/misc.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/package.json +13 -0
- package/src/api.mjs +567 -0
- package/src/cli.mjs +241 -0
- package/src/craft.mjs +172 -0
- package/src/db.mjs +93 -0
- package/src/farmhands.mjs +111 -0
- package/src/git.mjs +578 -0
- package/src/identity.mjs +199 -0
- package/src/legacy-defaults.mjs +44 -0
- package/src/mcp.mjs +681 -0
- package/src/mesh.mjs +222 -0
- package/src/packets.mjs +125 -0
- package/src/patches.mjs +141 -0
- package/src/pipelines.mjs +2145 -0
- package/src/pricing.mjs +91 -0
- package/src/projects.mjs +148 -0
- package/src/public/classic.html +1810 -0
- package/src/public/index.html +3284 -0
- package/src/questions.mjs +69 -0
- package/src/quota.mjs +76 -0
- package/src/registry.mjs +88 -0
- package/src/replay.mjs +151 -0
- package/src/rooms.mjs +320 -0
- package/src/server.mjs +765 -0
- package/src/store.mjs +546 -0
- package/src/supervisor.mjs +1803 -0
- package/src/tasks.mjs +191 -0
- package/src/template.mjs +86 -0
- package/src/tools.mjs +241 -0
- package/src/workspaces.mjs +605 -0
- package/src/ws.mjs +130 -0
- package/src/yaml.mjs +266 -0
- package/test/board.mjs +108 -0
- package/test/craft.mjs +281 -0
- package/test/extend.mjs +145 -0
- package/test/flow.mjs +165 -0
- package/test/handoff.mjs +228 -0
- package/test/linked.mjs +83 -0
- package/test/loop.mjs +168 -0
- package/test/mock-opencode.mjs +217 -0
- package/test/parallel.mjs +135 -0
- package/test/patches.mjs +93 -0
- package/test/repos.mjs +118 -0
- package/test/roles.mjs +157 -0
- package/test/shot.mjs +65 -0
- package/test/smoke.mjs +70 -0
- package/test/transport.mjs +131 -0
- package/test/workspaces.mjs +228 -0
- 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
|
+
}
|