rvlib-shipkit 2.2.0 → 2.4.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 (140) hide show
  1. package/CHANGELOG.md +47 -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/assets/kits/identity-kit.ts +5 -2
  12. package/src/cli/cmd_Commands.ts +9 -0
  13. package/src/cli/cmd_Review.ts +974 -0
  14. package/src/cli/cmd_Sound.ts +209 -0
  15. package/src/cli/cmd_Time.ts +112 -0
  16. package/src/cli/cmd_Trailer.ts +264 -6
  17. package/src/cli/kit/verb.ts +22 -4
  18. package/src/cli.ts +8 -4
  19. package/src/collections/collection-types.ts +6 -1
  20. package/src/collections/providers.ts +3 -0
  21. package/src/collections/review.ts +70 -0
  22. package/src/core/app-icon.ts +10 -6
  23. package/src/core/layout.ts +2 -0
  24. package/src/reflect/prompt-corpus.ts +15 -119
  25. package/src/reflect/transcript.ts +254 -0
  26. package/src/repo-config.ts +3 -0
  27. package/src/review/anim.ts +228 -0
  28. package/src/review/auto.ts +141 -0
  29. package/src/review/format.ts +158 -0
  30. package/src/review/http.ts +23 -0
  31. package/src/review/marks.ts +43 -0
  32. package/src/review/media.ts +45 -0
  33. package/src/review/model.ts +896 -0
  34. package/src/review/project.ts +126 -0
  35. package/src/review/role.ts +48 -0
  36. package/src/review/routes.ts +239 -0
  37. package/src/review/store.ts +1011 -0
  38. package/src/review/threads.ts +237 -0
  39. package/src/review/trailer-desk.ts +385 -0
  40. package/src/review/trailer-model.ts +183 -0
  41. package/src/review/trailer-routes.ts +136 -0
  42. package/src/review/unit.ts +183 -0
  43. package/src/review/watchers.ts +54 -0
  44. package/src/serve/embed.ts +35 -0
  45. package/src/serve/hub-app/activity-view.tsx +1 -1
  46. package/src/serve/hub-app/activity.css +0 -4
  47. package/src/serve/hub-app/admin-svc.ts +1 -1
  48. package/src/serve/hub-app/admin-view.tsx +1 -1
  49. package/src/serve/hub-app/dock-view.tsx +5 -4
  50. package/src/serve/hub-app/hash-route.ts +36 -1
  51. package/src/serve/hub-app/header-bar.tsx +3 -1
  52. package/src/serve/hub-app/home-st.ts +17 -3
  53. package/src/serve/hub-app/home-strip.tsx +3 -2
  54. package/src/serve/hub-app/home.html +1 -0
  55. package/src/serve/hub-app/hub-app.tsx +80 -11
  56. package/src/serve/hub-app/hub.css +83 -14
  57. package/src/serve/hub-app/icons.tsx +15 -0
  58. package/src/serve/hub-app/index.html +1 -0
  59. package/src/serve/hub-app/insights-view.tsx +4 -3
  60. package/src/serve/hub-app/keys.ts +14 -10
  61. package/src/serve/hub-app/layout-st.ts +63 -2
  62. package/src/serve/hub-app/md.ts +32 -0
  63. package/src/serve/hub-app/planned-view.tsx +3 -2
  64. package/src/serve/hub-app/planned.css +0 -1
  65. package/src/serve/hub-app/project-list.css +26 -6
  66. package/src/serve/hub-app/project-list.tsx +46 -10
  67. package/src/serve/hub-app/projects-svc.ts +7 -2
  68. package/src/serve/hub-app/repo-sections.ts +15 -3
  69. package/src/serve/hub-app/repo-st.ts +61 -4
  70. package/src/serve/hub-app/review/clip-st.ts +258 -0
  71. package/src/serve/hub-app/review/kind-view.tsx +240 -0
  72. package/src/serve/hub-app/review/kit.tsx +113 -0
  73. package/src/serve/hub-app/review/review-answer.tsx +269 -0
  74. package/src/serve/hub-app/review/review-board.tsx +46 -0
  75. package/src/serve/hub-app/review/review-list.tsx +156 -0
  76. package/src/serve/hub-app/review/review-page.tsx +96 -0
  77. package/src/serve/hub-app/review/review-st.ts +902 -0
  78. package/src/serve/hub-app/review/review-views.tsx +648 -0
  79. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  80. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  81. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  82. package/src/serve/hub-app/review.css +389 -0
  83. package/src/serve/hub-app/theme-st.ts +56 -0
  84. package/src/serve/hub-app/tree-build.ts +4 -0
  85. package/src/serve/hub-app/tree-row.tsx +1 -1
  86. package/src/serve/hub-app/tree-svc.ts +4 -0
  87. package/src/serve/hub-app/wiki-st.ts +190 -0
  88. package/src/serve/hub-app/wikis-view.tsx +134 -5
  89. package/src/serve/hub-runtime.ts +9 -7
  90. package/src/serve/project-gutter-route.ts +25 -1
  91. package/src/serve/project-gutter.ts +1 -1
  92. package/src/serve/same-origin.ts +5 -1
  93. package/src/serve/serve-hub.ts +15 -0
  94. package/src/shipkit-files.ts +9 -5
  95. package/src/shot/client.ts +17 -5
  96. package/src/shot/shooter.ts +19 -3
  97. package/src/shot/shot-ask.ts +9 -1
  98. package/src/shot/stepped-clip.ts +53 -0
  99. package/src/trailer/adapter.ts +90 -6
  100. package/src/trailer/build.ts +459 -59
  101. package/src/trailer/ffmpeg.ts +22 -0
  102. package/src/trailer/fonts.ts +55 -0
  103. package/src/trailer/footage.ts +112 -43
  104. package/src/trailer/grammar.ts +864 -127
  105. package/src/trailer/graph.ts +308 -49
  106. package/src/trailer/holes.ts +72 -0
  107. package/src/trailer/load-unit.ts +20 -3
  108. package/src/trailer/look.ts +20 -3
  109. package/src/trailer/motion.ts +207 -0
  110. package/src/trailer/passage.ts +129 -0
  111. package/src/trailer/plan.ts +82 -23
  112. package/src/trailer/prune.ts +204 -0
  113. package/src/trailer/score/compose.ts +256 -0
  114. package/src/trailer/score/dsp.ts +272 -0
  115. package/src/trailer/score/gm-render.ts +43 -0
  116. package/src/trailer/score/gm.ts +90 -0
  117. package/src/trailer/score/midi.ts +143 -0
  118. package/src/trailer/score/palette.ts +188 -0
  119. package/src/trailer/score/pattern.ts +98 -0
  120. package/src/trailer/score/score.ts +228 -0
  121. package/src/trailer/score/sound.ts +123 -0
  122. package/src/trailer/score/synth.ts +383 -0
  123. package/src/trailer/sound/audio.ts +148 -0
  124. package/src/trailer/sound/brand.ts +42 -0
  125. package/src/trailer/sound/comfy.ts +38 -0
  126. package/src/trailer/sound/eleven.ts +72 -0
  127. package/src/trailer/sound/fluid.ts +71 -0
  128. package/src/trailer/sound/gen.ts +139 -0
  129. package/src/trailer/sound/index.ts +60 -0
  130. package/src/trailer/sound/presets.ts +69 -0
  131. package/src/trailer/sound/run.ts +189 -0
  132. package/src/trailer/states.ts +76 -0
  133. package/src/trailer/stitch.ts +92 -9
  134. package/src/trailer/titles.ts +471 -150
  135. package/src/trailer/url.ts +18 -3
  136. package/src/trailer/vocab.ts +34 -4
  137. package/src/trailer/words.ts +275 -0
  138. package/src/wiki/graph-mount.ts +3 -0
  139. package/src/wiki/graph-page.ts +45 -9
  140. package/src/wiki/graph-pages.ts +128 -0
