@threadcrumbs/common 0.1.3 → 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.
@@ -10,6 +10,7 @@ import {
10
10
  clipBlock,
11
11
  diffHunk,
12
12
  emptyActivity,
13
+ emptySample,
13
14
  endCall,
14
15
  fileList,
15
16
  firstChange,
@@ -20,6 +21,7 @@ import {
20
21
  openTool,
21
22
  patchLines,
22
23
  planItems,
24
+ samplePoint,
23
25
  settleCalls,
24
26
  startCall,
25
27
  textList,
@@ -176,6 +178,7 @@ function emptyState(sidechain, detail, words, sizes) {
176
178
  cost: null,
177
179
  plan: null,
178
180
  prompts: 0,
181
+ contextSample: emptySample(),
179
182
  uuids: new Set(),
180
183
  }
181
184
  }
@@ -440,6 +443,7 @@ function applyAssistant(state, record, at) {
440
443
  for (const key of Object.keys(next)) state.tokens[key] += next[key] - (previous?.[key] || 0)
441
444
  state.usageById.set(message.id, next)
442
445
  state.context = next.input + next.cacheRead + next.cacheWrite + next.output
446
+ samplePoint(state.contextSample, state.context, { update: Boolean(previous) })
443
447
  const cost = requestCost(message.model, usage)
444
448
  let added = null
445
449
  if (cost != null) {
@@ -10,6 +10,7 @@ import {
10
10
  clip,
11
11
  clipBlock,
12
12
  emptyActivity,
13
+ emptySample,
13
14
  endCall,
14
15
  fileList,
15
16
  joinedText,
@@ -19,6 +20,8 @@ import {
19
20
  openTool,
20
21
  planItems,
21
22
  planSummary,
23
+ sampleList,
24
+ samplePoint,
22
25
  settleCalls,
23
26
  startCall,
24
27
  text,
@@ -458,6 +461,7 @@ function session(context, row, turn, extra) {
458
461
  tool: turn.open && turn.tool ? toolNow(turn.tool) : null,
459
462
  tokens: turn.tokens ? { ...turn.tokens } : null,
460
463
  context: turn.context ? { used: turn.context, limit: context.byModel.get(`${turn.provider}/${turn.model}`) || 0 } : null,
464
+ contextHistory: sampleList(turn.contextSample, turn.context),
461
465
  costUsd: turn.cost,
462
466
  lines: extra.lines,
463
467
  plan: planSummary(extra.plan),
@@ -504,7 +508,7 @@ function legacySessions(db, tables, context) {
504
508
  }
505
509
 
506
510
  function legacyTurn(db, row, context) {
507
- const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, lines: null, tool: null, turnStartedAt: 0 }
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 }
508
512
  const typed = new Set(
509
513
  db
510
514
  .prepare(`SELECT message_id FROM part WHERE session_id = ? AND data LIKE '%"type":"text"%' AND data NOT LIKE '%"synthetic":true%'`)
@@ -537,7 +541,10 @@ function legacyTurn(db, row, context) {
537
541
  if (data.providerID) turn.provider = text(data.providerID)
538
542
  if (data.variant) turn.effort = text(data.variant)
539
543
  if (data.cost != null) turn.cost = (turn.cost || 0) + number(data.cost)
540
- 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
+ }
541
548
  }
542
549
  const usage = usageFromColumns(row)
543
550
  if (usage) {
@@ -605,8 +612,28 @@ function currentSessions(db, tables, context) {
605
612
  return out
606
613
  }
607
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
+
608
634
  function currentTurn(db, row, parts, context) {
609
- const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, tool: null, plan: null, turnStartedAt: 0 }
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) }
610
637
  turn.prompts = number(db.prepare(`SELECT COUNT(*) AS n FROM session_message WHERE session_id = ? AND type = 'user'`).get(row.id)?.n)
611
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)
612
639
  turn.turnStartedAt = number(row.time_busy) || number(lastUser?.time_created)
@@ -316,3 +316,37 @@ export function nextPlan(plan, tool, input) {
316
316
  }
317
317
  return next
318
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadcrumbs/common",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Code the Threadcrumbs apps share, starting with the readers for each coding-agent harness.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -59,6 +59,7 @@ adapter in `../harnesses/`, so a session has the same id in both apps. Add the h
59
59
  | `tool` | `{ name, startedAt, target, kind }` or `null` | The tool running right now, only while working or delegating. `target` is its short file path, command, or search, as in `Detail.activity`, or `''` when the harness doesn't record one. `kind` is one of the [tool kinds](#tool-kinds) |
60
60
  | `tokens` | `{ input, output, cacheRead, cacheWrite }` or `null` | Totals for the session's own thread. `input` leaves out cache reads and writes |
61
61
  | `context` | `{ used, limit }` or `null` | Tokens in the window after the last reply; `limit` is `0` when unknown |
62
+ | `contextHistory` | number[] or `null` | The context after each reply, oldest first, for a sparkline: at most 24 whole numbers, evenly spaced across the session and always ending at `context.used`. A reply written twice counts once. Each reader keeps a running sample as it reads, never the whole history; OpenCode 2.x reads only the replies newer than the last one it saw, once the session changes, leaves the newest for the next read while it has no `time.completed`, and starts over if replies were removed. `null` for Cursor, Antigravity, and Hermes, whose session scan sees no per-reply counts, and before the first reply |
62
63
  | `costUsd` | number or `null` | Spend as the harness records it; `null` when it doesn't |
63
64
  | `lines` | `{ added, removed }` or `null` | Lines changed, as the harness records it |
64
65
  | `plan` | `{ total, done, current }` or `null` | Plan progress, if the agent keeps one |
@@ -233,6 +233,7 @@ export function antigravity({
233
233
  tool: null,
234
234
  tokens: null,
235
235
  context: null,
236
+ contextHistory: null,
236
237
  costUsd: null,
237
238
  lines: null,
238
239
  plan: planSummary(plan),
@@ -6,7 +6,7 @@ import { awaitedPeers, readLiveSessions, transcriptDetail, transcriptReader, tur
6
6
  import { exists, listDirs, listFiles, num } from '../lib/fsutil.mjs'
7
7
  import { contextBreakdown } from '../lib/context-breakdown.mjs'
8
8
  import { recentReaders } from '../lib/jsonl-follower.mjs'
9
- import { DETAIL_LIMITS, RECENT_MS, askOf, number, planSummary, text, titleFromPrompt, toolNow } from '../lib/telemetry-fields.mjs'
9
+ import { DETAIL_LIMITS, RECENT_MS, askOf, number, planSummary, sampleList, text, titleFromPrompt, toolNow } from '../lib/telemetry-fields.mjs'
10
10
 
11
11
  export { nextPlan } from '../lib/telemetry-fields.mjs'
12
12
 
@@ -388,6 +388,7 @@ function toSession({ id, state, stat, running, subagents, workingSubagents, awai
388
388
  tool: status === 'working' || status === 'delegating' ? currentTool(state.open) : null,
389
389
  tokens: { ...state.tokens },
390
390
  context: state.context ? { used: state.context, limit } : null,
391
+ contextHistory: sampleList(state.contextSample, state.context),
391
392
  costUsd: state.unpriced || totals.unpriced ? (state.cost ? state.cost.usd : null) : state.spend + totals.spend,
392
393
  lines: { added: state.lines.added + totals.added, removed: state.lines.removed + totals.removed },
393
394
  plan: planSummary(state.plan),
@@ -16,6 +16,7 @@ import {
16
16
  clipBlock,
17
17
  diffHunk,
18
18
  emptyActivity,
19
+ emptySample,
19
20
  endCall,
20
21
  fileList,
21
22
  inputKind,
@@ -27,6 +28,8 @@ import {
27
28
  planItems,
28
29
  planSummary,
29
30
  relativePath,
31
+ sampleList,
32
+ samplePoint,
30
33
  settleCalls,
31
34
  startCall,
32
35
  text,
@@ -147,6 +150,7 @@ function emptyRollout() {
147
150
  lines: { added: 0, removed: 0 },
148
151
  tokens: null,
149
152
  context: null,
153
+ contextSample: emptySample(),
150
154
  plan: null,
151
155
  history: [],
152
156
  contextParts: null,
@@ -451,6 +455,7 @@ function applyTokens(state, info, at) {
451
455
  const sum = number(total?.total_tokens)
452
456
  if (sum === state.lastTotal) return
453
457
  state.lastTotal = sum
458
+ samplePoint(state.contextSample, last.total_tokens)
454
459
  keepNewest(state.history, { at, context: number(last.total_tokens), output: number(last.output_tokens) }, DETAIL_LIMITS.history)
455
460
  }
456
461
 
@@ -668,6 +673,7 @@ function toSession({ id, state, names, live, turnLive, fresh, locking, unread, s
668
673
  tool,
669
674
  tokens: state.tokens ? { ...state.tokens } : null,
670
675
  context: state.context && state.context.used ? { ...state.context } : null,
676
+ contextHistory: sampleList(state.contextSample, state.context?.used),
671
677
  costUsd: null,
672
678
  lines: { ...state.lines },
673
679
  plan: planSummary(state.plan),
@@ -273,6 +273,7 @@ export function cursor({
273
273
  tool: open && state.tool ? { ...toolNow(state.tool), target: clip(relativePath(state.tool.target, projectPath)) } : null,
274
274
  tokens: null,
275
275
  context: null,
276
+ contextHistory: null,
276
277
  costUsd: null,
277
278
  lines: null,
278
279
  plan: planSummary(state.plan),
@@ -423,6 +423,7 @@ function toSession({ db, profile, tip, root, chain, ids, open, errands, workingS
423
423
  ? { input: tokens[0] || 0, output: tokens[1] || 0, cacheRead: tokens[2] || 0, cacheWrite: tokens[3] || 0 }
424
424
  : null,
425
425
  context: used ? { used, limit: contextLimit(lengths, text(tip.model), tip.billing_base_url) } : null,
426
+ contextHistory: null,
426
427
  costUsd: costs.some((c) => c != null) ? costs.reduce((total, c) => total + number(c), 0) : null,
427
428
  lines: null,
428
429
  plan: planSummary(latestPlan(db, ids, messages)),