opencode-onboard 0.4.3 → 0.4.5
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/README.md +41 -40
- package/content/.agents/agents/devops-manager.md +123 -123
- package/content/.agents/skills/ob-default/SKILL.md +25 -21
- package/content/.agents/skills/ob-generic-guardrails/SKILL.md +36 -32
- package/content/.agents/skills/ob-global/SKILL.md +92 -84
- package/content/.agents/skills/ob-pullrequest-az/SKILL.md +168 -160
- package/content/.agents/skills/ob-pullrequest-gh/SKILL.md +140 -136
- package/content/.opencode/commands/create-engineer.md +109 -0
- package/content/.opencode/plugins/session-log.js +523 -519
- package/content/AGENTS.md +32 -21
- package/package.json +1 -1
- package/src/commands/wizard.js +124 -113
- package/src/presets/browser.json +22 -18
- package/src/presets/optimization.json +27 -22
- package/src/steps/browser/browser.test.js +115 -81
- package/src/steps/browser/index.js +62 -54
- package/src/steps/clean/index.js +108 -107
- package/src/steps/metadata/index.js +63 -62
- package/src/steps/models/format.js +61 -60
- package/src/steps/models/write.test.js +117 -117
- package/src/steps/openspec/ensemble.test.js +79 -79
- package/src/steps/openspec/index.js +121 -32
- package/src/steps/openspec/index.test.js +63 -0
- package/src/steps/optimization/caveman.js +34 -29
- package/src/steps/optimization/codegraph.js +103 -0
- package/src/steps/optimization/codegraph.test.js +104 -0
- package/src/steps/optimization/global.js +88 -64
- package/src/steps/optimization/global.test.js +99 -0
- package/src/steps/optimization/index.js +109 -101
- package/src/steps/optimization/optimization.test.js +101 -93
- package/src/steps/optimization/quota.js +84 -84
- package/src/steps/source/source.test.js +124 -124
- package/src/utils/__tests__/copy.test.js +117 -117
- package/src/utils/exec-spinner.js +47 -47
- package/src/utils/exec.js +134 -131
- package/src/utils/terminal.js +6 -0
|
@@ -1,519 +1,523 @@
|
|
|
1
|
-
import fs from "node:fs"
|
|
2
|
-
import path from "node:path"
|
|
3
|
-
|
|
4
|
-
const LOG_FILE = ".agents/session-log.json"
|
|
5
|
-
const SPAWN_MATCH_WINDOW_MS = 30000
|
|
6
|
-
const UNMATCHED_SESSION_WARN_MS = 30000
|
|
7
|
-
const DEBUG = process.env.SESSION_LOG_DEBUG === "true"
|
|
8
|
-
|
|
9
|
-
// Per-session state
|
|
10
|
-
const sessionState = new Map()
|
|
11
|
-
|
|
12
|
-
// Lead session -> current team name
|
|
13
|
-
const leadTeamBySession = new Map()
|
|
14
|
-
|
|
15
|
-
// Pending spawn records waiting for a session.created match
|
|
16
|
-
// Each entry: { leadSessionId, at, member, agentType, teamName, spawnedSessionId, intendedSkills }
|
|
17
|
-
const pendingSpawns = []
|
|
18
|
-
|
|
19
|
-
// spawnedSessionId -> pending spawn (direct correlation fast path)
|
|
20
|
-
const pendingSpawnBySessionId = new Map()
|
|
21
|
-
|
|
22
|
-
// Team -> completed session snapshots
|
|
23
|
-
const completedByTeam = new Map()
|
|
24
|
-
|
|
25
|
-
function ts() {
|
|
26
|
-
return new Date().toISOString()
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function nowMs() {
|
|
30
|
-
return Date.now()
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function appendEntry(directory, entry) {
|
|
34
|
-
const logPath = path.join(directory, LOG_FILE)
|
|
35
|
-
const dir = path.dirname(logPath)
|
|
36
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
37
|
-
|
|
38
|
-
let entries = []
|
|
39
|
-
if (fs.existsSync(logPath)) {
|
|
40
|
-
try { entries = JSON.parse(fs.readFileSync(logPath, "utf8")) } catch
|
|
41
|
-
}
|
|
42
|
-
entries.push(entry)
|
|
43
|
-
fs.writeFileSync(logPath, JSON.stringify(entries, null, 2), "utf8")
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function resolveAgentName(session) {
|
|
47
|
-
const agentPath = session?.agent
|
|
48
|
-
if (agentPath) {
|
|
49
|
-
const base = path.basename(agentPath, ".md")
|
|
50
|
-
if (base) return base
|
|
51
|
-
}
|
|
52
|
-
return "lead"
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function resolveModel(session) {
|
|
56
|
-
// Try common field names used by OpenCode session objects
|
|
57
|
-
return session?.model || session?.modelId || session?.model_id || null
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function addSkillToState(state, skillName) {
|
|
61
|
-
if (!skillName || !state) return false
|
|
62
|
-
if (!state.skills) state.skills = new Set()
|
|
63
|
-
if (state.skills.has(skillName)) return false
|
|
64
|
-
state.skills.add(skillName)
|
|
65
|
-
return true
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Extract skill names hinted in a spawn prompt by scanning for known skill-like tokens.
|
|
69
|
-
// Matches strings that look like skill directory names (kebab-case words after "skill" keyword
|
|
70
|
-
// or adjacent to known skill name patterns).
|
|
71
|
-
function extractIntendedSkills(prompt) {
|
|
72
|
-
if (!prompt || typeof prompt !== "string") return []
|
|
73
|
-
const skills = new Set()
|
|
74
|
-
|
|
75
|
-
// Match explicit skill name mentions: e.g. "ob-pullrequest-gh", "browser-automation"
|
|
76
|
-
const kebabPattern = /\b([a-z][a-z0-9]*(?:-[a-z0-9]+){1,})\b/g
|
|
77
|
-
let m
|
|
78
|
-
while ((m = kebabPattern.exec(prompt)) !== null) {
|
|
79
|
-
const candidate = m[1]
|
|
80
|
-
// Filter to plausible skill names: 2+ segments, known prefixes or suffixes
|
|
81
|
-
if (
|
|
82
|
-
candidate.startsWith("ob-") ||
|
|
83
|
-
candidate.startsWith("openspec-") ||
|
|
84
|
-
candidate.startsWith("browser-") ||
|
|
85
|
-
candidate.endsWith("-gh") ||
|
|
86
|
-
candidate.endsWith("-az") ||
|
|
87
|
-
candidate.endsWith("-automation") ||
|
|
88
|
-
candidate.endsWith("-change") ||
|
|
89
|
-
candidate.endsWith("-engineer") ||
|
|
90
|
-
candidate.endsWith("-manager") ||
|
|
91
|
-
candidate.endsWith("-auditor")
|
|
92
|
-
) {
|
|
93
|
-
skills.add(candidate)
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return Array.from(skills).sort()
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function toNum(v) {
|
|
100
|
-
if (typeof v === "number" && Number.isFinite(v)) return v
|
|
101
|
-
if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) return Number(v)
|
|
102
|
-
return null
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function extractReportedTokens(obj) {
|
|
106
|
-
const out = { input: 0, output: 0, total: 0, found: false }
|
|
107
|
-
const visited = new Set()
|
|
108
|
-
|
|
109
|
-
function walk(node) {
|
|
110
|
-
if (!node || typeof node !== "object") return
|
|
111
|
-
if (visited.has(node)) return
|
|
112
|
-
visited.add(node)
|
|
113
|
-
|
|
114
|
-
for (const [k, v] of Object.entries(node)) {
|
|
115
|
-
const key = String(k).toLowerCase()
|
|
116
|
-
const n = toNum(v)
|
|
117
|
-
|
|
118
|
-
if (n !== null) {
|
|
119
|
-
if ((key.includes("input") || key.includes("prompt")) && key.includes("token")) {
|
|
120
|
-
out.input += n
|
|
121
|
-
out.found = true
|
|
122
|
-
} else if ((key.includes("output") || key.includes("completion")) && key.includes("token")) {
|
|
123
|
-
out.output += n
|
|
124
|
-
out.found = true
|
|
125
|
-
} else if (key.includes("total") && key.includes("token")) {
|
|
126
|
-
out.total += n
|
|
127
|
-
out.found = true
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (v && typeof v === "object") walk(v)
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
walk(obj)
|
|
136
|
-
return out
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function estimateTokens(state) {
|
|
140
|
-
const inTokens = Math.ceil((state.charIn || 0) / 4)
|
|
141
|
-
const outTokens = Math.ceil((state.charOut || 0) / 4)
|
|
142
|
-
const base = inTokens + outTokens + Math.max(0, (state.toolCalls || 0) * 20)
|
|
143
|
-
const low = Math.max(0, Math.floor(base * 0.7))
|
|
144
|
-
const high = Math.max(low, Math.ceil(base * 1.4))
|
|
145
|
-
return { low, high }
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function usagePayload(state) {
|
|
149
|
-
const est = estimateTokens(state)
|
|
150
|
-
const reportedIn = state.reportedInputTokens || 0
|
|
151
|
-
const reportedOut = state.reportedOutputTokens || 0
|
|
152
|
-
const reportedTotalDirect = state.reportedTotalTokens || 0
|
|
153
|
-
const reportedTotalDerived = reportedIn + reportedOut
|
|
154
|
-
const reportedTotal = reportedTotalDirect || reportedTotalDerived || 0
|
|
155
|
-
|
|
156
|
-
let method = "heuristic"
|
|
157
|
-
if (reportedTotal > 0 && (est.low > 0 || est.high > 0)) method = "mixed"
|
|
158
|
-
else if (reportedTotal > 0) method = "reported"
|
|
159
|
-
|
|
160
|
-
return {
|
|
161
|
-
inputTokensReported: reportedIn || null,
|
|
162
|
-
outputTokensReported: reportedOut || null,
|
|
163
|
-
totalTokensReported: reportedTotal || null,
|
|
164
|
-
tokenEstimateLow: est.low,
|
|
165
|
-
tokenEstimateHigh: est.high,
|
|
166
|
-
method,
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function buildCompletedSnapshot(state, sessionId) {
|
|
171
|
-
return {
|
|
172
|
-
sessionId,
|
|
173
|
-
agent: state.agentName,
|
|
174
|
-
member: state.member || null,
|
|
175
|
-
agentType: state.agentType || null,
|
|
176
|
-
team: state.teamName || null,
|
|
177
|
-
model: state.model || null,
|
|
178
|
-
skills: Array.from(state.skills || []).sort(),
|
|
179
|
-
intendedSkills: state.intendedSkills || [],
|
|
180
|
-
usage: usagePayload(state),
|
|
181
|
-
filesEdited: state.editCount || 0,
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function buildTeamSkillsSummary(teamName) {
|
|
186
|
-
const rows = completedByTeam.get(teamName) || []
|
|
187
|
-
const byAgent = {}
|
|
188
|
-
for (const row of rows) {
|
|
189
|
-
const key = row.member || row.agent || "unknown"
|
|
190
|
-
if (!byAgent[key]) byAgent[key] = { agentType: row.agentType || null, model: row.model || null, skills: new Set(), intendedSkills: new Set() }
|
|
191
|
-
for (const s of row.skills || []) byAgent[key].skills.add(s)
|
|
192
|
-
for (const s of row.intendedSkills || []) byAgent[key].intendedSkills.add(s)
|
|
193
|
-
}
|
|
194
|
-
const out = {}
|
|
195
|
-
for (const [k, v] of Object.entries(byAgent)) {
|
|
196
|
-
const skills = Array.from(v.skills).sort()
|
|
197
|
-
const intendedSkills = Array.from(v.intendedSkills).sort()
|
|
198
|
-
const missingSkills = intendedSkills.filter(s => !v.skills.has(s))
|
|
199
|
-
out[k] = {
|
|
200
|
-
agentType: v.agentType,
|
|
201
|
-
model: v.model,
|
|
202
|
-
skills,
|
|
203
|
-
intendedSkills,
|
|
204
|
-
missingSkills: missingSkills.length > 0 ? missingSkills : undefined,
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return out
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function trackCompletedByTeam(snapshot) {
|
|
211
|
-
if (!snapshot.team) return
|
|
212
|
-
if (!completedByTeam.has(snapshot.team)) completedByTeam.set(snapshot.team, [])
|
|
213
|
-
completedByTeam.get(snapshot.team).push(snapshot)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function enqueuePendingSpawn(leadSessionId, args, spawnOutput) {
|
|
217
|
-
// Try to extract spawnedSessionId from team_spawn output for direct correlation
|
|
218
|
-
const spawnedSessionId =
|
|
219
|
-
spawnOutput?.sessionId ||
|
|
220
|
-
spawnOutput?.session_id ||
|
|
221
|
-
spawnOutput?.id ||
|
|
222
|
-
spawnOutput?.data?.sessionId ||
|
|
223
|
-
spawnOutput?.data?.session_id ||
|
|
224
|
-
spawnOutput?.data?.id ||
|
|
225
|
-
null
|
|
226
|
-
|
|
227
|
-
const record = {
|
|
228
|
-
leadSessionId,
|
|
229
|
-
at: nowMs(),
|
|
230
|
-
member: args?.name || null,
|
|
231
|
-
agentType: args?.agent || null,
|
|
232
|
-
teamName: leadTeamBySession.get(leadSessionId) || null,
|
|
233
|
-
spawnedSessionId,
|
|
234
|
-
intendedSkills: extractIntendedSkills(args?.prompt),
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
pendingSpawns.push(record)
|
|
238
|
-
|
|
239
|
-
if (spawnedSessionId) {
|
|
240
|
-
pendingSpawnBySessionId.set(spawnedSessionId, record)
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function matchPendingSpawn(sessionId) {
|
|
245
|
-
const now = nowMs()
|
|
246
|
-
|
|
247
|
-
// Fast path: direct session ID correlation from team_spawn output
|
|
248
|
-
if (sessionId && pendingSpawnBySessionId.has(sessionId)) {
|
|
249
|
-
const record = pendingSpawnBySessionId.get(sessionId)
|
|
250
|
-
pendingSpawnBySessionId.delete(sessionId)
|
|
251
|
-
const idx = pendingSpawns.indexOf(record)
|
|
252
|
-
if (idx !== -1) pendingSpawns.splice(idx, 1)
|
|
253
|
-
return record
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Fallback: time-window heuristic (drop expired first)
|
|
257
|
-
for (let i = pendingSpawns.length - 1; i >= 0; i--) {
|
|
258
|
-
if (now - pendingSpawns[i].at > SPAWN_MATCH_WINDOW_MS) {
|
|
259
|
-
pendingSpawnBySessionId.delete(pendingSpawns[i].spawnedSessionId)
|
|
260
|
-
pendingSpawns.splice(i, 1)
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
if (pendingSpawns.length === 0) return null
|
|
264
|
-
return pendingSpawns.shift()
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Maps ensemble tool name -> function that extracts log entry fields from args
|
|
268
|
-
const ENSEMBLE_TOOL_HANDLERS = {
|
|
269
|
-
team_create: (args) => ({ action: "team-created", team: args.name }),
|
|
270
|
-
team_spawn: (args) => ({ action: "teammate-spawned", name: args.name, agentType: args.agent }),
|
|
271
|
-
team_shutdown: (args) => ({ action: "teammate-shutdown", name: args.name }),
|
|
272
|
-
team_merge: (args) => ({ action: "teammate-merged", name: args.name }),
|
|
273
|
-
team_cleanup: () => ({ action: "team-cleanup" }),
|
|
274
|
-
team_status: () => ({ action: "team-status-checked" }),
|
|
275
|
-
team_results: (args) => ({ action: "team-results-read", from: args.from }),
|
|
276
|
-
team_message: (args) => ({ action: "team-message", to: args.to ?? "lead", preview: String(args.text ?? "").slice(0, 120) }),
|
|
277
|
-
team_broadcast: (args) => ({ action: "team-broadcast", preview: String(args.text ?? "").slice(0, 120) }),
|
|
278
|
-
team_tasks_add: (args) => ({ action: "tasks-added", count: Array.isArray(args.tasks) ? args.tasks.length : "?" }),
|
|
279
|
-
team_tasks_complete: (args) => ({ action: "task-completed", taskId: args.task_id }),
|
|
280
|
-
team_claim: (args) => ({ action: "task-claimed", taskId: args.task_id }),
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
export const SessionLogPlugin = async ({ client, directory }) => {
|
|
284
|
-
return {
|
|
285
|
-
event: async ({ event }) => {
|
|
286
|
-
try {
|
|
287
|
-
if (event?.type === "session.created") {
|
|
288
|
-
const sessionId = event.properties?.id ?? event.properties?.sessionID
|
|
289
|
-
if (!sessionId) return
|
|
290
|
-
|
|
291
|
-
const res = await client.session.get({ path: { id: sessionId } })
|
|
292
|
-
const session = res?.data
|
|
293
|
-
const fallbackAgent = resolveAgentName(session)
|
|
294
|
-
const model = resolveModel(session)
|
|
295
|
-
const spawnMatch = matchPendingSpawn(sessionId)
|
|
296
|
-
|
|
297
|
-
const state = {
|
|
298
|
-
agentName: spawnMatch?.member || fallbackAgent,
|
|
299
|
-
member: spawnMatch?.member || null,
|
|
300
|
-
agentType: spawnMatch?.agentType || null,
|
|
301
|
-
teamName: spawnMatch?.teamName || null,
|
|
302
|
-
model,
|
|
303
|
-
intendedSkills: spawnMatch?.intendedSkills || [],
|
|
304
|
-
editCount: 0,
|
|
305
|
-
skills: new Set(),
|
|
306
|
-
startedAtMs: nowMs(),
|
|
307
|
-
toolCalls: 0,
|
|
308
|
-
charIn: 0,
|
|
309
|
-
charOut: 0,
|
|
310
|
-
reportedInputTokens: 0,
|
|
311
|
-
reportedOutputTokens: 0,
|
|
312
|
-
reportedTotalTokens: 0,
|
|
313
|
-
// Track whether this session was matched to a spawn record
|
|
314
|
-
spawnMatched: !!spawnMatch,
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
sessionState.set(sessionId, state)
|
|
318
|
-
|
|
319
|
-
const startedEntry = {
|
|
320
|
-
ts: ts(),
|
|
321
|
-
agent: state.agentName,
|
|
322
|
-
member: state.member,
|
|
323
|
-
agentType: state.agentType,
|
|
324
|
-
team: state.teamName,
|
|
325
|
-
model: state.model,
|
|
326
|
-
action: "started",
|
|
327
|
-
sessionId,
|
|
328
|
-
}
|
|
329
|
-
appendEntry(directory, startedEntry)
|
|
330
|
-
|
|
331
|
-
// Emit explicit teammate-registered entry when a spawn is matched
|
|
332
|
-
if (spawnMatch) {
|
|
333
|
-
appendEntry(directory, {
|
|
334
|
-
ts: ts(),
|
|
335
|
-
agent: state.agentName,
|
|
336
|
-
member: state.member,
|
|
337
|
-
agentType: state.agentType,
|
|
338
|
-
team: state.teamName,
|
|
339
|
-
model: state.model,
|
|
340
|
-
intendedSkills: state.intendedSkills,
|
|
341
|
-
action: "teammate-registered",
|
|
342
|
-
sessionId,
|
|
343
|
-
correlationMethod: spawnMatch.spawnedSessionId === sessionId ? "direct" : "time-window",
|
|
344
|
-
})
|
|
345
|
-
} else {
|
|
346
|
-
// No spawn match, schedule an unmatched-session warning
|
|
347
|
-
const capturedSessionId = sessionId
|
|
348
|
-
setTimeout(() => {
|
|
349
|
-
const s = sessionState.get(capturedSessionId)
|
|
350
|
-
if (!s || s.spawnMatched) return
|
|
351
|
-
appendEntry(directory, {
|
|
352
|
-
ts: ts(),
|
|
353
|
-
agent: s.agentName,
|
|
354
|
-
member: s.member,
|
|
355
|
-
team: s.teamName,
|
|
356
|
-
model: s.model,
|
|
357
|
-
action: "unmatched-session",
|
|
358
|
-
sessionId: capturedSessionId,
|
|
359
|
-
warning: "Session started with no matching team_spawn record. Agent identity may be inaccurate.",
|
|
360
|
-
})
|
|
361
|
-
}, UNMATCHED_SESSION_WARN_MS)
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
if (event?.type === "file.edited") {
|
|
366
|
-
const sessionId = event.properties?.sessionID ?? event.properties?.id
|
|
367
|
-
if (!sessionId) return
|
|
368
|
-
const state = sessionState.get(sessionId)
|
|
369
|
-
if (state) state.editCount++
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (event?.type === "session.idle") {
|
|
373
|
-
const sessionId = event.properties?.id ?? event.properties?.sessionID
|
|
374
|
-
if (!sessionId) return
|
|
375
|
-
const state = sessionState.get(sessionId)
|
|
376
|
-
if (!state) return
|
|
377
|
-
|
|
378
|
-
const skills = Array.from(state.skills || []).sort()
|
|
379
|
-
const usage = usagePayload(state)
|
|
380
|
-
appendEntry(directory, {
|
|
381
|
-
ts: ts(),
|
|
382
|
-
agent: state.agentName,
|
|
383
|
-
member: state.member,
|
|
384
|
-
agentType: state.agentType,
|
|
385
|
-
team: state.teamName,
|
|
386
|
-
model: state.model,
|
|
387
|
-
action: "completed",
|
|
388
|
-
filesEdited: state.editCount,
|
|
389
|
-
skills,
|
|
390
|
-
intendedSkills: state.intendedSkills,
|
|
391
|
-
usage,
|
|
392
|
-
})
|
|
393
|
-
|
|
394
|
-
trackCompletedByTeam(buildCompletedSnapshot(state, sessionId))
|
|
395
|
-
sessionState.delete(sessionId)
|
|
396
|
-
}
|
|
397
|
-
} catch
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
state.
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
state.
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
state.
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
const LOG_FILE = ".agents/session-log.json"
|
|
5
|
+
const SPAWN_MATCH_WINDOW_MS = 30000
|
|
6
|
+
const UNMATCHED_SESSION_WARN_MS = 30000
|
|
7
|
+
const DEBUG = process.env.SESSION_LOG_DEBUG === "true"
|
|
8
|
+
|
|
9
|
+
// Per-session state
|
|
10
|
+
const sessionState = new Map()
|
|
11
|
+
|
|
12
|
+
// Lead session -> current team name
|
|
13
|
+
const leadTeamBySession = new Map()
|
|
14
|
+
|
|
15
|
+
// Pending spawn records waiting for a session.created match
|
|
16
|
+
// Each entry: { leadSessionId, at, member, agentType, teamName, spawnedSessionId, intendedSkills }
|
|
17
|
+
const pendingSpawns = []
|
|
18
|
+
|
|
19
|
+
// spawnedSessionId -> pending spawn (direct correlation fast path)
|
|
20
|
+
const pendingSpawnBySessionId = new Map()
|
|
21
|
+
|
|
22
|
+
// Team -> completed session snapshots
|
|
23
|
+
const completedByTeam = new Map()
|
|
24
|
+
|
|
25
|
+
function ts() {
|
|
26
|
+
return new Date().toISOString()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function nowMs() {
|
|
30
|
+
return Date.now()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function appendEntry(directory, entry) {
|
|
34
|
+
const logPath = path.join(directory, LOG_FILE)
|
|
35
|
+
const dir = path.dirname(logPath)
|
|
36
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
37
|
+
|
|
38
|
+
let entries = []
|
|
39
|
+
if (fs.existsSync(logPath)) {
|
|
40
|
+
try { entries = JSON.parse(fs.readFileSync(logPath, "utf8")) } catch { /* ignore parse errors */ }
|
|
41
|
+
}
|
|
42
|
+
entries.push(entry)
|
|
43
|
+
fs.writeFileSync(logPath, JSON.stringify(entries, null, 2), "utf8")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveAgentName(session) {
|
|
47
|
+
const agentPath = session?.agent
|
|
48
|
+
if (agentPath) {
|
|
49
|
+
const base = path.basename(agentPath, ".md")
|
|
50
|
+
if (base) return base
|
|
51
|
+
}
|
|
52
|
+
return "lead"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveModel(session) {
|
|
56
|
+
// Try common field names used by OpenCode session objects
|
|
57
|
+
return session?.model || session?.modelId || session?.model_id || null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function addSkillToState(state, skillName) {
|
|
61
|
+
if (!skillName || !state) return false
|
|
62
|
+
if (!state.skills) state.skills = new Set()
|
|
63
|
+
if (state.skills.has(skillName)) return false
|
|
64
|
+
state.skills.add(skillName)
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Extract skill names hinted in a spawn prompt by scanning for known skill-like tokens.
|
|
69
|
+
// Matches strings that look like skill directory names (kebab-case words after "skill" keyword
|
|
70
|
+
// or adjacent to known skill name patterns).
|
|
71
|
+
function extractIntendedSkills(prompt) {
|
|
72
|
+
if (!prompt || typeof prompt !== "string") return []
|
|
73
|
+
const skills = new Set()
|
|
74
|
+
|
|
75
|
+
// Match explicit skill name mentions: e.g. "ob-pullrequest-gh", "browser-automation"
|
|
76
|
+
const kebabPattern = /\b([a-z][a-z0-9]*(?:-[a-z0-9]+){1,})\b/g
|
|
77
|
+
let m
|
|
78
|
+
while ((m = kebabPattern.exec(prompt)) !== null) {
|
|
79
|
+
const candidate = m[1]
|
|
80
|
+
// Filter to plausible skill names: 2+ segments, known prefixes or suffixes
|
|
81
|
+
if (
|
|
82
|
+
candidate.startsWith("ob-") ||
|
|
83
|
+
candidate.startsWith("openspec-") ||
|
|
84
|
+
candidate.startsWith("browser-") ||
|
|
85
|
+
candidate.endsWith("-gh") ||
|
|
86
|
+
candidate.endsWith("-az") ||
|
|
87
|
+
candidate.endsWith("-automation") ||
|
|
88
|
+
candidate.endsWith("-change") ||
|
|
89
|
+
candidate.endsWith("-engineer") ||
|
|
90
|
+
candidate.endsWith("-manager") ||
|
|
91
|
+
candidate.endsWith("-auditor")
|
|
92
|
+
) {
|
|
93
|
+
skills.add(candidate)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return Array.from(skills).sort()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toNum(v) {
|
|
100
|
+
if (typeof v === "number" && Number.isFinite(v)) return v
|
|
101
|
+
if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) return Number(v)
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function extractReportedTokens(obj) {
|
|
106
|
+
const out = { input: 0, output: 0, total: 0, found: false }
|
|
107
|
+
const visited = new Set()
|
|
108
|
+
|
|
109
|
+
function walk(node) {
|
|
110
|
+
if (!node || typeof node !== "object") return
|
|
111
|
+
if (visited.has(node)) return
|
|
112
|
+
visited.add(node)
|
|
113
|
+
|
|
114
|
+
for (const [k, v] of Object.entries(node)) {
|
|
115
|
+
const key = String(k).toLowerCase()
|
|
116
|
+
const n = toNum(v)
|
|
117
|
+
|
|
118
|
+
if (n !== null) {
|
|
119
|
+
if ((key.includes("input") || key.includes("prompt")) && key.includes("token")) {
|
|
120
|
+
out.input += n
|
|
121
|
+
out.found = true
|
|
122
|
+
} else if ((key.includes("output") || key.includes("completion")) && key.includes("token")) {
|
|
123
|
+
out.output += n
|
|
124
|
+
out.found = true
|
|
125
|
+
} else if (key.includes("total") && key.includes("token")) {
|
|
126
|
+
out.total += n
|
|
127
|
+
out.found = true
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (v && typeof v === "object") walk(v)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
walk(obj)
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function estimateTokens(state) {
|
|
140
|
+
const inTokens = Math.ceil((state.charIn || 0) / 4)
|
|
141
|
+
const outTokens = Math.ceil((state.charOut || 0) / 4)
|
|
142
|
+
const base = inTokens + outTokens + Math.max(0, (state.toolCalls || 0) * 20)
|
|
143
|
+
const low = Math.max(0, Math.floor(base * 0.7))
|
|
144
|
+
const high = Math.max(low, Math.ceil(base * 1.4))
|
|
145
|
+
return { low, high }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function usagePayload(state) {
|
|
149
|
+
const est = estimateTokens(state)
|
|
150
|
+
const reportedIn = state.reportedInputTokens || 0
|
|
151
|
+
const reportedOut = state.reportedOutputTokens || 0
|
|
152
|
+
const reportedTotalDirect = state.reportedTotalTokens || 0
|
|
153
|
+
const reportedTotalDerived = reportedIn + reportedOut
|
|
154
|
+
const reportedTotal = reportedTotalDirect || reportedTotalDerived || 0
|
|
155
|
+
|
|
156
|
+
let method = "heuristic"
|
|
157
|
+
if (reportedTotal > 0 && (est.low > 0 || est.high > 0)) method = "mixed"
|
|
158
|
+
else if (reportedTotal > 0) method = "reported"
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
inputTokensReported: reportedIn || null,
|
|
162
|
+
outputTokensReported: reportedOut || null,
|
|
163
|
+
totalTokensReported: reportedTotal || null,
|
|
164
|
+
tokenEstimateLow: est.low,
|
|
165
|
+
tokenEstimateHigh: est.high,
|
|
166
|
+
method,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function buildCompletedSnapshot(state, sessionId) {
|
|
171
|
+
return {
|
|
172
|
+
sessionId,
|
|
173
|
+
agent: state.agentName,
|
|
174
|
+
member: state.member || null,
|
|
175
|
+
agentType: state.agentType || null,
|
|
176
|
+
team: state.teamName || null,
|
|
177
|
+
model: state.model || null,
|
|
178
|
+
skills: Array.from(state.skills || []).sort(),
|
|
179
|
+
intendedSkills: state.intendedSkills || [],
|
|
180
|
+
usage: usagePayload(state),
|
|
181
|
+
filesEdited: state.editCount || 0,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildTeamSkillsSummary(teamName) {
|
|
186
|
+
const rows = completedByTeam.get(teamName) || []
|
|
187
|
+
const byAgent = {}
|
|
188
|
+
for (const row of rows) {
|
|
189
|
+
const key = row.member || row.agent || "unknown"
|
|
190
|
+
if (!byAgent[key]) byAgent[key] = { agentType: row.agentType || null, model: row.model || null, skills: new Set(), intendedSkills: new Set() }
|
|
191
|
+
for (const s of row.skills || []) byAgent[key].skills.add(s)
|
|
192
|
+
for (const s of row.intendedSkills || []) byAgent[key].intendedSkills.add(s)
|
|
193
|
+
}
|
|
194
|
+
const out = {}
|
|
195
|
+
for (const [k, v] of Object.entries(byAgent)) {
|
|
196
|
+
const skills = Array.from(v.skills).sort()
|
|
197
|
+
const intendedSkills = Array.from(v.intendedSkills).sort()
|
|
198
|
+
const missingSkills = intendedSkills.filter(s => !v.skills.has(s))
|
|
199
|
+
out[k] = {
|
|
200
|
+
agentType: v.agentType,
|
|
201
|
+
model: v.model,
|
|
202
|
+
skills,
|
|
203
|
+
intendedSkills,
|
|
204
|
+
missingSkills: missingSkills.length > 0 ? missingSkills : undefined,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return out
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function trackCompletedByTeam(snapshot) {
|
|
211
|
+
if (!snapshot.team) return
|
|
212
|
+
if (!completedByTeam.has(snapshot.team)) completedByTeam.set(snapshot.team, [])
|
|
213
|
+
completedByTeam.get(snapshot.team).push(snapshot)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function enqueuePendingSpawn(leadSessionId, args, spawnOutput) {
|
|
217
|
+
// Try to extract spawnedSessionId from team_spawn output for direct correlation
|
|
218
|
+
const spawnedSessionId =
|
|
219
|
+
spawnOutput?.sessionId ||
|
|
220
|
+
spawnOutput?.session_id ||
|
|
221
|
+
spawnOutput?.id ||
|
|
222
|
+
spawnOutput?.data?.sessionId ||
|
|
223
|
+
spawnOutput?.data?.session_id ||
|
|
224
|
+
spawnOutput?.data?.id ||
|
|
225
|
+
null
|
|
226
|
+
|
|
227
|
+
const record = {
|
|
228
|
+
leadSessionId,
|
|
229
|
+
at: nowMs(),
|
|
230
|
+
member: args?.name || null,
|
|
231
|
+
agentType: args?.agent || null,
|
|
232
|
+
teamName: leadTeamBySession.get(leadSessionId) || null,
|
|
233
|
+
spawnedSessionId,
|
|
234
|
+
intendedSkills: extractIntendedSkills(args?.prompt),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
pendingSpawns.push(record)
|
|
238
|
+
|
|
239
|
+
if (spawnedSessionId) {
|
|
240
|
+
pendingSpawnBySessionId.set(spawnedSessionId, record)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function matchPendingSpawn(sessionId) {
|
|
245
|
+
const now = nowMs()
|
|
246
|
+
|
|
247
|
+
// Fast path: direct session ID correlation from team_spawn output
|
|
248
|
+
if (sessionId && pendingSpawnBySessionId.has(sessionId)) {
|
|
249
|
+
const record = pendingSpawnBySessionId.get(sessionId)
|
|
250
|
+
pendingSpawnBySessionId.delete(sessionId)
|
|
251
|
+
const idx = pendingSpawns.indexOf(record)
|
|
252
|
+
if (idx !== -1) pendingSpawns.splice(idx, 1)
|
|
253
|
+
return record
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Fallback: time-window heuristic (drop expired first)
|
|
257
|
+
for (let i = pendingSpawns.length - 1; i >= 0; i--) {
|
|
258
|
+
if (now - pendingSpawns[i].at > SPAWN_MATCH_WINDOW_MS) {
|
|
259
|
+
pendingSpawnBySessionId.delete(pendingSpawns[i].spawnedSessionId)
|
|
260
|
+
pendingSpawns.splice(i, 1)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (pendingSpawns.length === 0) return null
|
|
264
|
+
return pendingSpawns.shift()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Maps ensemble tool name -> function that extracts log entry fields from args
|
|
268
|
+
const ENSEMBLE_TOOL_HANDLERS = {
|
|
269
|
+
team_create: (args) => ({ action: "team-created", team: args.name }),
|
|
270
|
+
team_spawn: (args) => ({ action: "teammate-spawned", name: args.name, agentType: args.agent }),
|
|
271
|
+
team_shutdown: (args) => ({ action: "teammate-shutdown", name: args.name }),
|
|
272
|
+
team_merge: (args) => ({ action: "teammate-merged", name: args.name }),
|
|
273
|
+
team_cleanup: () => ({ action: "team-cleanup" }),
|
|
274
|
+
team_status: () => ({ action: "team-status-checked" }),
|
|
275
|
+
team_results: (args) => ({ action: "team-results-read", from: args.from }),
|
|
276
|
+
team_message: (args) => ({ action: "team-message", to: args.to ?? "lead", preview: String(args.text ?? "").slice(0, 120) }),
|
|
277
|
+
team_broadcast: (args) => ({ action: "team-broadcast", preview: String(args.text ?? "").slice(0, 120) }),
|
|
278
|
+
team_tasks_add: (args) => ({ action: "tasks-added", count: Array.isArray(args.tasks) ? args.tasks.length : "?" }),
|
|
279
|
+
team_tasks_complete: (args) => ({ action: "task-completed", taskId: args.task_id }),
|
|
280
|
+
team_claim: (args) => ({ action: "task-claimed", taskId: args.task_id }),
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export const SessionLogPlugin = async ({ client, directory }) => {
|
|
284
|
+
return {
|
|
285
|
+
event: async ({ event }) => {
|
|
286
|
+
try {
|
|
287
|
+
if (event?.type === "session.created") {
|
|
288
|
+
const sessionId = event.properties?.id ?? event.properties?.sessionID
|
|
289
|
+
if (!sessionId) return
|
|
290
|
+
|
|
291
|
+
const res = await client.session.get({ path: { id: sessionId } })
|
|
292
|
+
const session = res?.data
|
|
293
|
+
const fallbackAgent = resolveAgentName(session)
|
|
294
|
+
const model = resolveModel(session)
|
|
295
|
+
const spawnMatch = matchPendingSpawn(sessionId)
|
|
296
|
+
|
|
297
|
+
const state = {
|
|
298
|
+
agentName: spawnMatch?.member || fallbackAgent,
|
|
299
|
+
member: spawnMatch?.member || null,
|
|
300
|
+
agentType: spawnMatch?.agentType || null,
|
|
301
|
+
teamName: spawnMatch?.teamName || null,
|
|
302
|
+
model,
|
|
303
|
+
intendedSkills: spawnMatch?.intendedSkills || [],
|
|
304
|
+
editCount: 0,
|
|
305
|
+
skills: new Set(),
|
|
306
|
+
startedAtMs: nowMs(),
|
|
307
|
+
toolCalls: 0,
|
|
308
|
+
charIn: 0,
|
|
309
|
+
charOut: 0,
|
|
310
|
+
reportedInputTokens: 0,
|
|
311
|
+
reportedOutputTokens: 0,
|
|
312
|
+
reportedTotalTokens: 0,
|
|
313
|
+
// Track whether this session was matched to a spawn record
|
|
314
|
+
spawnMatched: !!spawnMatch,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
sessionState.set(sessionId, state)
|
|
318
|
+
|
|
319
|
+
const startedEntry = {
|
|
320
|
+
ts: ts(),
|
|
321
|
+
agent: state.agentName,
|
|
322
|
+
member: state.member,
|
|
323
|
+
agentType: state.agentType,
|
|
324
|
+
team: state.teamName,
|
|
325
|
+
model: state.model,
|
|
326
|
+
action: "started",
|
|
327
|
+
sessionId,
|
|
328
|
+
}
|
|
329
|
+
appendEntry(directory, startedEntry)
|
|
330
|
+
|
|
331
|
+
// Emit explicit teammate-registered entry when a spawn is matched
|
|
332
|
+
if (spawnMatch) {
|
|
333
|
+
appendEntry(directory, {
|
|
334
|
+
ts: ts(),
|
|
335
|
+
agent: state.agentName,
|
|
336
|
+
member: state.member,
|
|
337
|
+
agentType: state.agentType,
|
|
338
|
+
team: state.teamName,
|
|
339
|
+
model: state.model,
|
|
340
|
+
intendedSkills: state.intendedSkills,
|
|
341
|
+
action: "teammate-registered",
|
|
342
|
+
sessionId,
|
|
343
|
+
correlationMethod: spawnMatch.spawnedSessionId === sessionId ? "direct" : "time-window",
|
|
344
|
+
})
|
|
345
|
+
} else {
|
|
346
|
+
// No spawn match, schedule an unmatched-session warning
|
|
347
|
+
const capturedSessionId = sessionId
|
|
348
|
+
setTimeout(() => {
|
|
349
|
+
const s = sessionState.get(capturedSessionId)
|
|
350
|
+
if (!s || s.spawnMatched) return
|
|
351
|
+
appendEntry(directory, {
|
|
352
|
+
ts: ts(),
|
|
353
|
+
agent: s.agentName,
|
|
354
|
+
member: s.member,
|
|
355
|
+
team: s.teamName,
|
|
356
|
+
model: s.model,
|
|
357
|
+
action: "unmatched-session",
|
|
358
|
+
sessionId: capturedSessionId,
|
|
359
|
+
warning: "Session started with no matching team_spawn record. Agent identity may be inaccurate.",
|
|
360
|
+
})
|
|
361
|
+
}, UNMATCHED_SESSION_WARN_MS)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (event?.type === "file.edited") {
|
|
366
|
+
const sessionId = event.properties?.sessionID ?? event.properties?.id
|
|
367
|
+
if (!sessionId) return
|
|
368
|
+
const state = sessionState.get(sessionId)
|
|
369
|
+
if (state) state.editCount++
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (event?.type === "session.idle") {
|
|
373
|
+
const sessionId = event.properties?.id ?? event.properties?.sessionID
|
|
374
|
+
if (!sessionId) return
|
|
375
|
+
const state = sessionState.get(sessionId)
|
|
376
|
+
if (!state) return
|
|
377
|
+
|
|
378
|
+
const skills = Array.from(state.skills || []).sort()
|
|
379
|
+
const usage = usagePayload(state)
|
|
380
|
+
appendEntry(directory, {
|
|
381
|
+
ts: ts(),
|
|
382
|
+
agent: state.agentName,
|
|
383
|
+
member: state.member,
|
|
384
|
+
agentType: state.agentType,
|
|
385
|
+
team: state.teamName,
|
|
386
|
+
model: state.model,
|
|
387
|
+
action: "completed",
|
|
388
|
+
filesEdited: state.editCount,
|
|
389
|
+
skills,
|
|
390
|
+
intendedSkills: state.intendedSkills,
|
|
391
|
+
usage,
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
trackCompletedByTeam(buildCompletedSnapshot(state, sessionId))
|
|
395
|
+
sessionState.delete(sessionId)
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
// ignore
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
"tool.execute.after": async (input, output) => {
|
|
403
|
+
try {
|
|
404
|
+
const sessionId = input?.sessionID ?? input?.session_id
|
|
405
|
+
if (!sessionId) return
|
|
406
|
+
|
|
407
|
+
const state = sessionState.get(sessionId)
|
|
408
|
+
if (!state) return
|
|
409
|
+
|
|
410
|
+
const tool = input?.tool
|
|
411
|
+
const args = input?.args ?? {}
|
|
412
|
+
|
|
413
|
+
state.toolCalls++
|
|
414
|
+
state.charIn += JSON.stringify(args).length
|
|
415
|
+
state.charOut += JSON.stringify(output ?? {}).length
|
|
416
|
+
|
|
417
|
+
const reportedIn = extractReportedTokens(input)
|
|
418
|
+
const reportedOut = extractReportedTokens(output)
|
|
419
|
+
if (reportedIn.found) {
|
|
420
|
+
state.reportedInputTokens += reportedIn.input
|
|
421
|
+
state.reportedOutputTokens += reportedIn.output
|
|
422
|
+
state.reportedTotalTokens += reportedIn.total
|
|
423
|
+
}
|
|
424
|
+
if (reportedOut.found) {
|
|
425
|
+
state.reportedInputTokens += reportedOut.input
|
|
426
|
+
state.reportedOutputTokens += reportedOut.output
|
|
427
|
+
state.reportedTotalTokens += reportedOut.total
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (DEBUG && !reportedIn.found && !reportedOut.found && tool !== "read") {
|
|
431
|
+
appendEntry(directory, {
|
|
432
|
+
ts: ts(),
|
|
433
|
+
agent: state.agentName,
|
|
434
|
+
action: "debug-no-token-metrics",
|
|
435
|
+
tool,
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Track skill loads via skill tool (primary)
|
|
440
|
+
if (tool === "skill") {
|
|
441
|
+
const skillName = input?.args?.name
|
|
442
|
+
const added = addSkillToState(state, skillName)
|
|
443
|
+
if (added) {
|
|
444
|
+
appendEntry(directory, {
|
|
445
|
+
ts: ts(),
|
|
446
|
+
agent: state.agentName,
|
|
447
|
+
member: state.member,
|
|
448
|
+
agentType: state.agentType,
|
|
449
|
+
team: state.teamName,
|
|
450
|
+
model: state.model,
|
|
451
|
+
action: "skill-loaded",
|
|
452
|
+
skill: skillName,
|
|
453
|
+
source: "skill-tool",
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Track skill loads via reading SKILL.md (fallback)
|
|
460
|
+
if (tool === "read") {
|
|
461
|
+
const filePath = input?.args?.filePath ?? ""
|
|
462
|
+
const match = filePath.match(/[/\\]skills[/\\]([^/\\]+)[/\\]SKILL\.md$/i)
|
|
463
|
+
if (match) {
|
|
464
|
+
const skillName = match[1]
|
|
465
|
+
const added = addSkillToState(state, skillName)
|
|
466
|
+
if (added) {
|
|
467
|
+
appendEntry(directory, {
|
|
468
|
+
ts: ts(),
|
|
469
|
+
agent: state.agentName,
|
|
470
|
+
member: state.member,
|
|
471
|
+
agentType: state.agentType,
|
|
472
|
+
team: state.teamName,
|
|
473
|
+
model: state.model,
|
|
474
|
+
action: "skill-loaded",
|
|
475
|
+
skill: skillName,
|
|
476
|
+
source: "read-skill-file",
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Track ensemble tool calls
|
|
484
|
+
const ensembleHandler = ENSEMBLE_TOOL_HANDLERS[tool]
|
|
485
|
+
if (!ensembleHandler) return
|
|
486
|
+
|
|
487
|
+
const entry = {
|
|
488
|
+
ts: ts(),
|
|
489
|
+
agent: state.agentName,
|
|
490
|
+
member: state.member,
|
|
491
|
+
agentType: state.agentType,
|
|
492
|
+
team: state.teamName,
|
|
493
|
+
model: state.model,
|
|
494
|
+
...ensembleHandler(args),
|
|
495
|
+
}
|
|
496
|
+
appendEntry(directory, entry)
|
|
497
|
+
|
|
498
|
+
if (tool === "team_create") {
|
|
499
|
+
leadTeamBySession.set(sessionId, args?.name || null)
|
|
500
|
+
state.teamName = args?.name || state.teamName
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (tool === "team_spawn") {
|
|
504
|
+
enqueuePendingSpawn(sessionId, args, output)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (tool === "team_cleanup") {
|
|
508
|
+
const teamName = state.teamName || leadTeamBySession.get(sessionId)
|
|
509
|
+
appendEntry(directory, {
|
|
510
|
+
ts: ts(),
|
|
511
|
+
agent: state.agentName,
|
|
512
|
+
model: state.model,
|
|
513
|
+
action: "team-skills-summary",
|
|
514
|
+
team: teamName || null,
|
|
515
|
+
byAgent: teamName ? buildTeamSkillsSummary(teamName) : {},
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
} catch {
|
|
519
|
+
// ignore
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|