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.
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/cli/cmd_Commands.ts +5 -0
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +259 -6
- package/src/cli/kit/verb.ts +14 -4
- package/src/cli.ts +6 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +9 -5
- package/src/trailer/adapter.ts +58 -3
- package/src/trailer/build.ts +300 -41
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/footage.ts +109 -40
- package/src/trailer/grammar.ts +444 -69
- package/src/trailer/graph.ts +199 -14
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +17 -3
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +80 -21
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +123 -18
- package/src/trailer/vocab.ts +23 -4
- 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
|
+
}
|