@@ -0,0 +1,84 @@
1
+ // What his typed prompts say about the work, by rule, no model: a status ping
2
+ // (he is waiting and asks where things stand), a slowness complaint, a
3
+ // correction. The rules are this visible table, so every count is explainable
4
+ // by the row that matched.
5
+ //
6
+ // The constraint a reader breaks first: his prompts are also SPECS, and a spec
7
+ // says "slow" about the game ("the camera pans too slow"). A slowness rule
8
+ // names the agent's world (tests, the gate, a tool, "takes forever"), never
9
+ // the bare word.
10
+
11
+ export type Signal = 'status' | 'slow' | 'correction'
12
+
13
+ export type Rule = { signal: Signal; id: string; re: RegExp }
14
+
15
+ const AGENT_WORLD = String.raw`(?:tests?|ci|gate|tools?|builds?|agents?|commands?|machine|cpu|checks?|deploys?|hub|servers?|sessions?|lanes?|you|sk|shipkit|typecheck|tsc|lint|everything|things|projects?|progress|it all|shots?|renders?|indexing)`
16
+
17
+ export const RULES: Rule[] = [
18
+ { signal: 'status', id: 'where-stand', re: /\bwhere (?:do|are) we (?:stand|at)\b/i },
19
+ { signal: 'status', id: 'progress', re: /\b(?:good progress|any progress|how(?:'s| is) it going|any news|status update|still (?:running|working|on it)|are you (?:done|finished|still))\b/i },
20
+ { signal: 'status', id: 'done-q', re: /^\s*(?:done|finished|status|progress|eta|so)\s*\?+\s*$/i },
21
+ { signal: 'status', id: 'fr-avance', re: /\b(?:ça avance|ca avance|où en (?:est|sommes)|ou en est)\b/i },
22
+
23
+ { signal: 'slow', id: 'too-slow', re: new RegExp(String.raw`\b${AGENT_WORLD}\b[^.?!\n]{0,40}\b(?:too|so|very|really|super|crazy|insanely|extremely|painfully)\s+slow|\b(?:too|so|very|really|super|crazy|insanely|extremely|painfully)\s+slow\b[^.?!\n]{0,30}\b${AGENT_WORLD}\b`, 'i') },
24
+ { signal: 'slow', id: 'takes-long', re: /\b(?:takes?|taking|took)\s+(?:so\s+|too\s+|way\s+too\s+|really\s+)?(?:long|forever|ages|minutes)\b/i },
25
+ { signal: 'slow', id: 'world-slow', re: new RegExp(String.raw`\b${AGENT_WORLD}\b[^.?!\n]{0,40}\b(?:slow|sluggish|hangs?|stuck)\b`, 'i') },
26
+ { signal: 'slow', id: 'slow-world', re: new RegExp(String.raw`\b(?:slow|sluggish)\b[^.?!\n]{0,20}\b${AGENT_WORLD}\b`, 'i') },
27
+ { signal: 'slow', id: 'make-faster', re: new RegExp(String.raw`\b(?:make|making|get)\s+${AGENT_WORLD}\s+(?:\w+\s+)?faster\b`, 'i') },
28
+ { signal: 'slow', id: 'still-waiting', re: /\b(?:(?:i'?m|i am|we'?re|we are) still waiting|waiting for ages|been waiting|wait(?:ing)? forever|my cpu|save my machine)\b/i },
29
+ { signal: 'slow', id: 'slow-equals', re: /\bslow\s*=\s*wrong\b/i },
30
+
31
+ { signal: 'correction', id: 'double-bang', re: /!!/ },
32
+ { signal: 'correction', id: 'already-said', re: /\b(?:i (?:already|just) (?:said|told|asked)|how many times|as i said|i said (?:it|that) before|again\s*\?!|stop doing)\b/i },
33
+ { signal: 'correction', id: 'swear', re: /\b(?:wtf|fuck\w*|ffs|ridiculous|seriously\s*\?|are you kidding)\b/i },
34
+ { signal: 'correction', id: 'not-what', re: /\b(?:that'?s not what i (?:asked|said|meant)|you (?:didn'?t|did not) (?:read|listen)|you misread|wrong again)\b/i },
35
+ ]
36
+
37
+ /** short words in capitals that are names, never shouting */
38
+ const CAPS_OK = new Set([
39
+ 'API', 'URL', 'URLS', 'JSON', 'HTML', 'CSS', 'PR', 'OK', 'TODO', 'README', 'CLI', 'TUI', 'DSL', 'TLDR', 'ASAP', 'GPU', 'CPU', 'IDS',
40
+ 'FAIL', 'WARN', 'GO', 'UI', 'UX', 'SVG', 'PNG', 'MP4', 'SQL', 'HTTP', 'HTTPS', 'SSH', 'NPM', 'TS', 'JS', 'MD', 'IMO', 'FYI', 'LLM',
41
+ 'AGENTS', 'CLAUDE', 'STE', 'VAT', 'TVA', 'OVH', 'DNS', 'CDN', 'OG', 'RGB', 'FPS', 'HMR', 'MCP', 'SDK', 'AI', 'GIF', 'PDF', 'EN', 'FR',
42
+ 'WIP', 'MVP', 'VS', 'ID', 'NPC', 'NPCS', 'HUD', 'HP', 'XP', 'UTC', 'CET', 'ENV', 'EOF', 'RAM', 'SSD', 'USB', 'AND', 'OR', 'NOT',
43
+ ])
44
+
45
+ /** code and pastes say nothing about his mood: fenced blocks, inline code and
46
+ * quoted pastes are dropped before any rule runs */
47
+ export function prose(text: string): string {
48
+ return text
49
+ .replace(/<pasted_content\b[\s\S]*?(?:<\/pasted_content>|$)/g, ' ')
50
+ .replace(/```[\s\S]*?(?:```|$)/g, ' ')
51
+ .replace(/`[^`\n]*`/g, ' ')
52
+ .split('\n')
53
+ // quoted lines, tables, stack frames, shell lines, and an agent's report
54
+ // pasted back (its lines open on a skim marker)
55
+ .filter((l) => !/^\s*(?:>|\||\d+\s*[|:]|at |\$ |[✅💬🔴🔶🧹💡❓🤝🚧🔼🚀🔒💥💸])/u.test(l))
56
+ .join('\n')
57
+ }
58
+
59
+ /** two or more shouted words (three letters or more, not a known name) */
60
+ export function shouts(text: string): number {
61
+ const words = text.match(/\b[A-Z][A-Z']{2,}\b/g) ?? []
62
+ return words.filter((w) => !CAPS_OK.has(w.replace(/'.*$/, '')) && !/^[A-Z]{2,}\d/.test(w)).length
63
+ }
64
+
65
+ export type SignalHit = { signal: Signal; rule: string }
66
+
67
+ /** every signal one prompt carries, with the rule that matched each */
68
+ /** joy shouts too: `!!` and capitals next to these words are delight, not a correction */
69
+ const JOY = /\b(?:love|amazing|great|awesome|perfect|fantastic|wonderful|beautiful|wow|nice|yes|excellent|brilliant|bravo|incredible|gorgeous)\b/i
70
+
71
+ export function signalsOf(text: string): SignalHit[] {
72
+ const body = prose(text).slice(0, 2000)
73
+ const hits: SignalHit[] = []
74
+ const seen = new Set<Signal>()
75
+ const joy = JOY.test(body)
76
+ for (const r of RULES) {
77
+ if (joy && r.id === 'double-bang') continue
78
+ if (seen.has(r.signal) || !r.re.test(body)) continue
79
+ seen.add(r.signal)
80
+ hits.push({ signal: r.signal, rule: r.id })
81
+ }
82
+ if (!seen.has('correction') && !joy && shouts(body) >= 2) hits.push({ signal: 'correction', rule: 'caps' })
83
+ return hits
84
+ }
@@ -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
+