opencode-harness-kit 0.5.4 → 0.5.6
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/CHANGELOG.md +207 -1
- package/bin/oc-harness.mjs +278 -45
- package/dist/config.d.ts +5 -0
- package/dist/denylist.d.ts +5 -1
- package/dist/guard.d.ts +17 -1
- package/dist/health.d.ts +69 -7
- package/dist/index.js +514 -73
- package/dist/profile.d.ts +32 -2
- package/dist/telemetry-store.d.ts +22 -0
- package/docs/EXIT-CODES.md +7 -0
- package/docs/KNOWN-LIMITS.md +53 -11
- package/eval/measure-prune.mjs +228 -54
- package/package.json +1 -1
- package/src/config.js +126 -3
- package/src/denylist.js +45 -2
- package/src/guard.ts +78 -7
- package/src/health.js +130 -5
- package/src/lane-usage.js +13 -6
- package/src/profile.js +187 -25
- package/src/prune.ts +58 -13
- package/src/router.ts +217 -17
- package/src/rules.js +440 -19
- package/src/sqlite.js +80 -0
- package/src/tasks.js +193 -40
- package/src/telemetry-store.ts +27 -4
- package/src/telemetry.ts +69 -9
- package/src/trust.js +182 -54
package/src/tasks.js
CHANGED
|
@@ -195,6 +195,12 @@ export const isBlocked = (t) => t.status === "blocked"
|
|
|
195
195
|
* and its output became one string, and the driver handed that string to a
|
|
196
196
|
* shell. A separate field cannot be mistaken for a command.
|
|
197
197
|
*
|
|
198
|
+
* The writes happen in ONE order, and the order is part of the contract: the
|
|
199
|
+
* previous output is removed first, then the status is written, then the new
|
|
200
|
+
* output is written. A field line ends the output block, so a status line
|
|
201
|
+
* written before the old output was removed cut that block in half - and the
|
|
202
|
+
* half that survived was folded into the status by the parser.
|
|
203
|
+
*
|
|
198
204
|
* @param {string} text
|
|
199
205
|
* @param {Task} task
|
|
200
206
|
* @param {{ status: string, verifyOutput?: string }} outcome
|
|
@@ -202,46 +208,100 @@ export const isBlocked = (t) => t.status === "blocked"
|
|
|
202
208
|
*/
|
|
203
209
|
export function markTask(text, task, { status, verifyOutput }) {
|
|
204
210
|
const lines = String(text).split("\n")
|
|
205
|
-
|
|
211
|
+
// The lines of the task as they are RIGHT NOW.
|
|
212
|
+
//
|
|
213
|
+
// Every edit below moves the lines under it, so the range of the task is
|
|
214
|
+
// tracked here. The task the caller parsed was parsed before any of these
|
|
215
|
+
// edits, so its `end` goes stale as soon as one of them lands.
|
|
216
|
+
const range = { start: task.start, end: task.end }
|
|
217
|
+
|
|
218
|
+
const boxLine = lines[range.start] ?? ""
|
|
206
219
|
// The indentation is part of the document: a task nested under a heading
|
|
207
220
|
// keeps its nesting when it is marked.
|
|
208
|
-
lines[
|
|
221
|
+
lines[range.start] = boxLine.replace(
|
|
209
222
|
/^(\s*)-\s*\[[ xX]\]/,
|
|
210
223
|
(_match, indent) => `${indent}- ${status === "done" ? "[x]" : "[ ]"}`,
|
|
211
224
|
)
|
|
212
225
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
226
|
+
// The PREVIOUS output is removed FIRST, before anything else is written
|
|
227
|
+
// into the task.
|
|
228
|
+
//
|
|
229
|
+
// A task that is marked twice keeps one output block, not two.
|
|
230
|
+
//
|
|
231
|
+
// It used to be removed after the status line had been written. The
|
|
232
|
+
// status line is a field line, and a field line ends the output block, so
|
|
233
|
+
// the removal took the FIRST line of the old output and left its wrapped
|
|
234
|
+
// lines behind. The parser folds a wrapped line back into the field above
|
|
235
|
+
// it, so those leftovers were folded into the status - and the status read
|
|
236
|
+
// back as "done old line 2 old line 3" instead of "done". The task was not
|
|
237
|
+
// done; it was done with a story attached.
|
|
238
|
+
if (verifyOutput) {
|
|
239
|
+
const previous = outputBlock(lines, range)
|
|
240
|
+
if (previous) {
|
|
241
|
+
const removed = previous.end - previous.start + 1
|
|
242
|
+
lines.splice(previous.start, removed)
|
|
243
|
+
if (previous.end <= range.end) range.end -= removed
|
|
219
244
|
}
|
|
220
245
|
}
|
|
221
|
-
if (statusLine >= 0) lines[statusLine] = lines[statusLine].replace(
|
|
222
|
-
/(\s-\s+status\s*:).*$/,
|
|
223
|
-
`$1 ${status}`,
|
|
224
|
-
)
|
|
225
246
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
247
|
+
const statusLine = fieldLine(lines, range, "status")
|
|
248
|
+
if (statusLine >= 0) {
|
|
249
|
+
lines[statusLine] = lines[statusLine].replace(
|
|
250
|
+
/(\s-\s+status\s*:).*$/,
|
|
251
|
+
`$1 ${status}`,
|
|
252
|
+
)
|
|
253
|
+
} else {
|
|
254
|
+
// A task with no `status:` field had the status EVAPORATE.
|
|
255
|
+
//
|
|
256
|
+
// The status was only ever written into a status line that already
|
|
257
|
+
// existed, so a task marked blocked without one kept its unticked box
|
|
258
|
+
// and nothing else. The parser reads the status back from that box, so
|
|
259
|
+
// the block became "pending" - and `nextPending()` offered the blocked
|
|
260
|
+
// work again. The driver contract says blocked work is never retried.
|
|
261
|
+
//
|
|
262
|
+
// The status line is written where the other fields are, so the parser
|
|
263
|
+
// reads it back the way it reads them - and AFTER the whole block of
|
|
264
|
+
// the last field, not after the field line itself.
|
|
265
|
+
//
|
|
266
|
+
// Written after the field line, the status line landed INSIDE the block
|
|
267
|
+
// of a field that has wrapped lines under it: the output field, once the
|
|
268
|
+
// driver has run the task. The parser folded the wrapped lines into the
|
|
269
|
+
// status, so it read back as "blocked continued", the block was gone,
|
|
270
|
+
// and the blocked work was offered again.
|
|
271
|
+
const block = lastFieldBlock(lines, range)
|
|
272
|
+
const indent = fieldIndent(lines, range, block.field)
|
|
273
|
+
lines.splice(block.end + 1, 0, `${indent}- status: ${status}`)
|
|
274
|
+
range.end += 1
|
|
275
|
+
}
|
|
230
276
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
277
|
+
if (verifyOutput) {
|
|
278
|
+
// The output goes into its own `output:` field rather than under the
|
|
279
|
+
// `verify:` line. It used to be written as continuation lines under
|
|
280
|
+
// `verify:`, and the parser folded a continuation back into the field
|
|
281
|
+
// above it - so the command and its output became one string, and the
|
|
282
|
+
// driver handed that string to a shell. A separate field cannot be
|
|
283
|
+
// mistaken for a command.
|
|
284
|
+
//
|
|
285
|
+
// It is written after the whole block of the `verify:` field, so it
|
|
286
|
+
// cannot land inside that block either - a wrapped line under the
|
|
287
|
+
// verify field would be folded into the output above it.
|
|
288
|
+
const verifyAt = fieldLine(lines, range, "verify")
|
|
289
|
+
const block =
|
|
290
|
+
verifyAt >= 0
|
|
291
|
+
? { field: verifyAt, end: fieldBlockEnd(lines, range, verifyAt) }
|
|
292
|
+
: lastFieldBlock(lines, range)
|
|
234
293
|
// The field line sits at the same level as the field it belongs to; only
|
|
235
294
|
// the continuation lines under it are indented deeper.
|
|
236
|
-
const
|
|
237
|
-
const contIndent = `${
|
|
295
|
+
const indent = fieldIndent(lines, range, block.field)
|
|
296
|
+
const contIndent = `${indent} `
|
|
238
297
|
const quoted = String(verifyOutput)
|
|
239
298
|
.trimEnd()
|
|
240
299
|
.split("\n")
|
|
241
300
|
.map((l) => `${contIndent}${l}`)
|
|
242
301
|
// The first line rides on the field, the rest are continuations of it.
|
|
243
|
-
const
|
|
244
|
-
lines.splice(
|
|
302
|
+
const written = [`${indent}- output: ${quoted[0].trim()}`, ...quoted.slice(1)]
|
|
303
|
+
lines.splice(block.end + 1, 0, ...written)
|
|
304
|
+
range.end += written.length
|
|
245
305
|
}
|
|
246
306
|
|
|
247
307
|
return lines.join("\n")
|
|
@@ -249,46 +309,139 @@ export function markTask(text, task, { status, verifyOutput }) {
|
|
|
249
309
|
|
|
250
310
|
/**
|
|
251
311
|
* The line a named field sits on, or -1.
|
|
312
|
+
*
|
|
313
|
+
* The scan stops at a task line or a heading: the fields under a nested
|
|
314
|
+
* task belong to that task, not to the one above it.
|
|
315
|
+
*
|
|
252
316
|
* @param {string[]} lines
|
|
253
|
-
* @param {
|
|
317
|
+
* @param {{ start: number, end: number }} range
|
|
254
318
|
* @param {string} name
|
|
255
319
|
* @returns {number}
|
|
256
320
|
*/
|
|
257
|
-
function fieldLine(lines,
|
|
258
|
-
|
|
259
|
-
|
|
321
|
+
function fieldLine(lines, range, name) {
|
|
322
|
+
// The scan starts below the task's own line: that line is the task, not
|
|
323
|
+
// one of its fields.
|
|
324
|
+
for (let i = range.start + 1; i <= range.end + 1 && i < lines.length; i++) {
|
|
325
|
+
const line = lines[i] ?? ""
|
|
326
|
+
if (taskRe.test(line) || headingRe.test(line)) break
|
|
327
|
+
const f = line.match(fieldRe)
|
|
260
328
|
if (f && f[1] === name) return i
|
|
261
329
|
}
|
|
262
330
|
return -1
|
|
263
331
|
}
|
|
264
332
|
|
|
265
333
|
/**
|
|
266
|
-
* The last
|
|
334
|
+
* The last line of a field's block: the field line plus the lines wrapped
|
|
335
|
+
* under it.
|
|
336
|
+
*
|
|
337
|
+
* A field line is where a field is written, but it is not the end of the
|
|
338
|
+
* field: the parser folds a wrapped line back into the value above it, so
|
|
339
|
+
* the end of the block is the end of the value as the parser reads it. A
|
|
340
|
+
* field written inside that block takes the wrapped lines with it.
|
|
341
|
+
*
|
|
267
342
|
* @param {string[]} lines
|
|
268
|
-
* @param {
|
|
343
|
+
* @param {{ start: number, end: number }} range
|
|
344
|
+
* @param {number} fieldAt the line the field is written on
|
|
269
345
|
* @returns {number}
|
|
270
346
|
*/
|
|
271
|
-
function
|
|
272
|
-
let
|
|
273
|
-
for (let i =
|
|
274
|
-
|
|
347
|
+
function fieldBlockEnd(lines, range, fieldAt) {
|
|
348
|
+
let end = fieldAt
|
|
349
|
+
for (let i = fieldAt + 1; i <= range.end + 1 && i < lines.length; i++) {
|
|
350
|
+
const line = lines[i] ?? ""
|
|
351
|
+
// A task, a heading or the next field ends the block.
|
|
352
|
+
if (taskRe.test(line) || headingRe.test(line)) break
|
|
353
|
+
if (fieldRe.test(line)) break
|
|
354
|
+
// A blank line is not part of the value, but it does not end the block
|
|
355
|
+
// either: the parser skips it and folds the wrapped line under it back
|
|
356
|
+
// into the field.
|
|
357
|
+
if (!line.trim()) continue
|
|
358
|
+
// A line that is not indented deeper is not a wrapped line.
|
|
359
|
+
if (!/^\s{4,}\S/.test(line)) break
|
|
360
|
+
end = i
|
|
361
|
+
}
|
|
362
|
+
return end
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The last field of a task: the line it is written on, and the last line of
|
|
367
|
+
* its block.
|
|
368
|
+
*
|
|
369
|
+
* `field` is -1 when the task has no field of its own, and `end` is then
|
|
370
|
+
* the task's own line - the place a first field is written.
|
|
371
|
+
*
|
|
372
|
+
* @param {string[]} lines
|
|
373
|
+
* @param {{ start: number, end: number }} range
|
|
374
|
+
* @returns {{ field: number, end: number }}
|
|
375
|
+
*/
|
|
376
|
+
function lastFieldBlock(lines, range) {
|
|
377
|
+
let field = -1
|
|
378
|
+
// The scan starts below the task's own line: that line is the task, not
|
|
379
|
+
// one of its fields.
|
|
380
|
+
for (let i = range.start + 1; i <= range.end + 1 && i < lines.length; i++) {
|
|
381
|
+
const line = lines[i] ?? ""
|
|
382
|
+
if (taskRe.test(line) || headingRe.test(line)) break
|
|
383
|
+
if (fieldRe.test(line)) field = i
|
|
275
384
|
}
|
|
276
|
-
return
|
|
385
|
+
if (field < 0) return { field: -1, end: range.start }
|
|
386
|
+
return { field, end: fieldBlockEnd(lines, range, field) }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The indentation a field line of a task is written with.
|
|
391
|
+
*
|
|
392
|
+
* The fields of a task sit one level deeper than the task itself, so a
|
|
393
|
+
* field inserted into a task that has none yet follows that convention: a
|
|
394
|
+
* field at the task's own level reads as a task of its own, or as prose the
|
|
395
|
+
* parser skips.
|
|
396
|
+
*
|
|
397
|
+
* @param {string[]} lines
|
|
398
|
+
* @param {{ start: number, end: number }} range
|
|
399
|
+
* @param {number} fieldAt the line the field is written on, or -1 when the
|
|
400
|
+
* task has no field of its own yet
|
|
401
|
+
* @returns {string}
|
|
402
|
+
*/
|
|
403
|
+
function fieldIndent(lines, range, fieldAt) {
|
|
404
|
+
const source = lines[fieldAt >= 0 ? fieldAt : range.start] ?? ""
|
|
405
|
+
const own = (source.match(/^\s*/) ?? [""])[0]
|
|
406
|
+
if (fieldAt < 0) return `${own} `
|
|
407
|
+
return own
|
|
277
408
|
}
|
|
278
409
|
|
|
279
410
|
/**
|
|
280
411
|
* The lines a task's `output:` field occupies, or null when it has none.
|
|
412
|
+
*
|
|
413
|
+
* The block ends at the first line that is NOT part of the output - and a
|
|
414
|
+
* task line or a heading is never part of it, however deeply it is indented.
|
|
415
|
+
*
|
|
416
|
+
* The indentation heuristic alone said otherwise: a nested task indented
|
|
417
|
+
* four spaces or more looked like a continuation of the output above it, so
|
|
418
|
+
* the block swallowed it and the rewrite DELETED it. The file's own header
|
|
419
|
+
* promises that every byte the loop does not own survives a rewrite, and a
|
|
420
|
+
* task the loop never owned was destroyed by marking the task above it.
|
|
421
|
+
*
|
|
422
|
+
* A blank line at the end of the block is not part of it: it belongs to the
|
|
423
|
+
* file, not to the output, and the rewrite must not swallow it.
|
|
424
|
+
*
|
|
281
425
|
* @param {string[]} lines
|
|
282
|
-
* @param {
|
|
426
|
+
* @param {{ start: number, end: number }} range
|
|
283
427
|
* @returns {{ start: number, end: number } | null}
|
|
284
428
|
*/
|
|
285
|
-
function outputBlock(lines,
|
|
286
|
-
const start = fieldLine(lines,
|
|
429
|
+
function outputBlock(lines, range) {
|
|
430
|
+
const start = fieldLine(lines, range, "output")
|
|
287
431
|
if (start < 0) return null
|
|
288
432
|
let end = start
|
|
289
|
-
for (let i = start + 1; i <=
|
|
290
|
-
|
|
291
|
-
|
|
433
|
+
for (let i = start + 1; i <= range.end + 1 && i < lines.length; i++) {
|
|
434
|
+
const line = lines[i] ?? ""
|
|
435
|
+
// A task or a heading ends the block. This is checked BEFORE the
|
|
436
|
+
// indentation heuristic, because a nested task is indented deeper than
|
|
437
|
+
// the field it sits under - that is what nesting means here.
|
|
438
|
+
if (taskRe.test(line) || headingRe.test(line)) break
|
|
439
|
+
if (fieldRe.test(line)) break
|
|
440
|
+
// A blank line is not part of the output, but it does not end the block
|
|
441
|
+
// either: the parser skips it and folds the wrapped line under it back
|
|
442
|
+
// into the output.
|
|
443
|
+
if (!line.trim()) continue
|
|
444
|
+
if (!/^\s{4,}\S/.test(line)) break
|
|
292
445
|
end = i
|
|
293
446
|
}
|
|
294
447
|
return { start, end }
|
package/src/telemetry-store.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// The file is a JSONL in the project, gitignored, and never a network call.
|
|
13
13
|
|
|
14
|
-
import { appendFileSync, existsSync, mkdirSync, statSync, renameSync } from "node:fs"
|
|
14
|
+
import { appendFileSync, existsSync, mkdirSync, rmSync, statSync, renameSync } from "node:fs"
|
|
15
15
|
import { join } from "node:path"
|
|
16
16
|
|
|
17
17
|
/** Rotate above this, so a long-running project cannot fill its disk. */
|
|
@@ -33,10 +33,33 @@ export function logFiles(dir: string): string[] {
|
|
|
33
33
|
return out
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Rotate by size so a long-running project cannot fill its disk.
|
|
38
|
+
*
|
|
39
|
+
* THREE generations are kept: the current file, `.1` and `.2`. The reader
|
|
40
|
+
* enumerates all three, so the writer has to produce all three.
|
|
41
|
+
*
|
|
42
|
+
* The rotation used to rename the current file straight to `.1`, and a
|
|
43
|
+
* rename REPLACES the destination on POSIX - so the previous generation was
|
|
44
|
+
* destroyed on every rotation and `.2` never came into existence. Two
|
|
45
|
+
* rotations after the fact, the oldest history was gone and the third
|
|
46
|
+
* generation the reader looks for was missing: the history a project lost
|
|
47
|
+
* was lost on a schedule nobody was watching.
|
|
48
|
+
*
|
|
49
|
+
* So the oldest is shifted out of the way first, and the oldest of the
|
|
50
|
+
* three is dropped - the disk use stays bounded at three files, and the
|
|
51
|
+
* generation that is dropped is the one that was already two rotations
|
|
52
|
+
* old.
|
|
53
|
+
*
|
|
54
|
+
* @param file the file to rotate
|
|
55
|
+
* @param maxBytes the size above which it is rotated
|
|
56
|
+
*/
|
|
57
|
+
export function rotate(file: string, maxBytes: number = MAX_BYTES) {
|
|
38
58
|
try {
|
|
39
|
-
if (existsSync(file) && statSync(file).size >
|
|
59
|
+
if (existsSync(file) && statSync(file).size > maxBytes) {
|
|
60
|
+
const oldest = `${file}.2`
|
|
61
|
+
if (existsSync(oldest)) rmSync(oldest, { force: true })
|
|
62
|
+
if (existsSync(`${file}.1`)) renameSync(`${file}.1`, oldest)
|
|
40
63
|
renameSync(file, `${file}.1`)
|
|
41
64
|
}
|
|
42
65
|
} catch {
|
package/src/telemetry.ts
CHANGED
|
@@ -27,10 +27,24 @@
|
|
|
27
27
|
// one with a duration, so "how long does this lane take" and "was this lane
|
|
28
28
|
// called at all" are answered from the same file without guessing.
|
|
29
29
|
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
30
|
+
// WHERE THE LANE NAME COMES FROM
|
|
31
|
+
//
|
|
32
|
+
// Not from the message. opencode's AssistantMessage has no `agent` field - it
|
|
33
|
+
// has id, role, model, tokens, time and parentID - and the plugin read the
|
|
34
|
+
// agent from exactly that missing field. The lane record was therefore never
|
|
35
|
+
// written: not once, in any session, on any machine. The measurement that
|
|
36
|
+
// decides whether the fleet stays at ten lanes was reading a file that could
|
|
37
|
+
// not exist.
|
|
38
|
+
//
|
|
39
|
+
// The agent name is on the REQUEST. `chat.params` carries the agent and the
|
|
40
|
+
// model of the call that is about to be made, and the user message that call
|
|
41
|
+
// belongs to; the assistant message names that user message as its parent, so
|
|
42
|
+
// the two join.
|
|
43
|
+
//
|
|
44
|
+
// The `chat.message` hook is not used for this. Measured on OpenCode 1.19 its
|
|
45
|
+
// `agent` field is undefined, while `chat.params` declares it as required -
|
|
46
|
+
// and a second counter that cannot be correlated with the first would
|
|
47
|
+
// double-count every call.
|
|
34
48
|
//
|
|
35
49
|
// WHAT A DENIAL LOOKS LIKE HERE
|
|
36
50
|
//
|
|
@@ -69,7 +83,47 @@ export const TelemetryPlugin: Plugin = async ({ worktree, directory }) => {
|
|
|
69
83
|
const opened = new Set<string>()
|
|
70
84
|
const closed = new Set<string>()
|
|
71
85
|
|
|
86
|
+
// The lane a request ran on, and the model it ran on, keyed by the id of
|
|
87
|
+
// the user message the request was made from.
|
|
88
|
+
//
|
|
89
|
+
// The assistant message names that user message as its parent, so this is
|
|
90
|
+
// how a call is attributed to a lane.
|
|
91
|
+
//
|
|
92
|
+
// The map is bounded: a long session makes a lot of requests, and the map
|
|
93
|
+
// is only a bridge between a request and the assistant message that
|
|
94
|
+
// answers it. The oldest entries are dropped once it grows past what a
|
|
95
|
+
// session needs, so the plugin cannot grow without limit.
|
|
96
|
+
const requests = new Map<string, { agent?: string; model?: string; provider?: string }>()
|
|
97
|
+
const REQUEST_MEMORY = 500
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Remember which lane a request was made from.
|
|
101
|
+
*
|
|
102
|
+
* @param {any} input the `chat.params` input
|
|
103
|
+
*/
|
|
104
|
+
const rememberRequest = (input: any) => {
|
|
105
|
+
const id = input?.message?.id
|
|
106
|
+
if (typeof id !== "string") return
|
|
107
|
+
const model = input?.model
|
|
108
|
+
requests.set(id, {
|
|
109
|
+
agent: typeof input?.agent === "string" ? input.agent : undefined,
|
|
110
|
+
model: typeof model?.modelID === "string" ? model.modelID : undefined,
|
|
111
|
+
provider: typeof model?.providerID === "string" ? model.providerID : undefined,
|
|
112
|
+
})
|
|
113
|
+
if (requests.size > REQUEST_MEMORY) {
|
|
114
|
+
// The oldest first: a Map keeps its insertion order.
|
|
115
|
+
const oldest = requests.keys().next()
|
|
116
|
+
if (!oldest.done) requests.delete(oldest.value)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
72
120
|
return {
|
|
121
|
+
"chat.params": async (input: any) => {
|
|
122
|
+
// The lane and the model of the call that is about to be made. This is
|
|
123
|
+
// the only place opencode says which agent is running.
|
|
124
|
+
rememberRequest(input)
|
|
125
|
+
},
|
|
126
|
+
|
|
73
127
|
event: async ({ event }: any) => {
|
|
74
128
|
// Events are provider-specific and opencode may add shapes we have never
|
|
75
129
|
// seen. Anything unrecognised is ignored rather than trusted.
|
|
@@ -91,7 +145,7 @@ export const TelemetryPlugin: Plugin = async ({ worktree, directory }) => {
|
|
|
91
145
|
// on which model, and how long it took. That record is what makes "cut
|
|
92
146
|
// the lanes nobody uses" a measurement instead of a guess.
|
|
93
147
|
if (type === "message.updated") {
|
|
94
|
-
const lane = laneOf(event)
|
|
148
|
+
const lane = laneOf(event, requests)
|
|
95
149
|
if (!lane || !lane.id) return
|
|
96
150
|
if (lane.ms !== undefined) {
|
|
97
151
|
// The completed event. Written once, and only if the open record
|
|
@@ -142,7 +196,10 @@ export const TelemetryPlugin: Plugin = async ({ worktree, directory }) => {
|
|
|
142
196
|
*
|
|
143
197
|
* The message id is what makes the record countable: one message is one call.
|
|
144
198
|
*/
|
|
145
|
-
function laneOf(
|
|
199
|
+
function laneOf(
|
|
200
|
+
event: unknown,
|
|
201
|
+
requests: Map<string, { agent?: string; model?: string; provider?: string }>,
|
|
202
|
+
): {
|
|
146
203
|
id?: string
|
|
147
204
|
agent?: string
|
|
148
205
|
model?: string
|
|
@@ -159,9 +216,12 @@ function laneOf(event: unknown): {
|
|
|
159
216
|
if (info.role && info.role !== "assistant") return null
|
|
160
217
|
|
|
161
218
|
const id = typeof info.id === "string" ? info.id : undefined
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
219
|
+
// The request this message answers. The message does not name the agent;
|
|
220
|
+
// the request does, and the parent id is the join.
|
|
221
|
+
const parent = typeof info.parentID === "string" ? requests.get(info.parentID) : undefined
|
|
222
|
+
const agent = parent?.agent ?? (typeof info.agent === "string" ? info.agent : undefined)
|
|
223
|
+
const modelId = parent?.model ?? info.model?.modelID ?? info.modelID
|
|
224
|
+
const providerId = parent?.provider ?? info.model?.providerID ?? info.providerID
|
|
165
225
|
const time = info.time ?? {}
|
|
166
226
|
const created = typeof time.created === "number" ? time.created : undefined
|
|
167
227
|
const completed = typeof time.completed === "number" ? time.completed : undefined
|