@threadcrumbs/common 0.1.2 → 0.1.4

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.
@@ -6,21 +6,30 @@ import {
6
6
  RECENT_MS,
7
7
  activityDetail,
8
8
  addFileChange,
9
+ askOf,
9
10
  clip,
10
11
  clipBlock,
11
12
  emptyActivity,
13
+ emptySample,
12
14
  endCall,
13
15
  fileList,
16
+ joinedText,
14
17
  keepNewest,
18
+ keepText,
15
19
  number,
20
+ openTool,
16
21
  planItems,
17
22
  planSummary,
23
+ sampleList,
24
+ samplePoint,
18
25
  settleCalls,
19
26
  startCall,
20
27
  text,
28
+ textList,
21
29
  titleFromPrompt,
22
30
  toolFile,
23
31
  toolInput,
32
+ toolNow,
24
33
  toolTarget,
25
34
  } from './telemetry-fields.mjs'
26
35
 
@@ -185,12 +194,12 @@ function childList(rows, turnOf) {
185
194
  const turn = turnOf(r)
186
195
  return {
187
196
  id: r.id,
188
- name: text(r.title).trim() || (turn.firstPrompt ? titleFromPrompt(turn.firstPrompt) : '') || 'Subagent',
197
+ name: childName(r, turn),
189
198
  type: '',
190
199
  status: turn.open ? 'working' : turn.failed ? 'error' : 'done',
191
200
  model: turn.model,
192
201
  tokens: turn.tokens ? { ...turn.tokens } : null,
193
- tool: turn.open && turn.tool ? { ...turn.tool } : null,
202
+ tool: turn.open && turn.tool ? toolNow(turn.tool) : null,
194
203
  startedAt: number(r.time_created),
195
204
  lastActivityAt: number(r.time_updated),
196
205
  }
@@ -215,6 +224,7 @@ function legacyDetail(db, tables, row, context) {
215
224
  .map((r) => r.message_id),
216
225
  )
217
226
  const modelOf = new Map()
227
+ const messageAt = new Map()
218
228
  const messageUsage = []
219
229
  for (const message of db.prepare('SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, rowid').all(row.id)) {
220
230
  const data = parse(message.data)
@@ -232,6 +242,7 @@ function legacyDetail(db, tables, row, context) {
232
242
  if (data.role !== 'assistant') continue
233
243
  const model = text(data.modelID)
234
244
  modelOf.set(message.id, model)
245
+ messageAt.set(message.id, at)
235
246
  messageUsage.push([model, data.tokens, data.cost])
236
247
  if (number(data.tokens?.output) > 0) {
237
248
  const output = number(data.tokens.output) + number(data.tokens.reasoning)
@@ -266,6 +277,17 @@ function legacyDetail(db, tables, row, context) {
266
277
  endCall(activity, callId, number(state.time?.end), failed, text(state.error) || text(state.output))
267
278
  }
268
279
  }
280
+ const thoughts = []
281
+ const replies = []
282
+ const said = db.prepare(`SELECT message_id, data FROM part WHERE session_id = ? AND (data LIKE '%"type":"text"%' OR data LIKE '%"type":"reasoning"%') ORDER BY rowid`)
283
+ for (const part of said.all(row.id)) {
284
+ if (!messageAt.has(part.message_id)) continue
285
+ const data = parse(part.data)
286
+ if (data.synthetic || data.ignored) continue
287
+ const at = number(data.time?.end) || number(data.time?.start) || messageAt.get(part.message_id)
288
+ if (data.type === 'reasoning') keepText(thoughts, 'thoughts', { id: part.message_id, at, text: text(data.text) })
289
+ else if (data.type === 'text') keepText(replies, 'replies', { id: part.message_id, at, text: text(data.text) })
290
+ }
269
291
  const turnOf = (r) => cached(context, `v1:${r.id}`, number(r.time_updated), () => legacyTurn(db, r, context))
270
292
  if (!turnOf(row).open) settleCalls(activity)
271
293
 
@@ -284,6 +306,9 @@ function legacyDetail(db, tables, row, context) {
284
306
  subagents: childList(sessionRows(db, 'session', 'parent_id', row.id), turnOf),
285
307
  files: files.size || !context.liveSummary ? fileList(files, directory) : null,
286
308
  prompts,
309
+ thoughts: textList(thoughts),
310
+ replies: textList(replies),
311
+ contextBreakdown: null,
287
312
  errors,
288
313
  }
289
314
  }
@@ -296,13 +321,15 @@ function currentDetail(db, tables, row, context) {
296
321
  const errors = []
297
322
  const models = new Map()
298
323
  let contextParts = null
299
- for (const message of db.prepare('SELECT type, time_created, data FROM session_message WHERE session_id = ? ORDER BY seq').all(row.id)) {
324
+ const replied = []
325
+ for (const message of db.prepare('SELECT id, type, time_created, data FROM session_message WHERE session_id = ? ORDER BY seq').all(row.id)) {
300
326
  const data = parse(message.data)
301
327
  const at = number(message.time_created)
302
328
  if (message.type === 'user') {
303
329
  const prompt = text(data.text).trim()
304
330
  if (prompt) keepNewest(prompts, { at, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
305
331
  } else if (message.type === 'assistant') {
332
+ replied.push({ id: message.id, at, content: list(data.content) })
306
333
  addModelUsage(models, text(data.model?.id), data.tokens, data.cost)
307
334
  if (number(data.tokens?.output) > 0) {
308
335
  keepNewest(history, { at, context: windowOf(data.tokens), output: number(data.tokens.output) + number(data.tokens.reasoning) }, DETAIL_LIMITS.history)
@@ -314,21 +341,34 @@ function currentDetail(db, tables, row, context) {
314
341
  }
315
342
 
316
343
  const parts = tables.has('session_part') && ['session_id', 'message_id', 'type', 'data'].every((c) => columnsOf(db, 'session_part').has(c))
317
- let plan = []
344
+ const rows = new Map()
318
345
  if (parts) {
319
- for (const part of db.prepare(`SELECT id, data FROM session_part WHERE session_id = ? AND type = 'tool' ORDER BY rowid`).all(row.id)) {
346
+ for (const part of db.prepare(`SELECT id, message_id, type, data FROM session_part WHERE session_id = ? AND type IN ('tool', 'reasoning', 'text') ORDER BY rowid`).all(row.id)) {
320
347
  const data = parse(part.data)
348
+ if (!rows.has(part.message_id)) rows.set(part.message_id, [])
349
+ rows.get(part.message_id).push({ ...data, type: text(data.type) || part.type, rowId: part.id })
350
+ }
351
+ }
352
+ let plan = []
353
+ const thoughts = []
354
+ const replies = []
355
+ for (const message of replied) {
356
+ const content = rows.get(message.id) || message.content
357
+ content.forEach((data, i) => {
358
+ if (data?.type !== 'tool') return
321
359
  const state = data.state || {}
322
- const callId = text(data.callID) || text(data.id) || part.id
360
+ const callId = text(data.callID) || text(data.id) || data.rowId || `${message.id}:${i}`
323
361
  const call = { id: callId, name: text(data.name), target: toolTarget(state.input, directory), input: toolInput(state.input), ...toolFile(state.input, directory) }
324
- if (!startCall(activity, { ...call, startedAt: number(data.time?.ran) || number(data.time?.created) })) continue
362
+ if (!startCall(activity, { ...call, startedAt: number(data.time?.ran) || number(data.time?.created) || message.at })) return
325
363
  if (state.status === 'completed' || state.status === 'error') {
326
364
  endCall(activity, callId, number(state.time?.end) || number(data.time?.completed), state.status === 'error', text(state.error) || text(state.output))
327
365
  }
328
366
  if (data.name === TODO_TOOL && state.status === 'completed' && Array.isArray(state.input?.todos)) {
329
367
  plan = state.input.todos.map((t) => ({ content: text(t?.content), status: text(t?.status) }))
330
368
  }
331
- }
369
+ })
370
+ keepText(thoughts, 'thoughts', { at: message.at, text: joinedText(content, 'reasoning', 'text') })
371
+ keepText(replies, 'replies', { at: message.at, text: joinedText(content, 'text') })
332
372
  }
333
373
  const turnOf = (r) => cached(context, `v2:${r.id}`, number(r.time_updated), () => currentTurn(db, r, parts, context))
334
374
  if (!turnOf(row).open) settleCalls(activity)
@@ -343,6 +383,9 @@ function currentDetail(db, tables, row, context) {
343
383
  subagents: childList(sessionRows(db, 'session_v2', 'parent_id', row.id), turnOf),
344
384
  files: null,
345
385
  prompts,
386
+ thoughts: textList(thoughts),
387
+ replies: textList(replies),
388
+ contextBreakdown: null,
346
389
  errors,
347
390
  }
348
391
  }
@@ -373,14 +416,23 @@ function cached(context, key, updated, compute) {
373
416
 
374
417
  function statusOf({ open, failed, errands, tool, lastActivityAt, read, at }) {
375
418
  if (open) {
376
- if (tool?.name === QUESTION_TOOL) return 'asking'
377
- if (errands && (!tool || SUBAGENT_TOOLS.has(tool.name))) return 'delegating'
378
- return 'working'
379
- }
380
- if (errands) return 'delegating'
381
- if (failed) return 'error'
382
- if (read) return read.unread ? 'done' : 'idle'
383
- return at - lastActivityAt < ACTIVE_WINDOW_MS ? 'done' : 'idle'
419
+ if (tool?.name === QUESTION_TOOL) return ['asking', 'question']
420
+ if (errands && (!tool || SUBAGENT_TOOLS.has(tool.name))) return ['delegating', 'subagents']
421
+ return ['working', 'turn-open']
422
+ }
423
+ if (errands) return ['delegating', 'subagents']
424
+ if (failed) return ['error', 'turn-failed']
425
+ if (read) return read.unread ? ['done', 'unread'] : ['idle', 'read']
426
+ return at - lastActivityAt < ACTIVE_WINDOW_MS ? ['done', 'recent'] : ['idle', 'quiet']
427
+ }
428
+
429
+ const childName = (row, turn) => text(row.title).trim() || (turn.firstPrompt ? titleFromPrompt(turn.firstPrompt) : '') || 'Subagent'
430
+
431
+ function workingChildren(rows, parent, turnOf, context) {
432
+ return rows
433
+ .filter((r) => r.parent_id === parent.id && context.at - number(r.time_updated) < SUBAGENT_WINDOW_MS && turnOf(r).open)
434
+ .sort((a, b) => number(a.time_created) - number(b.time_created))
435
+ .map((r) => ({ id: r.id, name: childName(r, turnOf(r)) }))
384
436
  }
385
437
 
386
438
  function session(context, row, turn, extra) {
@@ -389,7 +441,8 @@ function session(context, row, turn, extra) {
389
441
  const lastActivityAt = number(row.time_updated)
390
442
  const viewed = number(row.time_viewed)
391
443
  const read = viewed > 0 ? { unread: number(row.time_idle) > viewed } : null
392
- const status = statusOf({ ...turn, errands: extra.errands, lastActivityAt, read, at: context.at })
444
+ const errands = extra.working.length
445
+ const [status, statusReason] = statusOf({ ...turn, errands, lastActivityAt, read, at: context.at })
393
446
  return {
394
447
  id: `${context.id}:${row.id}`,
395
448
  harness: context.id,
@@ -405,14 +458,20 @@ function session(context, row, turn, extra) {
405
458
  live: turn.open,
406
459
  startedAt: number(row.time_created),
407
460
  lastActivityAt,
408
- tool: turn.open && turn.tool ? { ...turn.tool } : null,
461
+ tool: turn.open && turn.tool ? toolNow(turn.tool) : null,
409
462
  tokens: turn.tokens ? { ...turn.tokens } : null,
410
463
  context: turn.context ? { used: turn.context, limit: context.byModel.get(`${turn.provider}/${turn.model}`) || 0 } : null,
464
+ contextHistory: sampleList(turn.contextSample, turn.context),
411
465
  costUsd: turn.cost,
412
466
  lines: extra.lines,
413
467
  plan: planSummary(extra.plan),
414
- subagents: extra.errands,
468
+ subagents: errands,
469
+ workingSubagents: extra.working,
415
470
  prompts: turn.prompts,
471
+ turnStartedAt: turn.open ? turn.turnStartedAt || null : null,
472
+ statusReason,
473
+ ask: askOf(status, { calls: turn.open && turn.tool ? [turn.tool] : [], errorText: turn.errorText }),
474
+ usageLimits: null,
416
475
  }
417
476
  }
418
477
 
@@ -431,7 +490,7 @@ function legacySessions(db, tables, context) {
431
490
  for (const row of rows) {
432
491
  if (row.parent_id || row.time_archived != null) continue
433
492
  const turn = turnOf(row)
434
- const errands = rows.filter((r) => r.parent_id === row.id && context.at - number(r.time_updated) < SUBAGENT_WINDOW_MS && turnOf(r).open).length
493
+ const working = workingChildren(rows, row, turnOf, context)
435
494
  const lines = context.liveSummary
436
495
  ? row.summary_additions == null && row.summary_deletions == null
437
496
  ? null
@@ -443,13 +502,13 @@ function legacySessions(db, tables, context) {
443
502
  .all(row.id)
444
503
  .map((t) => ({ content: text(t.content), status: text(t.status) }))
445
504
  : null
446
- out.push(session(context, row, turn, { errands, lines, plan }))
505
+ out.push(session(context, row, turn, { working, lines, plan }))
447
506
  }
448
507
  return out
449
508
  }
450
509
 
451
510
  function legacyTurn(db, row, context) {
452
- const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, lines: null, tool: null }
511
+ const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, contextSample: emptySample(), lines: null, tool: null, turnStartedAt: 0 }
453
512
  const typed = new Set(
454
513
  db
455
514
  .prepare(`SELECT message_id FROM part WHERE session_id = ? AND data LIKE '%"type":"text"%' AND data NOT LIKE '%"synthetic":true%'`)
@@ -458,10 +517,11 @@ function legacyTurn(db, row, context) {
458
517
  )
459
518
  let last = null
460
519
  let lastAssistant = ''
461
- for (const message of db.prepare('SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created, rowid').all(row.id)) {
520
+ for (const message of db.prepare('SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, rowid').all(row.id)) {
462
521
  const data = parse(message.data)
463
522
  last = data
464
523
  if (data.role === 'user') {
524
+ turn.turnStartedAt = number(data.time?.created) || number(message.time_created)
465
525
  if (typed.has(message.id)) {
466
526
  turn.prompts++
467
527
  if (!turn.firstPrompt) turn.firstPrompt = firstText(db, message.id)
@@ -481,7 +541,10 @@ function legacyTurn(db, row, context) {
481
541
  if (data.providerID) turn.provider = text(data.providerID)
482
542
  if (data.variant) turn.effort = text(data.variant)
483
543
  if (data.cost != null) turn.cost = (turn.cost || 0) + number(data.cost)
484
- if (number(data.tokens?.output) > 0) turn.context = windowOf(data.tokens)
544
+ if (number(data.tokens?.output) > 0) {
545
+ turn.context = windowOf(data.tokens)
546
+ samplePoint(turn.contextSample, turn.context)
547
+ }
485
548
  }
486
549
  const usage = usageFromColumns(row)
487
550
  if (usage) {
@@ -496,8 +559,10 @@ function legacyTurn(db, row, context) {
496
559
  const finish = text(last?.finish)
497
560
  const ended = Boolean(last?.time?.completed) || Boolean(finish)
498
561
  turn.busy = last?.role === 'user' || (last?.role === 'assistant' && !error && (!ended || context.continuing.has(finish)))
499
- turn.failed = last?.role === 'assistant' && (error ? !ABORTED.test(error) : ended && toolFailed(db, lastAssistant))
500
- if (turn.busy) turn.tool = runningTool(db, lastAssistant)
562
+ const failedTool = last?.role === 'assistant' && !error && ended ? toolFailure(db, lastAssistant) : ''
563
+ turn.failed = last?.role === 'assistant' && (error ? !ABORTED.test(error) : Boolean(failedTool))
564
+ turn.errorText = turn.failed ? text(last?.error?.data?.message) || error || failedTool : ''
565
+ if (turn.busy) turn.tool = runningTool(db, lastAssistant, text(row.directory))
501
566
  return turn
502
567
  }
503
568
 
@@ -509,21 +574,21 @@ function firstText(db, messageId) {
509
574
  return ''
510
575
  }
511
576
 
512
- function toolFailed(db, messageId) {
577
+ function toolFailure(db, messageId) {
513
578
  for (const part of db.prepare(`SELECT data FROM part WHERE message_id = ? AND data LIKE '%"status":"error"%'`).all(messageId)) {
514
579
  const state = parse(part.data).state || {}
515
- if (!STOPPED.test(`${text(state.error)}\n${text(state.output)}`)) return true
580
+ if (!STOPPED.test(`${text(state.error)}\n${text(state.output)}`)) return text(state.error) || text(state.output) || 'A tool failed'
516
581
  }
517
- return false
582
+ return ''
518
583
  }
519
584
 
520
- function runningTool(db, messageId) {
585
+ function runningTool(db, messageId, cwd) {
521
586
  if (!messageId) return null
522
587
  let tool = null
523
588
  for (const part of db.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY rowid').all(messageId)) {
524
589
  const data = parse(part.data)
525
590
  if (data.type !== 'tool' || !OPEN_TOOL.has(data.state?.status)) continue
526
- tool = { name: text(data.tool), startedAt: number(data.state?.time?.start) }
591
+ tool = openTool(text(data.tool), number(data.state?.time?.start), data.state?.input, cwd)
527
592
  }
528
593
  return tool
529
594
  }
@@ -542,15 +607,36 @@ function currentSessions(db, tables, context) {
542
607
  for (const row of rows) {
543
608
  if (row.parent_id || row.time_archived != null) continue
544
609
  const turn = turnOf(row)
545
- const errands = rows.filter((r) => r.parent_id === row.id && context.at - number(r.time_updated) < SUBAGENT_WINDOW_MS && turnOf(r).open).length
546
- out.push(session(context, row, turn, { errands, lines: null, plan: turn.plan }))
610
+ out.push(session(context, row, turn, { working: workingChildren(rows, row, turnOf, context), lines: null, plan: turn.plan }))
547
611
  }
548
612
  return out
549
613
  }
550
614
 
615
+ function currentSample(db, sessionId, before) {
616
+ let { contextSample: sample, contextSeq: seq = -1, contextCount: count = 0 } = before || {}
617
+ const kept = sample && db.prepare(`SELECT COUNT(*) AS n, MAX(seq) AS top FROM session_message WHERE session_id = ? AND type = 'assistant' AND seq <= ?`).get(sessionId, seq)
618
+ if (!kept || number(kept.n) !== count || (count && number(kept.top) !== seq)) {
619
+ sample = emptySample()
620
+ seq = -1
621
+ count = 0
622
+ }
623
+ const newer = db.prepare(`SELECT seq, data FROM session_message WHERE session_id = ? AND type = 'assistant' AND seq > ? ORDER BY seq`).all(sessionId, seq)
624
+ for (const [i, message] of newer.entries()) {
625
+ const data = parse(message.data)
626
+ if (i === newer.length - 1 && !data.time?.completed) break
627
+ if (number(data.tokens?.output) > 0) samplePoint(sample, windowOf(data.tokens))
628
+ seq = number(message.seq)
629
+ count++
630
+ }
631
+ return { contextSample: sample, contextSeq: seq, contextCount: count }
632
+ }
633
+
551
634
  function currentTurn(db, row, parts, context) {
552
- const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, tool: null, plan: null }
635
+ const before = context.facts.get(`v2:${row.id}`)?.value
636
+ const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, tool: null, plan: null, turnStartedAt: 0, ...currentSample(db, row.id, before) }
553
637
  turn.prompts = number(db.prepare(`SELECT COUNT(*) AS n FROM session_message WHERE session_id = ? AND type = 'user'`).get(row.id)?.n)
638
+ const lastUser = db.prepare(`SELECT time_created FROM session_message WHERE session_id = ? AND type = 'user' ORDER BY seq DESC LIMIT 1`).get(row.id)
639
+ turn.turnStartedAt = number(row.time_busy) || number(lastUser?.time_created)
554
640
  turn.firstPrompt = text(parse(db.prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'user' ORDER BY seq ASC LIMIT 1`).get(row.id)?.data).text).trim()
555
641
  const assistant = db.prepare(`SELECT id, data FROM session_message WHERE session_id = ? AND type = 'assistant' ORDER BY seq DESC LIMIT 1`).get(row.id)
556
642
  const latest = parse(assistant?.data)
@@ -573,13 +659,14 @@ function currentTurn(db, row, parts, context) {
573
659
  turn.busy = busy
574
660
  const outcome = text(row.idle_outcome) || (last?.type === 'idle' ? text(parse(last.data).outcome) : '')
575
661
  turn.failed = !busy && outcome === 'failed'
576
- const tools = currentTools(db, row.id, assistant, parts)
662
+ turn.errorText = turn.failed && last?.type === 'idle' ? text(parse(last.data).error?.message) : ''
663
+ const tools = currentTools(db, row.id, assistant, parts, text(row.directory))
577
664
  if (busy) turn.tool = tools.running
578
665
  turn.plan = tools.todos
579
666
  return turn
580
667
  }
581
668
 
582
- function currentTools(db, sessionId, assistant, parts) {
669
+ function currentTools(db, sessionId, assistant, parts, cwd) {
583
670
  const result = { running: null, todos: null }
584
671
  let latest = []
585
672
  let recent = []
@@ -593,8 +680,14 @@ function currentTools(db, sessionId, assistant, parts) {
593
680
  .map((p) => parse(p.data))
594
681
  }
595
682
  if (!latest.length && assistant) latest = list(parse(assistant.data).content).filter((c) => c?.type === 'tool')
683
+ if (!recent.length) {
684
+ recent = db
685
+ .prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' AND data LIKE ? ORDER BY seq DESC LIMIT 20`)
686
+ .all(sessionId, `%"${TODO_TOOL}"%`)
687
+ .flatMap((m) => list(parse(m.data).content).filter((c) => c?.type === 'tool').reverse())
688
+ }
596
689
  for (const part of latest) {
597
- if (OPEN_TOOL.has(part.state?.status)) result.running = { name: text(part.name), startedAt: number(part.time?.ran) || number(part.time?.created) }
690
+ if (OPEN_TOOL.has(part.state?.status)) result.running = openTool(text(part.name), number(part.time?.ran) || number(part.time?.created), part.state?.input, cwd)
598
691
  }
599
692
  const todos = recent.find((p) => p.name === TODO_TOOL && p.state?.status === 'completed' && Array.isArray(p.state?.input?.todos))
600
693
  if (todos) result.todos = todos.state.input.todos.map((t) => ({ content: text(t?.content), status: text(t?.status) }))
@@ -1,3 +1,5 @@
1
+ import { nameKind, toolKind } from './tool-kinds.mjs'
2
+
1
3
  export const RECENT_MS = 24 * 60 * 60 * 1000
2
4
 
3
5
  export const DETAIL_LIMITS = Object.freeze({
@@ -5,6 +7,10 @@ export const DETAIL_LIMITS = Object.freeze({
5
7
  activity: 100,
6
8
  prompts: 50,
7
9
  promptText: 2000,
10
+ thoughts: 20,
11
+ thoughtText: 2000,
12
+ replies: 50,
13
+ replyText: 2000,
8
14
  errors: 20,
9
15
  errorText: 500,
10
16
  input: 2000,
@@ -73,6 +79,8 @@ export function toolTarget(input, cwd = '', max = TARGET_MAX) {
73
79
  return ''
74
80
  }
75
81
 
82
+ export const inputKind = (name, input) => toolKind(name, typeof input === 'string' && input.endsWith('…') ? '' : input)
83
+
76
84
  export function toolInput(input) {
77
85
  if (typeof input === 'string') return clipBlock(input, DETAIL_LIMITS.input) || null
78
86
  if (!input || typeof input !== 'object') return null
@@ -92,12 +100,76 @@ export function toolFile(input, cwd = '') {
92
100
  return { path: null, line: null }
93
101
  }
94
102
 
103
+ const ASK_QUESTIONS = 4
104
+ const ASK_OPTIONS = 8
105
+ const askText = (value) => clipBlock(text(value), DETAIL_LIMITS.errorText)
106
+
107
+ export function askQuestions(input) {
108
+ if (!Array.isArray(input?.questions) || !input.questions.length) return null
109
+ return input.questions.slice(0, ASK_QUESTIONS).map((q) => ({
110
+ header: clip(q?.header),
111
+ text: askText(q?.question ?? q?.text),
112
+ options: (Array.isArray(q?.options) ? q.options : []).slice(0, ASK_OPTIONS).map((o) => ({
113
+ label: clip(typeof o === 'string' ? o : o?.label),
114
+ description: askText(o?.description),
115
+ })),
116
+ multiple: q?.multiSelect === true || q?.multiple === true,
117
+ }))
118
+ }
119
+
120
+ export function openTool(name, startedAt, input = null, cwd = '') {
121
+ const command = typeof input === 'string' ? input : commandText(input?.command ?? input?.cmd)
122
+ const tool = { name, startedAt, target: toolTarget(input, cwd), kind: toolKind(name, command || toolInput(input)).kind }
123
+ const questions = tool.kind === 'communicate' ? askQuestions(input) : null
124
+ if (questions) tool.questions = questions
125
+ return tool
126
+ }
127
+
128
+ export function askOf(status, { calls = [], errorText = '' } = {}) {
129
+ if (status === 'error') return { type: 'error', text: askText(errorText) }
130
+ if (status !== 'asking') return null
131
+ const asked = calls.findLast((c) => c?.questions)
132
+ if (asked) return { type: 'question', questions: asked.questions.map((q) => ({ ...q, options: q.options.map((o) => ({ ...o })) })) }
133
+ const call = calls.findLast((c) => (c.kind ?? nameKind(c.name)) !== 'agent')
134
+ if (call) return { type: 'approval', tool: call.name, target: call.target ?? '', kind: call.kind ?? nameKind(call.name) }
135
+ return { type: 'unknown' }
136
+ }
137
+
138
+ export const toolNow = (tool) => (tool ? { name: tool.name, startedAt: tool.startedAt, target: tool.target ?? '', kind: tool.kind ?? nameKind(tool.name) } : null)
139
+
95
140
  export function keepNewest(list, item, max) {
96
141
  list.push(item)
97
142
  if (list.length > max) list.splice(0, list.length - max)
98
143
  return item
99
144
  }
100
145
 
146
+ const TEXT_LISTS = {
147
+ thoughts: [DETAIL_LIMITS.thoughts, DETAIL_LIMITS.thoughtText],
148
+ replies: [DETAIL_LIMITS.replies, DETAIL_LIMITS.replyText],
149
+ }
150
+
151
+ export function keepText(list, kind, { id = null, at, text: value }) {
152
+ const [max, maxText] = TEXT_LISTS[kind]
153
+ const body = clipBlock(value, maxText)
154
+ if (!body) return null
155
+ const last = list.at(-1)
156
+ if (id != null && last?.id === id) {
157
+ if (last.text.length < maxText) last.text = clipBlock(`${last.text}\n\n${body}`, maxText)
158
+ return last
159
+ }
160
+ return keepNewest(list, { id, at, text: body }, max)
161
+ }
162
+
163
+ export const textList = (list) => (list ? list.map(({ at, text: value }) => ({ at, text: value })) : null)
164
+
165
+ export function joinedText(blocks, type, field = type) {
166
+ if (!Array.isArray(blocks)) return ''
167
+ return blocks
168
+ .filter((block) => block?.type === type && typeof block[field] === 'string' && block[field].trim())
169
+ .map((block) => block[field].trim())
170
+ .join('\n\n')
171
+ }
172
+
101
173
  export function patchLines(lines) {
102
174
  let added = 0
103
175
  let removed = 0
@@ -151,12 +223,13 @@ export const emptyActivity = () => ({ calls: [], tools: new Map() })
151
223
 
152
224
  export function startCall(activity, { id = '', name, target = '', input = null, path = null, line = null, startedAt, running = true }) {
153
225
  if (id && activity.calls.some((c) => c.id === id)) return null
226
+ const { kind, server } = inputKind(name, input)
154
227
  const call = keepNewest(
155
228
  activity.calls,
156
- { id, name, target, input, path, line, startedAt, endedAt: null, running, error: null, errorText: null },
229
+ { id, name, kind, server, target, input, path, line, startedAt, endedAt: null, running, error: null, errorText: null },
157
230
  DETAIL_LIMITS.activity,
158
231
  )
159
- const tool = activity.tools.get(name) || { name, calls: 0, errors: 0, ms: 0 }
232
+ const tool = activity.tools.get(name) || { name, kind: nameKind(name), server, calls: 0, errors: 0, ms: 0 }
160
233
  tool.calls++
161
234
  activity.tools.set(name, tool)
162
235
  return call
@@ -243,3 +316,37 @@ export function nextPlan(plan, tool, input) {
243
316
  }
244
317
  return next
245
318
  }
319
+
320
+ export const CONTEXT_POINTS = 24
321
+
322
+ export const emptySample = () => ({ stride: 1, seen: 0, points: [], newest: null, newestKept: false })
323
+
324
+ export function samplePoint(sample, value, { update = false } = {}) {
325
+ const point = Math.round(number(value))
326
+ if (point <= 0) return
327
+ if (update && sample.newest != null) {
328
+ if (sample.newestKept) sample.points[sample.points.length - 1] = point
329
+ sample.newest = point
330
+ return
331
+ }
332
+ sample.newestKept = sample.seen % sample.stride === 0
333
+ if (sample.newestKept) {
334
+ sample.points.push(point)
335
+ if (sample.points.length >= CONTEXT_POINTS * 2) {
336
+ sample.points = sample.points.filter((_, i) => i % 2 === 0)
337
+ sample.stride *= 2
338
+ sample.newestKept = false
339
+ }
340
+ }
341
+ sample.seen++
342
+ sample.newest = point
343
+ }
344
+
345
+ export function sampleList(sample, current) {
346
+ if (!sample || !(current > 0)) return null
347
+ const now = Math.round(current)
348
+ const older = sample.newestKept && sample.newest === now ? sample.points.slice(0, -1) : sample.points
349
+ const room = CONTEXT_POINTS - 1
350
+ const picked = older.length <= room ? older : Array.from({ length: room }, (_, i) => older[Math.round((i * (older.length - 1)) / (room - 1))])
351
+ return [...picked, now]
352
+ }