rvlib-shipkit 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/cli/cmd_Commands.ts +5 -0
  12. package/src/cli/cmd_Review.ts +948 -0
  13. package/src/cli/cmd_Time.ts +112 -0
  14. package/src/cli/cmd_Trailer.ts +259 -6
  15. package/src/cli/kit/verb.ts +14 -4
  16. package/src/cli.ts +6 -4
  17. package/src/collections/collection-types.ts +6 -1
  18. package/src/collections/providers.ts +3 -0
  19. package/src/collections/review.ts +70 -0
  20. package/src/core/layout.ts +2 -0
  21. package/src/reflect/prompt-corpus.ts +15 -119
  22. package/src/reflect/transcript.ts +254 -0
  23. package/src/review/anim.ts +228 -0
  24. package/src/review/auto.ts +141 -0
  25. package/src/review/format.ts +150 -0
  26. package/src/review/http.ts +23 -0
  27. package/src/review/marks.ts +43 -0
  28. package/src/review/media.ts +45 -0
  29. package/src/review/model.ts +854 -0
  30. package/src/review/project.ts +110 -0
  31. package/src/review/role.ts +48 -0
  32. package/src/review/routes.ts +228 -0
  33. package/src/review/store.ts +1002 -0
  34. package/src/review/threads.ts +237 -0
  35. package/src/review/trailer-desk.ts +385 -0
  36. package/src/review/trailer-model.ts +183 -0
  37. package/src/review/trailer-routes.ts +136 -0
  38. package/src/review/unit.ts +165 -0
  39. package/src/review/watchers.ts +54 -0
  40. package/src/serve/embed.ts +35 -0
  41. package/src/serve/hub-app/activity-view.tsx +1 -1
  42. package/src/serve/hub-app/activity.css +0 -4
  43. package/src/serve/hub-app/dock-view.tsx +5 -4
  44. package/src/serve/hub-app/hash-route.ts +28 -1
  45. package/src/serve/hub-app/header-bar.tsx +3 -1
  46. package/src/serve/hub-app/home-st.ts +10 -2
  47. package/src/serve/hub-app/home-strip.tsx +3 -2
  48. package/src/serve/hub-app/home.html +1 -0
  49. package/src/serve/hub-app/hub-app.tsx +80 -11
  50. package/src/serve/hub-app/hub.css +45 -13
  51. package/src/serve/hub-app/icons.tsx +9 -0
  52. package/src/serve/hub-app/index.html +1 -0
  53. package/src/serve/hub-app/insights-view.tsx +4 -3
  54. package/src/serve/hub-app/keys.ts +14 -10
  55. package/src/serve/hub-app/layout-st.ts +63 -2
  56. package/src/serve/hub-app/planned-view.tsx +3 -2
  57. package/src/serve/hub-app/planned.css +0 -1
  58. package/src/serve/hub-app/project-list.css +22 -4
  59. package/src/serve/hub-app/project-list.tsx +41 -8
  60. package/src/serve/hub-app/projects-svc.ts +4 -0
  61. package/src/serve/hub-app/repo-sections.ts +15 -3
  62. package/src/serve/hub-app/repo-st.ts +45 -3
  63. package/src/serve/hub-app/review/clip-st.ts +208 -0
  64. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  65. package/src/serve/hub-app/review/kit.tsx +113 -0
  66. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  67. package/src/serve/hub-app/review/review-board.tsx +46 -0
  68. package/src/serve/hub-app/review/review-list.tsx +156 -0
  69. package/src/serve/hub-app/review/review-page.tsx +101 -0
  70. package/src/serve/hub-app/review/review-st.ts +779 -0
  71. package/src/serve/hub-app/review/review-views.tsx +503 -0
  72. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  73. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  74. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  75. package/src/serve/hub-app/review.css +329 -0
  76. package/src/serve/hub-app/theme-st.ts +56 -0
  77. package/src/serve/hub-app/tree-build.ts +4 -0
  78. package/src/serve/hub-app/tree-row.tsx +1 -1
  79. package/src/serve/hub-app/tree-svc.ts +4 -0
  80. package/src/serve/hub-runtime.ts +5 -3
  81. package/src/serve/same-origin.ts +5 -1
  82. package/src/serve/serve-hub.ts +8 -0
  83. package/src/shipkit-files.ts +9 -5
  84. package/src/trailer/adapter.ts +58 -3
  85. package/src/trailer/build.ts +300 -41
  86. package/src/trailer/ffmpeg.ts +22 -0
  87. package/src/trailer/footage.ts +109 -40
  88. package/src/trailer/grammar.ts +444 -69
  89. package/src/trailer/graph.ts +199 -14
  90. package/src/trailer/holes.ts +72 -0
  91. package/src/trailer/load-unit.ts +20 -3
  92. package/src/trailer/look.ts +17 -3
  93. package/src/trailer/passage.ts +129 -0
  94. package/src/trailer/plan.ts +80 -21
  95. package/src/trailer/prune.ts +193 -0
  96. package/src/trailer/score/compose.ts +240 -0
  97. package/src/trailer/score/dsp.ts +272 -0
  98. package/src/trailer/score/palette.ts +188 -0
  99. package/src/trailer/score/pattern.ts +91 -0
  100. package/src/trailer/score/score.ts +214 -0
  101. package/src/trailer/score/sound.ts +112 -0
  102. package/src/trailer/score/synth.ts +361 -0
  103. package/src/trailer/states.ts +76 -0
  104. package/src/trailer/stitch.ts +92 -9
  105. package/src/trailer/titles.ts +123 -18
  106. package/src/trailer/vocab.ts +23 -4
  107. package/src/trailer/words.ts +254 -0
