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.
- package/CHANGELOG.md +47 -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/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +9 -0
- package/src/cli/cmd_Review.ts +974 -0
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +264 -6
- package/src/cli/kit/verb.ts +22 -4
- package/src/cli.ts +8 -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/app-icon.ts +10 -6
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/repo-config.ts +3 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +158 -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 +896 -0
- package/src/review/project.ts +126 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +239 -0
- package/src/review/store.ts +1011 -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 +183 -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/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +36 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +17 -3
- 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 +83 -14
- package/src/serve/hub-app/icons.tsx +15 -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/md.ts +32 -0
- 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 +26 -6
- package/src/serve/hub-app/project-list.tsx +46 -10
- package/src/serve/hub-app/projects-svc.ts +7 -2
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +61 -4
- package/src/serve/hub-app/review/clip-st.ts +258 -0
- package/src/serve/hub-app/review/kind-view.tsx +240 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +269 -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 +96 -0
- package/src/serve/hub-app/review/review-st.ts +902 -0
- package/src/serve/hub-app/review/review-views.tsx +648 -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 +389 -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-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +9 -7
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +15 -0
- package/src/shipkit-files.ts +9 -5
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +90 -6
- package/src/trailer/build.ts +459 -59
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +112 -43
- package/src/trailer/grammar.ts +864 -127
- package/src/trailer/graph.ts +308 -49
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +20 -3
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +82 -23
- package/src/trailer/prune.ts +204 -0
- package/src/trailer/score/compose.ts +256 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +98 -0
- package/src/trailer/score/score.ts +228 -0
- package/src/trailer/score/sound.ts +123 -0
- package/src/trailer/score/synth.ts +383 -0
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +471 -150
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +34 -4
- package/src/trailer/words.ts +275 -0
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- 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
|
+
|