@@ -0,0 +1,379 @@
1
+ // THE AGENT TIME INDEX: every Claude Code transcript on this machine, every
2
+ // config root, read once into one sqlite file, `~/.shipkit/agent-time.sqlite`.
3
+ // A file is read again only when it changed, and a live session only from
4
+ // where the last read stopped, so a warm query reads a few new lines.
5
+ //
6
+ // The index KEEPS what Claude Code prunes: a transcript deleted by retention
7
+ // stays here, so the history grows past the harness's window.
8
+ //
9
+ // The constraint a reader breaks first: the rows hold his commands and prompts.
10
+ // The file is machine local, under $HOME, never in a repo, and every line
11
+ // printed from it goes through mask.ts.
12
+ //
13
+ // Several agents query at once: each file is indexed inside one immediate
14
+ // transaction that reads its row again first, so two indexers never both
15
+ // append the same lines, and a killed build resumes at the next file.
16
+ import { Database } from 'bun:sqlite'
17
+ import { closeSync, existsSync, mkdirSync, openSync, readSync, readdirSync, statSync } from 'fs'
18
+ import { homedir } from 'os'
19
+ import { basename, dirname, join } from 'path'
20
+ import { ensureWal } from '../ledger/db.ts'
21
+ import { type ConfigRoot, discoverConfigRoots } from '../reflect/prompt-corpus.ts'
22
+ import { type TranscriptEvent, readTranscriptLine } from '../reflect/transcript.ts'
23
+ import { SHAPE_VERSION, toolShape } from './shape.ts'
24
+ import { READ_VERSION, isReadOnly } from './turns.ts'
25
+ import type { Call, Mark } from './timeline.ts'
26
+
27
+ export const AGENT_TIME_DB_ENV = 'SHIPKIT_AGENT_TIME_DB'
28
+
29
+ /** bumped when what a row holds changes: every file is read again */
30
+ export const INDEX_VERSION = 6
31
+
32
+ export function agentTimeDbPath(): string {
33
+ const env = process.env[AGENT_TIME_DB_ENV]
34
+ if (env != null && env !== '') return env
35
+ return join(homedir(), '.shipkit', 'agent-time.sqlite')
36
+ }
37
+
38
+ const SCHEMA = `
39
+ create table if not exists files (
40
+ id integer primary key,
41
+ path text not null unique,
42
+ account text not null,
43
+ project text not null,
44
+ session text not null,
45
+ agent text not null,
46
+ size integer not null,
47
+ mtime integer not null,
48
+ offset integer not null,
49
+ version integer not null,
50
+ first_at integer,
51
+ last_at integer,
52
+ gone integer not null default 0
53
+ );
54
+ create index if not exists files_project on files (project);
55
+ create table if not exists calls (
56
+ file_id integer not null,
57
+ id text not null,
58
+ start integer not null,
59
+ end integer,
60
+ tool text not null,
61
+ shape text not null,
62
+ cmd text not null,
63
+ outcome text,
64
+ code integer,
65
+ approval integer not null default 0,
66
+ perm text not null default '',
67
+ read integer not null default 0
68
+ );
69
+ create index if not exists calls_file on calls (file_id, start);
70
+ create table if not exists marks (
71
+ file_id integer not null,
72
+ at integer not null,
73
+ kind text not null
74
+ );
75
+ create index if not exists marks_file on marks (file_id, at);
76
+ create table if not exists prompts (
77
+ file_id integer not null,
78
+ at integer not null,
79
+ uuid text,
80
+ origin text not null,
81
+ text text not null
82
+ );
83
+ create index if not exists prompts_file on prompts (file_id, at);
84
+ create table if not exists meta (key text primary key, value text not null);
85
+ `
86
+
87
+ export function openIndex(path = agentTimeDbPath()): Database {
88
+ mkdirSync(dirname(path), { recursive: true })
89
+ const db = new Database(path, { create: true, readwrite: true })
90
+ db.exec('pragma busy_timeout = 30000')
91
+ ensureWal(db, 5_000)
92
+ db.exec('pragma synchronous = normal')
93
+ db.exec(SCHEMA)
94
+ migrate(db)
95
+ return db
96
+ }
97
+
98
+ /** columns added after the first build, added in place: the rows of pruned
99
+ * transcripts cannot be read again, so a migration never drops a table */
100
+ function migrate(db: Database): void {
101
+ const cols = (db.query('pragma table_info(calls)').all() as { name: string }[]).map((c) => c.name)
102
+ if (!cols.includes('approval')) db.exec('alter table calls add column approval integer not null default 0')
103
+ if (!cols.includes('perm')) db.exec("alter table calls add column perm text not null default ''")
104
+ if (!cols.includes('read')) db.exec('alter table calls add column read integer not null default 0')
105
+ // a tool use id is unique on the machine: a session copied into a second
106
+ // account (to resume it there) holds the same calls, written once
107
+ const unique = db.query("select 1 from sqlite_master where type = 'index' and name = 'calls_id'").get()
108
+ if (unique == null) {
109
+ db.exec('drop index if exists calls_open')
110
+ db.exec('delete from calls where rowid not in (select min(rowid) from calls group by id)')
111
+ db.exec('create unique index calls_id on calls (id)')
112
+ }
113
+ const row = db.query("select value from meta where key = 'shape'").get() as { value: string } | null
114
+ if (row?.value === RULES_VERSION) return
115
+ reshape(db)
116
+ }
117
+
118
+ /** the shape rules and the read rules, one stamp: either changing reshapes */
119
+ const RULES_VERSION = `${SHAPE_VERSION}.${READ_VERSION}`
120
+
121
+ /** a shape or read rule changed: every call is shaped and judged again from
122
+ * its stored command, pruned transcripts included, no file read */
123
+ function reshape(db: Database): void {
124
+ const rows = db.query('select rowid, tool, cmd, shape, read from calls').all() as { rowid: number; tool: string; cmd: string; shape: string; read: number }[]
125
+ const set = db.query('update calls set shape = ?, read = ? where rowid = ?')
126
+ db.transaction(() => {
127
+ for (const r of rows) {
128
+ const shape = toolShape(r.tool, r.cmd)
129
+ const read = isReadOnly({ tool: r.tool, shape, cmd: r.cmd }) ? 1 : 0
130
+ if (shape !== r.shape || read !== r.read) set.run(shape, read, r.rowid)
131
+ }
132
+ db.query("insert or replace into meta (key, value) values ('shape', ?)").run(RULES_VERSION)
133
+ })()
134
+ }
135
+
136
+ /** one transcript file on disk */
137
+ export type TranscriptFile = { path: string; account: string; project: string; session: string; agent: string; size: number; mtime: number }
138
+
139
+ /** what the index remembers of a file */
140
+ export type IndexedFile = { size: number; mtime: number; offset: number; version: number }
141
+
142
+ export type Freshness = 'skip' | 'append' | 'rebuild'
143
+
144
+ /** what a file needs: nothing, its new tail, or a full read. A transcript only
145
+ * grows, so a larger file with the same start is read from where the last
146
+ * read stopped; anything else is read again from the top */
147
+ export function freshness(prev: IndexedFile | null, now: { size: number; mtime: number }, version = INDEX_VERSION): Freshness {
148
+ if (prev == null || prev.version !== version) return 'rebuild'
149
+ if (now.size === prev.size && now.mtime === prev.mtime) return 'skip'
150
+ if (now.size > prev.size && prev.offset <= now.size) return 'append'
151
+ return 'rebuild'
152
+ }
153
+
154
+ /** the projects of the repos asked for: the repo's slug and its worktrees */
155
+ export type ProjectFilter = (project: string) => boolean
156
+
157
+ /** every transcript file under every config root: sessions, and the sub-agent
158
+ * runs under `<session>/subagents/` */
159
+ export function listTranscripts(roots: ConfigRoot[] = discoverConfigRoots(), keep: ProjectFilter = () => true): TranscriptFile[] {
160
+ const out: TranscriptFile[] = []
161
+ const add = (path: string, account: string, project: string, session: string, agent: string): void => {
162
+ try {
163
+ const st = statSync(path)
164
+ out.push({ path, account, project, session, agent, size: st.size, mtime: Math.floor(st.mtimeMs) })
165
+ } catch { /* deleted between the listing and the stat */ }
166
+ }
167
+ for (const root of roots) {
168
+ let projects: string[]
169
+ try { projects = readdirSync(root.projects) } catch { continue }
170
+ for (const project of projects) {
171
+ if (!keep(project)) continue
172
+ const dir = join(root.projects, project)
173
+ let entries: string[]
174
+ try { entries = readdirSync(dir) } catch { continue }
175
+ const sessions = new Set(entries.filter((e) => e.endsWith('.jsonl')).map((e) => e.slice(0, -6)))
176
+ for (const s of sessions) add(join(dir, `${s}.jsonl`), root.account, project, s, '')
177
+ for (const e of entries) {
178
+ if (e.endsWith('.jsonl') || e.includes('.')) continue
179
+ const sub = join(dir, e, 'subagents')
180
+ let agents: string[]
181
+ try { agents = readdirSync(sub) } catch { continue }
182
+ for (const a of agents) if (a.endsWith('.jsonl')) add(join(sub, a), root.account, project, e, a.slice(0, -6))
183
+ }
184
+ }
185
+ }
186
+ return out
187
+ }
188
+
189
+ const CHUNK_BYTES = 32 * 1024 * 1024
190
+
191
+ /** every complete line of a file from `offset`, read in chunks so a session
192
+ * of half a gigabyte never becomes one string. Returns the offset after the
193
+ * last newline, so a half written tail line is read next time */
194
+ function eachLine(path: string, offset: number, size: number, onLine: (line: string) => void): number {
195
+ const fd = openSync(path, 'r')
196
+ let pos = offset
197
+ let carry: Buffer | null = null
198
+ try {
199
+ while (pos < size) {
200
+ const want = Math.min(CHUNK_BYTES, size - pos)
201
+ const fresh = Buffer.allocUnsafe(want)
202
+ const n = readSync(fd, fresh, 0, want, pos)
203
+ if (n <= 0) break
204
+ pos += n
205
+ const buf: Buffer = carry == null ? fresh.subarray(0, n) : Buffer.concat([carry, fresh.subarray(0, n)])
206
+ let start = 0
207
+ for (;;) {
208
+ const end = buf.indexOf(10, start)
209
+ if (end === -1) break
210
+ if (end > start) onLine(buf.toString('utf8', start, end))
211
+ start = end + 1
212
+ }
213
+ carry = start < buf.length ? Buffer.from(buf.subarray(start)) : null
214
+ }
215
+ } finally {
216
+ closeSync(fd)
217
+ }
218
+ return pos - (carry?.length ?? 0)
219
+ }
220
+
221
+ type Stmts = ReturnType<typeof statements>
222
+ const statements = (db: Database) => ({
223
+ get: db.query('select id, size, mtime, offset, version from files where path = ?'),
224
+ insFile: db.query('insert into files (path, account, project, session, agent, size, mtime, offset, version, first_at, last_at) values (?, ?, ?, ?, ?, 0, 0, 0, 0, null, null) returning id'),
225
+ setFile: db.query('update files set size = ?, mtime = ?, offset = ?, version = ?, first_at = coalesce(first_at, ?), last_at = coalesce(?, last_at), gone = 0 where id = ?'),
226
+ clear: [db.query('delete from calls where file_id = ?'), db.query('delete from marks where file_id = ?'), db.query('delete from prompts where file_id = ?')],
227
+ resetFile: db.query('update files set offset = 0, first_at = null, last_at = null where id = ?'),
228
+ insCall: db.query('insert or ignore into calls (file_id, id, start, end, tool, shape, cmd, outcome, code, approval, perm, read) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'),
229
+ endCall: db.query('update calls set end = ?, outcome = ?, code = ?, approval = ?, perm = ? where id = ? and end is null'),
230
+ insMark: db.query('insert into marks (file_id, at, kind) values (?, ?, ?)'),
231
+ insPrompt: db.query('insert into prompts (file_id, at, uuid, origin, text) values (?, ?, ?, ?, ?)'),
232
+ })
233
+
234
+ /** write the events of a chunk of lines: a call is written once its result
235
+ * is in this chunk or the chunk ends, and only once on the machine (a copy of
236
+ * the session in another account is ignored); a result for a call written by
237
+ * an earlier read, or by the copy, closes that row */
238
+ function writeEvents(st: Stmts, fileId: number, events: TranscriptEvent[]): { first: number | null; last: number | null } {
239
+ const pending = new Map<string, { id: string; start: number; end: number | null; tool: string; detail: string; outcome: string | null; code: number | null; perm: string }>()
240
+ let first: number | null = null
241
+ let last: number | null = null
242
+ const seen = (at: number): void => {
243
+ if (Number.isNaN(at)) return
244
+ if (first == null || at < first) first = at
245
+ if (last == null || at > last) last = at
246
+ }
247
+ for (const e of events) {
248
+ seen(e.at)
249
+ if (e.kind === 'use') pending.set(e.id, { id: e.id, start: e.at, end: null, tool: e.name, detail: e.detail, outcome: null, code: null, perm: '' })
250
+ else if (e.kind === 'result') {
251
+ const p = pending.get(e.id)
252
+ if (p != null) Object.assign(p, { end: e.at, outcome: e.outcome, code: e.code, perm: e.perm })
253
+ else st.endCall.run(e.at, e.outcome, e.code, e.perm === 'user' ? 1 : 0, e.perm, e.id)
254
+ } else if (e.kind === 'prompt') {
255
+ if (!Number.isNaN(e.at)) st.insPrompt.run(fileId, e.at, e.uuid, e.origin, e.text)
256
+ if (e.origin === 'typed' && !Number.isNaN(e.at)) st.insMark.run(fileId, e.at, 'prompt')
257
+ } else st.insMark.run(fileId, e.at, e.kind)
258
+ }
259
+ for (const p of pending.values()) {
260
+ const shape = toolShape(p.tool, p.detail)
261
+ st.insCall.run(fileId, p.id, p.start, p.end, p.tool, shape, p.detail, p.outcome, p.code, p.perm === 'user' ? 1 : 0, p.perm, isReadOnly({ tool: p.tool, shape, cmd: p.detail }) ? 1 : 0)
262
+ }
263
+ return { first, last }
264
+ }
265
+
266
+ export type IndexProgress = { files: number; done: number; bytes: number; doneBytes: number }
267
+
268
+ export type IndexResult = { scanned: number; read: number; bytes: number; ms: number; gone: number }
269
+
270
+ /** bring the index up to date for the files listed: unchanged files cost a
271
+ * stat, a grown one its new lines. `onProgress` fires after each file read */
272
+ export function indexTranscripts(db: Database, files: TranscriptFile[], onProgress?: (p: IndexProgress) => void): IndexResult {
273
+ const t0 = performance.now()
274
+ const st = statements(db)
275
+ const prevOf = (path: string) => st.get.get(path) as ({ id: number } & IndexedFile) | null
276
+ const toRead = new Map<string, number>()
277
+ for (const f of files) {
278
+ const prev = prevOf(f.path)
279
+ const how = freshness(prev, f)
280
+ if (how !== 'skip') toRead.set(f.path, how === 'append' && prev != null ? f.size - prev.offset : f.size)
281
+ }
282
+ const todo = files.filter((f) => toRead.has(f.path))
283
+ const total = todo.reduce((a, f) => a + (toRead.get(f.path) ?? 0), 0)
284
+ let doneBytes = 0
285
+ let readBytes = 0
286
+ todo.forEach((f, i) => {
287
+ db.exec('begin immediate')
288
+ try {
289
+ let row = prevOf(f.path)
290
+ const how = freshness(row, f)
291
+ if (how !== 'skip') {
292
+ if (row == null) {
293
+ const ins = st.insFile.get(f.path, f.account, f.project, f.session, f.agent) as { id: number }
294
+ row = { id: ins.id, size: 0, mtime: 0, offset: 0, version: 0 }
295
+ }
296
+ let from = row.offset
297
+ if (how === 'rebuild') {
298
+ for (const q of st.clear) q.run(row.id)
299
+ st.resetFile.run(row.id)
300
+ from = 0
301
+ }
302
+ const events: TranscriptEvent[] = []
303
+ const next = eachLine(f.path, from, f.size, (line) => {
304
+ for (const e of readTranscriptLine(line)) events.push(e)
305
+ })
306
+ readBytes += next - from
307
+ const span = writeEvents(st, row.id, events)
308
+ st.setFile.run(f.size, f.mtime, next, INDEX_VERSION, span.first, span.last, row.id)
309
+ }
310
+ db.exec('commit')
311
+ } catch (e) {
312
+ db.exec('rollback')
313
+ throw e
314
+ }
315
+ doneBytes += toRead.get(f.path) ?? 0
316
+ onProgress?.({ files: todo.length, done: i + 1, bytes: total, doneBytes })
317
+ })
318
+ return { scanned: files.length, read: todo.length, bytes: readBytes, ms: Math.round(performance.now() - t0), gone: 0 }
319
+ }
320
+
321
+ /** the files the index holds that are no longer on disk, marked so the
322
+ * report can say how much history outlived the harness */
323
+ export function markGone(db: Database, onDisk: TranscriptFile[], keep: ProjectFilter): number {
324
+ const present = new Set(onDisk.map((f) => f.path))
325
+ const rows = db.query('select id, path, project, gone from files').all() as { id: number; path: string; project: string; gone: number }[]
326
+ let gone = 0
327
+ const set = db.query('update files set gone = ? where id = ?')
328
+ db.transaction(() => {
329
+ for (const r of rows) {
330
+ if (!keep(r.project)) continue
331
+ const isGone = present.has(r.path) ? 0 : 1
332
+ if (isGone !== r.gone) set.run(isGone, r.id)
333
+ gone += isGone
334
+ }
335
+ })()
336
+ return gone
337
+ }
338
+
339
+ export type FileRow = { id: number; path: string; account: string; project: string; session: string; agent: string; first_at: number | null; last_at: number | null; gone: number }
340
+
341
+ export type Loaded = { files: FileRow[]; calls: Map<number, Call[]>; marks: Map<number, Mark[]>; prompts: { file_id: number; at: number; origin: 'typed' | 'queued'; text: string }[] }
342
+
343
+ /** every row of the matching projects with activity in [from, to] */
344
+ export function loadWindow(db: Database, keep: ProjectFilter, from: number, to: number): Loaded {
345
+ const all = db.query('select id, path, account, project, session, agent, first_at, last_at, gone from files').all() as FileRow[]
346
+ const files = all.filter((f) => keep(f.project) && f.last_at != null && f.last_at >= from && (f.first_at ?? 0) <= to)
347
+ const ids = files.map((f) => f.id)
348
+ const calls = new Map<number, Call[]>()
349
+ const marks = new Map<number, Mark[]>()
350
+ const prompts: Loaded['prompts'] = []
351
+ const CHUNK = 500
352
+ for (let i = 0; i < ids.length; i += CHUNK) {
353
+ const part = ids.slice(i, i + CHUNK)
354
+ const inList = part.map(() => '?').join(',')
355
+ for (const c of db.query(`select file_id, id, start, end, tool, shape, cmd, outcome, code, approval = 1 as approval, perm, read = 1 as read from calls where file_id in (${inList}) and start between ? and ?`).all(...part, from, to) as (Call & { file_id: number })[]) {
356
+ const list = calls.get(c.file_id) ?? []
357
+ list.push(c)
358
+ calls.set(c.file_id, list)
359
+ }
360
+ for (const m of db.query(`select file_id, at, kind from marks where file_id in (${inList}) and at between ? and ?`).all(...part, from, to) as (Mark & { file_id: number })[]) {
361
+ const list = marks.get(m.file_id) ?? []
362
+ list.push(m)
363
+ marks.set(m.file_id, list)
364
+ }
365
+ prompts.push(...(db.query(`select file_id, at, origin, text from prompts where file_id in (${inList}) and at between ? and ?`).all(...part, from, to) as Loaded['prompts']))
366
+ }
367
+ return { files, calls, marks, prompts }
368
+ }
369
+
370
+ /** the repo a project dir belongs to: its slug without the worktree suffix,
371
+ * named by what follows the home dev folder when it sits there */
372
+ export function repoOfProject(project: string, home = homedir()): string {
373
+ const bare = project.replace(/--claude-worktrees-.*$/, '')
374
+ const devSlug = join(home, 'dev').replace(/[^a-zA-Z0-9]/g, '-')
375
+ return bare.startsWith(`${devSlug}-`) ? bare.slice(devSlug.length + 1) : bare
376
+ }
377
+
378
+ export const fileLabel = (path: string): string => basename(path).replace(/\.jsonl$/, '')
379
+
@@ -0,0 +1,218 @@
1
+ // The time math of one transcript stream (a session, or one sub-agent run):
2
+ // how long each tool call took, and where the wall time went.
3
+ //
4
+ // The constraint a reader breaks first: parallel calls overlap. A call's own
5
+ // time is end minus start, and a sum of those is CALL time, which can exceed
6
+ // the wall. The wall is cut once, by a sweep over every boundary, so a second
7
+ // of wall lands in exactly one bucket.
8
+ import type { Outcome } from '../reflect/transcript.ts'
9
+
10
+ export type Call = {
11
+ id: string
12
+ start: number
13
+ /** null while its result has not arrived (a live call, or one never answered) */
14
+ end: number | null
15
+ tool: string
16
+ shape: string
17
+ cmd: string
18
+ outcome: Outcome | null
19
+ code: number | null
20
+ /** it waited for the human to allow it */
21
+ approval: boolean
22
+ /** how it was allowed (transcript.ts Perm) */
23
+ perm: string
24
+ /** it only read (turns.ts isReadOnly, judged when indexed) */
25
+ read: boolean
26
+ }
27
+
28
+ export type Mark = { at: number; kind: 'prompt' | 'wake' | 'message' | 'reply' }
29
+
30
+ export type Stream = { calls: Call[]; marks: Mark[] }
31
+
32
+ /** where a slice of wall time went */
33
+ export type Bucket = 'model' | 'tools' | 'approval' | 'subagent' | 'background' | 'human' | 'idle'
34
+
35
+ export type Breakdown = Record<Bucket, number>
36
+
37
+ /** a wait on him longer than this is the session parked, not a wait */
38
+ export const IDLE_MS = 30 * 60 * 1000
39
+
40
+ export const emptyBreakdown = (): Breakdown => ({ model: 0, tools: 0, approval: 0, subagent: 0, background: 0, human: 0, idle: 0 })
41
+
42
+ /** a foreground sub-agent call: the session waits on another agent's work */
43
+ export const isAgentCall = (c: Call): boolean => c.tool === 'Agent' || c.tool === 'Task'
44
+
45
+ /** the first value of a sorted list above `t`, by bisection */
46
+ function firstAfter(sorted: number[], t: number): number | undefined {
47
+ let lo = 0
48
+ let hi = sorted.length
49
+ while (lo < hi) {
50
+ const mid = (lo + hi) >> 1
51
+ if ((sorted[mid] as number) <= t) lo = mid + 1
52
+ else hi = mid
53
+ }
54
+ return sorted[lo]
55
+ }
56
+
57
+ /** the end every call is measured to: its result, else the next prompt or wake
58
+ * after it (the human moved on, so it was interrupted), else the stream's last
59
+ * event. `open` says the end was inferred */
60
+ export type Closed = Call & { stop: number; open: boolean }
61
+
62
+ /** sorted by start */
63
+ export function closeCalls(s: Stream): Closed[] {
64
+ const turns = s.marks.filter((m) => m.kind !== 'reply').map((m) => m.at).sort((a, b) => a - b)
65
+ let last = 0
66
+ for (const m of s.marks) if (m.at > last) last = m.at
67
+ for (const c of s.calls) if ((c.end ?? c.start) > last) last = c.end ?? c.start
68
+ return s.calls
69
+ .map((c): Closed => {
70
+ if (c.end != null) return { ...c, stop: Math.max(c.start, c.end), open: false }
71
+ const next = firstAfter(turns, c.start)
72
+ return { ...c, stop: next ?? last, open: true }
73
+ })
74
+ .sort((a, b) => a.start - b.start)
75
+ }
76
+
77
+ /** the call's own duration in ms */
78
+ export const callMs = (c: { start: number; stop: number }): number => Math.max(0, c.stop - c.start)
79
+
80
+ type Point = { at: number; d: 'start' | 'stop' | Mark['kind']; lane: 'tool' | 'agent' | 'approval'; ix: number }
81
+
82
+ /** the wall of one stream cut into buckets, clipped to [from, to]. Between two
83
+ * boundaries: a tool in flight → tools; only calls waiting for his allow →
84
+ * approval; only a sub-agent in flight → subagent;
85
+ * nothing in flight and the next thing is his prompt → human (idle past
86
+ * IDLE_MS); the next thing is a wake (a task notification, a hand-back) →
87
+ * background; a message relayed in counts as a prompt (a sub-agent that
88
+ * handed back sits parked until the coordinator or he writes to it); anything else → the model was working.
89
+ *
90
+ * `turnModel` (one slot per closed call) receives the model stretch that
91
+ * ended in each call: the turn that issued it, split evenly over the calls
92
+ * of one batch. A stretch that ends in his prompt or a wake was the final
93
+ * reply and goes to no call */
94
+ export function wallBreakdown(s: Stream, from = -Infinity, to = Infinity, idleMs = IDLE_MS, closed: Closed[] = closeCalls(s), turnModel?: Float64Array): Breakdown {
95
+ const out = emptyBreakdown()
96
+ const points: Point[] = []
97
+ closed.forEach((c, ix) => {
98
+ const lane = isAgentCall(c) ? 'agent' : c.approval ? 'approval' : 'tool'
99
+ points.push({ at: c.start, d: 'start', lane, ix }, { at: c.stop, d: 'stop', lane, ix })
100
+ })
101
+ for (const m of s.marks) points.push({ at: m.at, d: m.kind, lane: 'tool', ix: -1 })
102
+ points.sort((a, b) => a.at - b.at)
103
+ let tools = 0
104
+ let agents = 0
105
+ let approvals = 0
106
+ let pending = 0
107
+ const issued: number[] = []
108
+ let i = 0
109
+ while (i < points.length) {
110
+ const at = (points[i] as Point).at
111
+ issued.length = 0
112
+ let ended = false
113
+ while (i < points.length && (points[i] as Point).at === at) {
114
+ const p = points[i] as Point
115
+ const n = p.d === 'start' ? 1 : p.d === 'stop' ? -1 : 0
116
+ if (p.lane === 'agent') agents += n
117
+ else if (p.lane === 'approval') approvals += n
118
+ else tools += n
119
+ if (p.d === 'start') issued.push(p.ix)
120
+ else if (p.d === 'prompt' || p.d === 'wake' || p.d === 'message') ended = true
121
+ i++
122
+ }
123
+ if (turnModel != null) {
124
+ if (issued.length > 0) for (const ix of issued) turnModel[ix] = (turnModel[ix] ?? 0) + pending / issued.length
125
+ if (issued.length > 0 || ended) pending = 0
126
+ }
127
+ if (i >= points.length) break
128
+ const nextAt = (points[i] as Point).at
129
+ const a = Math.max(at, from)
130
+ const b = Math.min(nextAt, to)
131
+ if (b <= a) continue
132
+ const len = b - a
133
+ let bucket: Bucket
134
+ if (tools > 0) bucket = 'tools'
135
+ else if (approvals > 0) bucket = 'approval'
136
+ else if (agents > 0) bucket = 'subagent'
137
+ else {
138
+ let next = i
139
+ let prompt = false
140
+ let wake = false
141
+ while (next < points.length && (points[next] as Point).at === nextAt) {
142
+ const d = (points[next] as Point).d
143
+ if (d === 'prompt' || d === 'message') prompt = true
144
+ if (d === 'wake') wake = true
145
+ next++
146
+ }
147
+ const gap = nextAt - at
148
+ bucket = prompt ? (gap > idleMs ? 'idle' : 'human') : wake ? 'background' : 'model'
149
+ }
150
+ out[bucket] += len
151
+ if (bucket === 'model') pending += len
152
+ }
153
+ return out
154
+ }
155
+
156
+ export const addBreakdown = (into: Breakdown, b: Breakdown): Breakdown => {
157
+ for (const k of Object.keys(into) as Bucket[]) into[k] += b[k]
158
+ return into
159
+ }
160
+
161
+ /** a failed call is retried when the next call of the same shape in the
162
+ * stream comes within this window */
163
+ export const RETRY_MS = 10 * 60 * 1000
164
+
165
+ export const FAILED: ReadonlySet<Outcome> = new Set<Outcome>(['exit', 'error', 'timeout'])
166
+
167
+ export type Retry = { failed: Closed; next: Closed; blind: boolean }
168
+
169
+ /** every failed call followed by the same shape again: `blind` when the
170
+ * command is the exact same text, so nothing changed between the two.
171
+ * `calls` sorted by start (closeCalls) */
172
+ export function retries(calls: Closed[]): Retry[] {
173
+ const out: Retry[] = []
174
+ const nextOfShape = new Map<string, Closed>()
175
+ for (let i = calls.length - 1; i >= 0; i--) {
176
+ const c = calls[i] as Closed
177
+ const next = nextOfShape.get(c.shape)
178
+ nextOfShape.set(c.shape, c)
179
+ if (c.outcome == null || !FAILED.has(c.outcome)) continue
180
+ if (next == null || next.start - c.stop > RETRY_MS) continue
181
+ out.push({ failed: c, next, blind: next.cmd.trim() === c.cmd.trim() })
182
+ }
183
+ return out.reverse()
184
+ }
185
+
186
+ /** a call that ran longer than this before a complaint is not looked for */
187
+ const LOOKBACK_MS = 3 * 60 * 60 * 1000
188
+
189
+ /** what the agent ran in the minutes before `at`: calls in flight at that
190
+ * moment or started in the window, cut at `at`, longest first. `calls`
191
+ * sorted by start (closeCalls) */
192
+ export function ranBefore(calls: Closed[], at: number, windowMs = 10 * 60 * 1000): Closed[] {
193
+ let lo = 0
194
+ let hi = calls.length
195
+ while (lo < hi) {
196
+ const mid = (lo + hi) >> 1
197
+ if ((calls[mid] as Closed).start <= at) lo = mid + 1
198
+ else hi = mid
199
+ }
200
+ const out: Closed[] = []
201
+ for (let i = lo - 1; i >= 0; i--) {
202
+ const c = calls[i] as Closed
203
+ if (c.start < at - LOOKBACK_MS) break
204
+ if (c.stop >= at - windowMs) out.push({ ...c, stop: Math.min(c.stop, at) })
205
+ }
206
+ return out.sort((a, b) => callMs(b) - callMs(a))
207
+ }
208
+
209
+ /** p in [0, 1] of a list of ms, nearest rank */
210
+ export function quantile(xs: number[], p: number): number {
211
+ return quantileSorted([...xs].sort((a, b) => a - b), p)
212
+ }
213
+
214
+ /** the same over a list already sorted ascending */
215
+ export function quantileSorted(s: number[], p: number): number {
216
+ if (s.length === 0) return 0
217
+ return s[Math.min(s.length - 1, Math.max(0, Math.ceil(p * s.length) - 1))] ?? 0
218
+ }