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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
+
## 2.3.0
|
|
2
|
+
|
|
3
|
+
- **A sound is a review view.** `sk review add --audio <sound>`, a sound in `--view`, `add-view <id> "<label>" --audio <sound>`, or a before and after of two sounds: the store keeps the sound and draws its waveform (`wave.png`, `before-wave.png`), and the hub shows each waveform over a player, one row a side. A sound takes reactions and words, never a rectangle; a pair is never a sound and an image.
|
|
4
|
+
- **The hub's review page draws the repo's parts, and embeds in another tool.** The answer column shows the item's data view (its kind's facts, links, a text or a line diff, a live frame he opens) and offers a pair's animation (cut, fade or wipe; mp4 or gif; labels en, fr or none). With a dispatcher, « board » lists the raised items by state, each topic and worker, a stale claim marked, and who holds the role. `<base>/embed/?page=review` (or `trailer`) serves the page alone, bundled once a process with relative asset paths, so a proxy can frame it under its own origin and a prefix; the same origin guard honours `x-forwarded-host`; the page posts `{ shipkit: 'route', page, hash }` to its parent and moves on `{ shipkit: 'go', hash }`.
|
|
5
|
+
- **`review({...})` carries what only one repo knows.** A kind's `flags` and `parse` turn `sk review add --subject place --where z:1,2` into its subject and topic; the unit's `flags`, `add` and `addView` hooks check and fill a card before it is written; `live: { frames, stop }` makes `sk review add --wip --live <name>` a draft card on the repo's live shot, stopped by `ready`; `verbs` adds the repo's own `sk review <verb>`.
|
|
6
|
+
- **`review({...})`: a repo's parts of the review store, in one unit.** Any `<name>.shipkit.ts` may default-export `review({ kinds, topics, dispatcher, sources, auto, anim })` (`rvlib-shipkit/src/shipkit-files.ts`). A kind declares its label and icon, and on the server its `key` (what names one subject), `near` (two subjects of one topic close enough to be one card), `problems` (what is wrong with a subject), and its data view (`facts`, `links`, `live`, `text`), which the hub fetches from `GET /api/review/<id>/view`. When a unit declares kinds or topics, `add`, `raise` and every read refuse others (`shot`, `screen` and `frame` always pass). The unit file is found by a walk cached on the walked dirs' times and a text test, never by importing every unit file.
|
|
7
|
+
- **`sk review`: the dispatcher, the workers and every generic verb.** `dispatcher --as <name> [--take]` (one holder, 30 minutes idle before another takes it), `dispatch`, `ask`, `drop`, `reassign`, `next <topic> | --lane <lane>`, `blocked`, `done`, `topics`, `board`, `ready`, `delete` (a player's test report at its source and here), `marks`, `anim` (a pair as a looping cut, fade or wipe, mp4 or gif, cached), `auto` and `auto run <id> [--dry]`. `inbox` and `watch` list the items nobody takes (2 minutes after the last message, then every 2 hours); `inbox`, `board`, `next` and `watch` pull the unit's sources first; `show` and `seen` take a report's ref (`live#3`). Without a dispatcher in the unit, a worker claims a new item as it is. Routes `POST /api/review/raise` and `/api/review/rate` raise an owner's note and his mark from any tool.
|
|
8
|
+
- **`sk review` answers in about 0.15 s on a 3318 item store.** Its threads index their links once per list (420 ms of lookups became 21), the store stamps two directories an item, and the index is its own file, `.review-index.shipkit.json`, so a second code reading the store no longer throws it away.
|
|
9
|
+
|
|
10
|
+
- **`sk trailer`: a score composed in the `.trailer` file, rendered offline in Bun.** The header reads `score tempo <bpm> [meter] [grid] [reverb <s> <mix>] [fade-in] [volume]`, `track <name> <instrument> [volume]` (up to 16), `drums <letter>=<instrument> …` and a `code` block of TypeScript; a section reads `play <track> <expression>` (a quoted string is a melody, `play *` a record of tracks, deeper lines continue it), `drums`, `mark`, and `stop at <time>`, which cuts the score and its reverb dead so a sound after it plays alone. The syntax is pkm-multi's, verbatim, `stop` added. The compose helpers are in scope (`mel`, `arp`, `bass`, `voice`, `chart`, `rep`, `seq`, `transpose`, `rest`, `parseChord`, `midiToNote`, `INST`, `KIT`, `additive`), and with no `export const instruments` the score plays the default palette, now with `sub` and `clap`. `sk trailer check` compiles the score in about 0.1 s, every error at its line (a play of the wrong length in bars, an unknown instrument or token, the TypeScript failing). The synth (`src/trailer/score/synth.ts`) renders every note as the browser synth the language comes from does (band limited tables softened per pitch band, ADSR, biquad, unison, vibrato, sweep, glide, pan), through a convolution room and the master chain (compressor, limiter, soft ceiling), in Bun with no browser: about 0.25 s for 9 s of music, cached on its notes and the synth's code. The build mixes it under the voices and sfx, ducked by `duck`, prints each section's RMS and peak, and refuses a section whose notes make no sound. New verb `sk trailer score [file]`: the score alone as `out/<cut>.score.wav` and `out/<cut>.score.png` (the waveform over the spectrum, a line at each section and the stop), with the integrated loudness.
|
|
11
|
+
|
|
12
|
+
- **`sk time`: where the agents' time went, from every Claude Code transcript on the machine.** Every config root (`~/.claude`, `~/.claude-<name>`), sub-agent runs included, read into one index, `~/.shipkit/agent-time.sqlite`: a file is read again only when it changed, a live session only from where the last read stopped, and a session copied into a second account counts once (a tool use id is unique). The index keeps the sessions Claude Code prunes. The first build read 65 GB in about 70 s, niced, resumable, one progress line; a warm query answers in about 0.2 s for a week of a busy repo, under 1 s for all of it. The summary opens on what costs the most, each line a measured total with its cause: file edits through Bash (`python3 (edit)`, `sed -i (edit)`, `cat > (write)`) against the Edit tool's p50, failed runs and their reruns, polling loops and their timeouts, the auto mode classifier (cheap commands it checked against the same commands a rule allowed, an estimate), and the calls that waited for a human allow. Then the wall cut once per moment (model, tools, a human allow, sub-agent wait, background wait, waiting on the human, idle left out), the commands grouped by shape (`git commit`, `bun test <file>`, `sk ci --only lint`, `(until) grep`) with calls, total, p50, p95 and max, and the human's prompts with three signals by rule (status ping, slowness, correction), each joined to the longest calls of the 10 minutes before. Views `commands`, `tools`, `slow`, `fails`, `feedback`, `sessions`; `--since`, `--all`, repo paths, `--top`, `--json`. Commands and prompts print through the leak guard's keyword lists and token shapes.
|
|
13
|
+
- **One transcript reader for `sk reflect` and `sk time`** (`src/reflect/transcript.ts`): a line in, its events out (a typed or queued prompt, a wake, a tool use, its result with how it ended and how it was allowed, a reply). `sk reflect` keeps the same prompts as before, now through it, and skips the `api-request` lines (most of a transcript's bytes) before parsing them.
|
|
14
|
+
|
|
15
|
+
- **`sk time` shows turn count, what makes agents slow when no command is.** Every call costs a model turn before it. The summary gives, for the main sessions and for the sub-agents, the model time before each call, the share of single read calls and the share sent in parallel. The sub-agent lines lead with a verdict (turn count or command speed, with a fix line when reads pile up one per turn), then their work split (model, tools, their own waits), run length p50 and p90, and calls per run. The `turns` view lists the runs that worked longest with their turns, read and parallel shares and model per turn, then the read shapes by the model time of the turns that issued them. A read is Read, Grep, Glob, or a Bash command of a read shape whose every part reads (a redirect to a file, `sed -i`, `find -delete` or `-exec`, `tee`, or a second command that writes or runs makes it not one), judged when a call is indexed and again when the rules change (`src/agent-time/turns.ts`). A call is parallel when it starts while the previous call still runs, within 2 s of its start. The model stretch before a call comes from the same sweep that cuts the wall. pkm-multi, every recorded session: sub-agents worked 905.4h, model 67%, 7.7 s of model before each of 283,821 calls, 51% single reads, 2.3% in parallel.
|
|
16
|
+
- **A message relayed into a sub-agent ends its park.** The harness writes "The coordinator sent a message while you were working" (or the user, or another session) into a sub-agent as an `isMeta` user entry with no peer origin, which the transcript reader dropped, so a sub-agent that handed back and sat for hours until someone wrote to it counted those hours as model: 352h of pkm-multi's sub-agent time. It is now a `message` event that counts as a prompt (idle past 30 minutes). The index version moves to 6, so every file is read again once.
|
|
17
|
+
|
|
18
|
+
## Unreleased (patch)
|
|
19
|
+
|
|
20
|
+
- **The rail's icons come in three sizes.** Folded, the list's foot shows an S, M or L button: 18, 24 or 32px icons, the rail 44, 50 or 58px wide to fit, kept per browser (`hub:rail-icon`). M is the default.
|
|
21
|
+
- **The project list folds to an icon rail.** ⌘B, the fold button at the list's foot, or a drag of its separator below the list's min folds it to 44px of icons: the names, the ⌘ hints and the step hint hide, a badge rides its icon's corner, the hover card still names the row. A drag out or ⌘B again unfolds it to its kept width; the fold is kept per browser (`hub:list-rail`). The dock moves to ⌘⇧B.
|
|
22
|
+
- **⌘; opens the insights**, its filter focused, so a package name is one key and a word away.
|
|
23
|
+
- **↑ ↓ walk every section again, review and trailer included.** Their pages claim the arrows for their cards and shots, so a walk down from the readme stopped on review. The arrows now stay the tree's while its cursor walks it, and a click in the page hands them back to the page (`RepoSt.pageKey`, `TreeSvc.leave`).
|
|
24
|
+
- **A light theme.** The button at the project list's foot cycles dark, light and auto (the system's), kept per browser. Every colour of the hub was already a token, so the light theme is a second token block (`:root[data-theme=light]`) and the guard fails a token it leaves out. A repo's name and mark use its colorLight on a light page, its colorDark on a dark one, everywhere: the list, the strip, the header, the dock, the plan.
|
|
25
|
+
- **The review page places the card against its answer: `top`, `left` or `auto`.** A switch on the card's top bar, kept with the review prefs. `top` stacks the card over the answer in one column, its boundary dragged; `left` keeps them side by side; `auto` stacks when the page is narrower than 900px (`STACK_PX`), measured on the page itself, so the project list and the sidebar count.
|
|
26
|
+
- **The review list narrows to 160px** (it stopped at 220). Under 250px a card puts its title on top, a 40px image under it beside the badges, the filters one a line. A thread's fold is the tree's chevron in a round 24px button, rotating as it opens, instead of a tiny ▾.
|
|
27
|
+
- **A repo's sidebar narrows to 150px** (it stopped at 220). Under 200px its ⌥ key hints and the filter's a, n, r letters hide, so the section names keep the room.
|
|
28
|
+
- **A repo's review and trailer sections sit right under the readme**, before contracts, and the review row wears its own pink hue (`--review`) so it reads at a glance.
|
|
29
|
+
- **One select shape across the hub.** Every native `select` and every segmented bar (the activity and insights pickers, the severity filter, the review and trailer bars) is one 22px pill with the live segment on the selection wash; the activity page's own `.acwin` bar is gone, merged into `.seg`.
|
|
30
|
+
|
|
31
|
+
- **`sk trailer`: a section that plays after the score's `stop` is an error at its play.** Its notes were never rendered, and the build then refused the measured silence as a volume 0. A section after the stop with no play stays legal. The synth's soft ceiling now clamps its input first, as a browser wave shaper does, so an overdriven score peaks where the browser's does (-0.7 dBFS), and the hot guard reads that ceiling.
|
|
32
|
+
|
|
33
|
+
- **`sk trailer`: the final mix is held under -1 dBTP, and the build says its loudness.** The mix's limiter ran with alimiter's default auto level, which lifts the output back to 0 dBFS, and it limited sample peaks only: a cut measured +0.4 dBTP. It now limits at 4 times the sample rate with auto level off, and a mix whose aac encode still overshoots (a limited square wave rang 2.1 dB past the limit) is made again that much lower. `sk trailer` prints the integrated LUFS and true peak of the score and of the final mix, warns past -1 dBTP, and fails past 0 dBTP.
|
|
34
|
+
|
|
35
|
+
- **`sk trailer`: a word that opens on a single quote fails at its line.** The language quotes with double quotes only, so `film take 'clip=walk&cam=low'` handed the project CLI a word with the quotes inside it, and the film took a wrong key without a word. The error names the double quoted form to write. A tokenizer error on a film line no longer adds "films nothing" for its shot.
|
|
36
|
+
|
|
37
|
+
- **`sk time` books a wait on a sub-agent as background wait, never as model time.** A sub-agent hand-back or another session's message reaches the session as a user entry flagged `isMeta` (`turnOrigin: 'peer'`), which the transcript reader skipped, so the gap before it counted as model thinking: pkm-multi showed model 35% where it is 14%, and background wait 34% where it is 55%. The index version moves to 5, so every file is read again once (about 70 s).
|
|
38
|
+
|
|
1
39
|
## 2.2.0
|
|
2
40
|
|
|
3
41
|
- **The project CLI kit, `rvlib-shipkit/cli-kit`, and a project CLI from `sk start`.** A repo's own CLI is `runProjectCli({ name, root, families })`: the first word loads only its family, the bare listing prints one line per command (0.027 s on a fresh repo), and every run is logged to `.shipkit/logs/<cli>-calls.jsonl`. A command over code that exists is a row, `verbs([{ path, category, description, examples, run: { bun } | { cmd } | { tool, cold } }])`, never a second implementation. A row marked `essential` lands on the everyday page, and a row marked `film: 'still' | 'clip'` takes `--out` (resolved against the caller's cwd), a clip also `--seconds`, and fails when it exits 0 without writing `--out`. The kit adds `<cli> commands` (`--out` and `--essentials` write the agent pages, `--check` fails on a stale page or a row with no category, a long description or no example starting with its words, `--json` is the registry for other tools) and `<cli> calls`. `sk start` writes the skeleton on day one: `src/cli.ts`, `src/cli/cmd_Dev.ts` with a dev row and a commented shot row, both agent pages generated, the agent index loading the essentials page, package.json `cli` and a `bin` of the repo name. `sk start <name> --cli` writes it into a repo with history, and refuses one that already has a CLI. The scaffold tsconfig names `types: ["bun"]`, which TypeScript 7 needs for Bun's globals.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"funding": "https://github.com/sponsors/rvion",
|
|
5
5
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// What `sk time` prints comes from his sessions: commands and prompts can
|
|
2
|
+
// carry a token or a private name. Every line passes through here first: the
|
|
3
|
+
// leak guard's own keyword list (machine wide and the repo's), then the token
|
|
4
|
+
// shapes no list has to name.
|
|
5
|
+
import { existsSync, readFileSync } from 'fs'
|
|
6
|
+
import { join } from 'path'
|
|
7
|
+
import { KEYWORD_FILE } from '../core/layout.ts'
|
|
8
|
+
import { type ParsedPatterns, globalKeywordFile, parsePatterns } from '../hooks/leak-guard.ts'
|
|
9
|
+
|
|
10
|
+
/** token shapes masked whatever the lists say */
|
|
11
|
+
const TOKEN_SHAPES: RegExp[] = [
|
|
12
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
|
|
13
|
+
/(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{20,}/g,
|
|
14
|
+
/\bgh[pousr]_[A-Za-z0-9]{30,}/g,
|
|
15
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
|
|
16
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
17
|
+
/\b(?:Bearer|token|Token)\s+[A-Za-z0-9._~+/=-]{16,}/g,
|
|
18
|
+
/\b(?:api[_-]?key|secret|password|passwd|token)\s*[=:]\s*['"]?[^\s'"]{8,}/gi,
|
|
19
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
23
|
+
|
|
24
|
+
export type Masker = (text: string) => string
|
|
25
|
+
|
|
26
|
+
/** a masker over parsed keyword rows plus the token shapes */
|
|
27
|
+
export function makeMasker(p: ParsedPatterns): Masker {
|
|
28
|
+
const res: RegExp[] = [
|
|
29
|
+
...TOKEN_SHAPES,
|
|
30
|
+
...p.substrings.map((s) => new RegExp(escapeRegex(s), 'gi')),
|
|
31
|
+
...p.words.map((w) => new RegExp(w.re.source, 'giu')),
|
|
32
|
+
...p.regexes.map((r) => new RegExp(r.re.source, 'gi')),
|
|
33
|
+
]
|
|
34
|
+
return (text) => res.reduce((t, re) => t.replace(re, '***'), text)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** the masker this machine and repo call for: the global keyword list and
|
|
38
|
+
* the repo's own, when they exist */
|
|
39
|
+
export function loadMasker(root: string | null): Masker {
|
|
40
|
+
const files = [globalKeywordFile(), root == null ? null : join(root, KEYWORD_FILE)].filter((f): f is string => f != null && existsSync(f))
|
|
41
|
+
const text = files.map((f) => readFileSync(f, 'utf8')).join('\n')
|
|
42
|
+
return makeMasker(parsePatterns(text))
|
|
43
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// `sk time` as text: verdict first, one line a row, ids not dumps. The default
|
|
2
|
+
// view answers "where did the time go and what wastes it" in about 25 lines;
|
|
3
|
+
// each sub view goes one level deeper on one question.
|
|
4
|
+
import type { Breakdown } from './timeline.ts'
|
|
5
|
+
import { type ShapeRow, type TimeReport, total } from './report.ts'
|
|
6
|
+
import type { TurnStats } from './turns.ts'
|
|
7
|
+
|
|
8
|
+
export const VIEWS = ['summary', 'commands', 'tools', 'slow', 'fails', 'feedback', 'sessions', 'turns'] as const
|
|
9
|
+
export type View = (typeof VIEWS)[number]
|
|
10
|
+
|
|
11
|
+
/** 0.4s, 12s, 4m10s, 3.2h */
|
|
12
|
+
export function fmtMs(ms: number): string {
|
|
13
|
+
if (ms < 1000) return `${(ms / 1000).toFixed(1)}s`
|
|
14
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
|
|
15
|
+
if (ms < 3_600_000) {
|
|
16
|
+
const m = Math.floor(ms / 60_000)
|
|
17
|
+
const s = Math.round((ms % 60_000) / 1000)
|
|
18
|
+
return s === 0 ? `${m}m` : `${m}m${String(s).padStart(2, '0')}s`
|
|
19
|
+
}
|
|
20
|
+
return `${(ms / 3_600_000).toFixed(1)}h`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const day = (t: number): string => new Date(t).toISOString().slice(0, 10)
|
|
24
|
+
const when = (t: number): string => new Date(t).toISOString().slice(5, 16).replace('T', ' ')
|
|
25
|
+
const pct = (part: number, whole: number): string => (whole <= 0 ? '0%' : `${Math.round((100 * part) / whole)}%`)
|
|
26
|
+
const n = (x: number): string => x.toLocaleString('en-US')
|
|
27
|
+
const account = (a: string): string => (a === '.claude' ? 'cc1' : a.replace(/^\.claude-/, ''))
|
|
28
|
+
|
|
29
|
+
function table(head: string[], rows: string[][]): string[] {
|
|
30
|
+
const all = [head, ...rows]
|
|
31
|
+
const w = head.map((_, i) => Math.max(...all.map((r) => (r[i] ?? '').length)))
|
|
32
|
+
return all.map((r) => ` ${r.map((c, i) => (i === 0 ? c.padEnd(w[i] ?? 0) : c.padStart(w[i] ?? 0))).join(' ')}`.trimEnd())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const shapeRows = (rows: ShapeRow[]): string[][] =>
|
|
36
|
+
rows.map((r) => [r.shape.slice(0, 44), n(r.calls), fmtMs(r.ms), fmtMs(r.p50), fmtMs(r.p95), fmtMs(r.max), r.failed === 0 ? '' : `${r.failed}`])
|
|
37
|
+
|
|
38
|
+
function wallLine(b: Breakdown): string {
|
|
39
|
+
const w = total(b, false)
|
|
40
|
+
const part = (label: string, ms: number): string => `${label} ${pct(ms, w)} ${fmtMs(ms)}`
|
|
41
|
+
return `wall ${fmtMs(w)}: ${[part('model', b.model), part('tools', b.tools), part('your allow', b.approval), part('sub-agent wait', b.subagent), part('background wait', b.background), part('waiting on you', b.human)].join(' · ')}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function header(r: TimeReport): string[] {
|
|
45
|
+
const span = r.from == null ? 'all recorded' : `since ${day(r.from)}`
|
|
46
|
+
const repos = r.repos.length > 3 ? `${r.repos.length} repos` : r.repos.join(', ')
|
|
47
|
+
const pruned = r.prunedSessions > 0 ? ` (${r.prunedSessions} kept after Claude Code pruned them)` : ''
|
|
48
|
+
return [
|
|
49
|
+
`${repos} · ${span} · ${r.accounts.length} account${r.accounts.length === 1 ? '' : 's'} · ${n(r.sessions)} sessions${pruned} · ${n(r.subagentRuns)} sub-agent runs`,
|
|
50
|
+
wallLine(r.wall),
|
|
51
|
+
`idle (no prompt for ${30}m+) left out: ${fmtMs(r.wall.idle)}`,
|
|
52
|
+
`${n(r.callCount)} tool calls, ${fmtMs(r.callMs)} of call time (parallel calls overlap, so it can pass the wall)`,
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** a share in percent, one decimal under 10 */
|
|
57
|
+
const share = (part: number, whole: number): string => {
|
|
58
|
+
if (whole <= 0) return '0%'
|
|
59
|
+
const p = (100 * part) / whole
|
|
60
|
+
return p < 10 ? `${p.toFixed(1)}%` : `${Math.round(p)}%`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** model time a call: one decimal, it is the number a change moves */
|
|
64
|
+
const perCall = (ms: number): string => (ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : fmtMs(ms))
|
|
65
|
+
|
|
66
|
+
/** the three turn metrics: model before each call, single reads, parallel */
|
|
67
|
+
const turnMetrics = (t: TurnStats): string => `${perCall(t.calls === 0 ? 0 : t.model / t.calls)} of model before each call · ${share(t.reads, t.calls)} single reads · ${share(t.parallel, t.calls)} in parallel`
|
|
68
|
+
|
|
69
|
+
/** what the work went to, verdict first: turns or commands, then the fix */
|
|
70
|
+
function turnVerdict(t: TurnStats, model: number, tools: number): string[] {
|
|
71
|
+
const work = model + tools
|
|
72
|
+
if (model <= tools) return [`command speed, not turns: tools ${pct(tools, work)} of the work, model ${pct(model, work)}; the slow commands are below`]
|
|
73
|
+
const out = [`turn count, not command speed: model ${pct(model, work)} of the work, ${turnMetrics(t)}`]
|
|
74
|
+
if (t.reads >= 0.3 * t.calls && t.parallel < 0.1 * t.calls) out.push(' fix: fewer turns (several reads in one turn, a project command that returns the whole answer)')
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** the turn metrics of the main sessions, then the sub-agents' verdict and work */
|
|
79
|
+
function turnsSection(r: TimeReport): { main: string[]; sub: string[] } {
|
|
80
|
+
const { main, sub } = r.turns
|
|
81
|
+
const out = { main: [] as string[], sub: [] as string[] }
|
|
82
|
+
if (main.calls > 0) out.main.push(`main sessions, ${n(main.calls)} calls: ${turnMetrics(main)}`)
|
|
83
|
+
if (sub.runs > 0) {
|
|
84
|
+
const w = sub.work
|
|
85
|
+
const busy = w.model + w.tools + w.approval + w.subagent + w.background
|
|
86
|
+
const waits = busy - w.model - w.tools
|
|
87
|
+
const [verdict, ...fix] = turnVerdict(sub, w.model, w.tools)
|
|
88
|
+
out.sub.push(
|
|
89
|
+
`sub-agents: ${verdict}`,
|
|
90
|
+
...fix,
|
|
91
|
+
` ${n(sub.runs)} runs worked ${fmtMs(busy)}: model ${fmtMs(w.model)} · tools ${fmtMs(w.tools)} · their own waits ${fmtMs(waits)} · ${n(sub.calls)} calls · a run p50 ${fmtMs(sub.runP50)} p90 ${fmtMs(sub.runP90)}, ${n(sub.callsP50)} calls p50`,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const bashOnly = (r: TimeReport): ShapeRow[] => r.shapes.filter((s) => s.tool === 'Bash')
|
|
98
|
+
|
|
99
|
+
function summary(r: TimeReport): string[] {
|
|
100
|
+
const turns = turnsSection(r)
|
|
101
|
+
const out = [...header(r), ...turns.main]
|
|
102
|
+
if (turns.sub.length > 0) out.push('', ...turns.sub)
|
|
103
|
+
if (r.insights.length > 0) out.push('', 'what costs the most (measured, biggest first; ~ = estimate)', ...r.insights.map((i) => ` ${`${i.estimate ? '~' : ''}${fmtMs(i.ms)}`.padStart(7)} ${i.text}`))
|
|
104
|
+
out.push('', 'commands by total time (Bash shapes)', ...table(['shape', 'calls', 'total', 'p50', 'p95', 'max', 'fail'], shapeRows(bashOnly(r).slice(0, 8))))
|
|
105
|
+
const p95 = [...bashOnly(r)].filter((s) => s.calls >= 5).sort((a, b) => b.p95 - a.p95).slice(0, 4)
|
|
106
|
+
if (p95.length > 0) out.push('', 'slowest per call (p95, 5 calls or more)', ...p95.map((s) => ` ${fmtMs(s.p95).padStart(6)} ${s.shape} (${s.calls} calls, ${fmtMs(s.ms)} total)`))
|
|
107
|
+
const waste = [...r.shapes].filter((s) => s.failed > 0).sort((a, b) => b.failedMs - a.failedMs || b.failed - a.failed).slice(0, 4)
|
|
108
|
+
if (waste.length > 0) out.push('', 'failures (time in failed runs, run again after a fail, of those unchanged)', ...waste.map((s) => ` ${fmtMs(s.failedMs).padStart(6)} ${s.shape}: ${s.failed} failed, ${s.retried} run again, ${s.blind} unchanged${s.timeouts > 0 ? `, ${s.timeouts} timeouts` : ''}`))
|
|
109
|
+
out.push('', ...feedbackHead(r))
|
|
110
|
+
const slow = r.feedback.hits.filter((h) => h.signals.some((s) => s.signal === 'slow')).slice(-3)
|
|
111
|
+
for (const h of slow) out.push(complaint(h))
|
|
112
|
+
out.push('', 'more: sk time commands | tools | slow | fails | feedback | sessions | turns, --json')
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function feedbackHead(r: TimeReport): string[] {
|
|
117
|
+
const f = r.feedback
|
|
118
|
+
const out = [`feedback: ${n(f.prompts)} prompts (${n(f.queued)} typed mid-run) · ${f.counts.status} status pings · ${f.counts.slow} slowness · ${f.counts.correction} corrections`]
|
|
119
|
+
for (const sig of ['status', 'slow'] as const) {
|
|
120
|
+
const by = new Map<string, number>()
|
|
121
|
+
for (const h of f.hits) if (h.signals.some((s) => s.signal === sig) && h.ran[0] != null) by.set(h.ran[0].shape, (by.get(h.ran[0].shape) ?? 0) + 1)
|
|
122
|
+
const top = [...by].sort((a, b) => b[1] - a[1]).slice(0, 4)
|
|
123
|
+
if (top.length > 0) out.push(` the longest run before his ${sig === 'status' ? 'status pings' : 'slowness complaints'}: ${top.map(([k, v]) => `${k} ×${v}`).join(', ')}`)
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function complaint(h: TimeReport['feedback']['hits'][number]): string {
|
|
129
|
+
const ran = h.ran.length === 0 ? 'nothing ran in the 10 minutes before' : h.ran.map((c) => `${c.shape} ${fmtMs(c.ms)}${c.open ? ' (no result)' : ''}`).join(', ')
|
|
130
|
+
return ` ${when(h.at)} ${h.signals.map((s) => s.rule).join(',')} "${h.text.slice(0, 70)}" ← ${ran}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function render(r: TimeReport, view: View, top: number): string {
|
|
134
|
+
let out: string[]
|
|
135
|
+
switch (view) {
|
|
136
|
+
case 'summary':
|
|
137
|
+
out = summary(r)
|
|
138
|
+
break
|
|
139
|
+
case 'commands':
|
|
140
|
+
out = [`${n(r.shapes.length)} shapes, by total time (every tool but sub-agents)`, ...table(['shape', 'calls', 'total', 'p50', 'p95', 'max', 'fail', 'rerun', 'timeout'], r.shapes.slice(0, top).map((s) => [...(shapeRows([s])[0] ?? []), `${s.retried || ''}`, `${s.timeouts || ''}`]))]
|
|
141
|
+
break
|
|
142
|
+
case 'tools':
|
|
143
|
+
out = [`${r.tools.length} tools, by total call time`, ...table(['tool', 'calls', 'total', 'p50', 'p95', 'max', 'fail'], shapeRows(r.tools.slice(0, top)))]
|
|
144
|
+
break
|
|
145
|
+
case 'slow':
|
|
146
|
+
out = [`the ${Math.min(top, r.slowest.length)} slowest Bash calls`, ...r.slowest.slice(0, top).map((s) => ` ${fmtMs(s.ms).padStart(6)} ${when(s.at)} ${account(s.account).padEnd(12)} ${s.session.slice(0, 8)}${s.agent ? ' sub' : ' '} ${s.outcome ?? 'no result'} ${s.cmd}`)]
|
|
147
|
+
break
|
|
148
|
+
case 'fails': {
|
|
149
|
+
const rows = [...r.shapes].filter((s) => s.failed + s.blocked > 0).sort((a, b) => b.failedMs - a.failedMs || b.failed - a.failed).slice(0, top)
|
|
150
|
+
out = [`${rows.length} shapes failed or were blocked, by time in failed runs`, ...table(['shape', 'failed', 'time lost', 'run again', 'unchanged', 'timeout', 'blocked'], rows.map((s) => [s.shape.slice(0, 44), `${s.failed}`, fmtMs(s.failedMs), `${s.retried}`, `${s.blind}`, `${s.timeouts}`, `${s.blocked}`]))]
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
case 'feedback': {
|
|
154
|
+
const f = r.feedback
|
|
155
|
+
out = [...feedbackHead(r), `rules that matched: ${Object.entries(f.rules).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${v}`).join(' · ') || 'none'}`]
|
|
156
|
+
for (const sig of ['slow', 'status', 'correction'] as const) {
|
|
157
|
+
const hs = f.hits.filter((h) => h.signals.some((s) => s.signal === sig))
|
|
158
|
+
if (hs.length === 0) continue
|
|
159
|
+
out.push('', `${sig} (${hs.length}, latest ${Math.min(top, hs.length)})`, ...hs.slice(-top).map(complaint))
|
|
160
|
+
}
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
case 'sessions':
|
|
164
|
+
out = [`${r.sessionRows.length} sessions, by wall (idle left out)`, ...table(['session', 'account', 'start', 'wall', 'model', 'tools', 'sub', 'bg', 'you', 'calls', 'agents', 'first prompt'], r.sessionRows.slice(0, top).map((s) => [s.session.slice(0, 8), account(s.account), when(s.start), fmtMs(total(s.wall, false)), fmtMs(s.wall.model), fmtMs(s.wall.tools), fmtMs(s.wall.subagent), fmtMs(s.wall.background), fmtMs(s.wall.human), `${s.calls}`, `${s.subagents}`, s.first.slice(0, 40)]))]
|
|
165
|
+
break
|
|
166
|
+
case 'turns': {
|
|
167
|
+
const t = r.turns
|
|
168
|
+
const reads = t.reads.reduce((a, x) => a + x.model, 0)
|
|
169
|
+
const ts = turnsSection(r)
|
|
170
|
+
out = [
|
|
171
|
+
...ts.main,
|
|
172
|
+
...ts.sub,
|
|
173
|
+
'',
|
|
174
|
+
`the ${Math.min(top, t.rows.length)} runs that worked longest (work: model, tools, your allow; wall adds their waits on other agents; a sub-agent run is session and agent id)`,
|
|
175
|
+
...table(['run', 'account', 'start', 'work', 'wall', 'model', 'calls', 'turns', 'read', 'parallel', 'model/turn'], t.rows.slice(0, top).map((x) => {
|
|
176
|
+
const turns = x.turns.calls - x.turns.parallel
|
|
177
|
+
return [`${x.session.slice(0, 8)}${x.agent === '' ? '' : ` ${x.agent.replace(/^agent-/, '').slice(0, 8)}`}`, account(x.account), when(x.start), fmtMs(x.work), fmtMs(x.wall), fmtMs(x.turns.model), n(x.turns.calls), n(turns), share(x.turns.reads, x.turns.calls), share(x.turns.parallel, x.turns.calls), perCall(turns === 0 ? 0 : x.turns.model / turns)]
|
|
178
|
+
})),
|
|
179
|
+
'',
|
|
180
|
+
`read shapes by the model time of the turns that issued them (${fmtMs(reads)} in all)`,
|
|
181
|
+
...table(['read', 'calls', 'model', 'per call'], t.reads.slice(0, top).map((x) => [x.shape, n(x.calls), fmtMs(x.model), perCall(x.calls === 0 ? 0 : x.model / x.calls)])),
|
|
182
|
+
]
|
|
183
|
+
break
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return `${out.join('\n')}\n`
|
|
187
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// `sk time` from the index rows: where the wall went, which commands ate the
|
|
2
|
+
// time, what failed and ran again, and what he said while it ran.
|
|
3
|
+
//
|
|
4
|
+
// The constraint a reader breaks first: one session can sit in two or three
|
|
5
|
+
// config roots (a session imported into another account to resume it there).
|
|
6
|
+
// Streams are merged by session and agent, and calls deduped by their tool
|
|
7
|
+
// use id, or every imported session counts twice.
|
|
8
|
+
import { type PromptEntry, dedupePrompts } from '../reflect/prompt-corpus.ts'
|
|
9
|
+
import type { Outcome } from '../reflect/transcript.ts'
|
|
10
|
+
import type { Masker } from './mask.ts'
|
|
11
|
+
import { type Signal, signalsOf } from './signals.ts'
|
|
12
|
+
import type { FileRow, Loaded } from './store.ts'
|
|
13
|
+
import { type ReadCost, type TurnStats, addTurns, emptyTurns, turnsOf } from './turns.ts'
|
|
14
|
+
import { type Breakdown, type Call, type Mark, type Stream, FAILED, IDLE_MS, addBreakdown, callMs, type Closed, closeCalls, emptyBreakdown, quantileSorted, ranBefore, retries, wallBreakdown } from './timeline.ts'
|
|
15
|
+
|
|
16
|
+
export type ShapeRow = { shape: string; tool: string; calls: number; ms: number; approvals: number; approvalMs: number; p50: number; p95: number; max: number; failed: number; timeouts: number; blocked: number; retried: number; blind: number; failedMs: number }
|
|
17
|
+
|
|
18
|
+
export type SlowCall = { ms: number; shape: string; cmd: string; at: number; account: string; session: string; agent: boolean; outcome: Outcome | null; open: boolean }
|
|
19
|
+
|
|
20
|
+
export type Complaint = { at: number; session: string; account: string; text: string; signals: { signal: Signal; rule: string }[]; ran: { shape: string; cmd: string; ms: number; open: boolean }[] }
|
|
21
|
+
|
|
22
|
+
/** one session or one sub-agent run, by its turns */
|
|
23
|
+
export type TurnRow = { session: string; agent: string; account: string; start: number; work: number; wall: number; turns: TurnStats }
|
|
24
|
+
|
|
25
|
+
/** turn count: the main sessions and the sub-agents apart, the runs with the
|
|
26
|
+
* most wall, and the read shapes by the model time of the turns that issued them */
|
|
27
|
+
export type TurnReport = {
|
|
28
|
+
main: TurnStats
|
|
29
|
+
sub: TurnStats & { runs: number; work: Breakdown; runP50: number; runP90: number; callsP50: number }
|
|
30
|
+
rows: TurnRow[]
|
|
31
|
+
reads: ReadCost[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type SessionRow = { session: string; account: string; repo: string; start: number; wall: Breakdown; calls: number; subagents: number; first: string }
|
|
35
|
+
|
|
36
|
+
export type TimeReport = {
|
|
37
|
+
repos: string[]
|
|
38
|
+
from: number | null
|
|
39
|
+
to: number
|
|
40
|
+
accounts: string[]
|
|
41
|
+
sessions: number
|
|
42
|
+
subagentRuns: number
|
|
43
|
+
/** history whose transcript Claude Code already pruned, still in the index */
|
|
44
|
+
prunedSessions: number
|
|
45
|
+
wall: Breakdown
|
|
46
|
+
subagentWork: number
|
|
47
|
+
callCount: number
|
|
48
|
+
callMs: number
|
|
49
|
+
shapes: ShapeRow[]
|
|
50
|
+
tools: ShapeRow[]
|
|
51
|
+
slowest: SlowCall[]
|
|
52
|
+
feedback: { prompts: number; queued: number; counts: Record<Signal, number>; rules: Record<string, number>; hits: Complaint[] }
|
|
53
|
+
sessionRows: SessionRow[]
|
|
54
|
+
turns: TurnReport
|
|
55
|
+
/** the measured waste, biggest first: each with the number behind it */
|
|
56
|
+
insights: Insight[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type Insight = { id: 'classifier' | 'bash-edits' | 'polling' | 'failed' | 'approval'; ms: number; estimate: boolean; text: string }
|
|
60
|
+
|
|
61
|
+
/** commands that cost nothing to run: their time is the harness around them */
|
|
62
|
+
const CHEAP = new Set(['grep', 'sed', 'cat', 'ls', 'head', 'tail', 'wc', 'git status', 'echo', 'pwd', 'rg'])
|
|
63
|
+
|
|
64
|
+
const isEdit = (shape: string): boolean => shape.endsWith('(edit)') || shape === 'cat > (write)'
|
|
65
|
+
const isPolling = (shape: string): boolean => shape.startsWith('(until)') || shape.startsWith('(while)') || shape === 'sleep'
|
|
66
|
+
|
|
67
|
+
const fmt = (ms: number): string => (ms < 1000 ? `${(ms / 1000).toFixed(1)}s` : ms < 60_000 ? `${Math.round(ms / 1000)}s` : ms < 3_600_000 ? `${Math.round(ms / 60_000)}m` : `${(ms / 3_600_000).toFixed(1)}h`)
|
|
68
|
+
|
|
69
|
+
const p50Of = (xs: number[]): number => quantileSorted(xs.sort((a, b) => a - b), 0.5)
|
|
70
|
+
|
|
71
|
+
/** the waste the rows add up to, each a cause with its number */
|
|
72
|
+
export function insightsOf(calls: Closed[], wall: Breakdown, shapes: ShapeRow[]): Insight[] {
|
|
73
|
+
const out: Insight[] = []
|
|
74
|
+
const bash = calls.filter((c) => c.tool === 'Bash')
|
|
75
|
+
const cheap = bash.filter((c) => CHEAP.has(c.shape))
|
|
76
|
+
const viaClassifier = cheap.filter((c) => c.perm === 'classifier').map(callMs)
|
|
77
|
+
const viaRule = cheap.filter((c) => c.perm === 'rule').map(callMs)
|
|
78
|
+
const classified = bash.filter((c) => c.perm === 'classifier').length
|
|
79
|
+
if (viaClassifier.length >= 20 && viaRule.length >= 20) {
|
|
80
|
+
const c = p50Of(viaClassifier)
|
|
81
|
+
const r = p50Of(viaRule)
|
|
82
|
+
if (c > r) out.push({ id: 'classifier', ms: (c - r) * classified, estimate: true, text: `the auto mode classifier checked ${classified.toLocaleString('en-US')} Bash calls before they ran: a cheap command (grep, sed, cat, ls) takes p50 ${fmt(c)} through it, ${fmt(r)} when a rule allows it. fix: allow rules for the read only commands agents repeat` })
|
|
83
|
+
}
|
|
84
|
+
const edits = bash.filter((c) => isEdit(c.shape))
|
|
85
|
+
const editTool = calls.filter((c) => c.tool === 'Edit').map(callMs)
|
|
86
|
+
if (edits.length > 0) {
|
|
87
|
+
const ms = edits.map(callMs)
|
|
88
|
+
const bashP50 = p50Of(ms)
|
|
89
|
+
const toolP50 = editTool.length >= 20 ? p50Of(editTool) : null
|
|
90
|
+
const saved = toolP50 == null || toolP50 >= bashP50 ? '' : `, about ${fmt((bashP50 - toolP50) * edits.length)} back at the Edit tool's p50 ${fmt(toolP50)}`
|
|
91
|
+
out.push({ id: 'bash-edits', ms: ms.reduce((a, b) => a + b, 0), estimate: false, text: `${edits.length.toLocaleString('en-US')} file edits through Bash (python3 heredocs, sed -i, perl -i, cat >), p50 ${fmt(bashP50)}${saved}` })
|
|
92
|
+
}
|
|
93
|
+
const polls = bash.filter((c) => isPolling(c.shape))
|
|
94
|
+
if (polls.length > 0) {
|
|
95
|
+
const timeouts = polls.filter((c) => c.outcome === 'timeout').length
|
|
96
|
+
const top = shapes.filter((s) => isPolling(s.shape)).slice(0, 3).map((s) => `${s.shape} ${fmt(s.ms)}`).join(', ')
|
|
97
|
+
out.push({ id: 'polling', ms: polls.map(callMs).reduce((a, b) => a + b, 0), estimate: false, text: `${polls.length.toLocaleString('en-US')} polling waits (until and while loops, sleep), ${timeouts} hit the Bash timeout: ${top}` })
|
|
98
|
+
}
|
|
99
|
+
const failed = bash.filter((c) => c.outcome != null && FAILED.has(c.outcome))
|
|
100
|
+
if (failed.length > 0) {
|
|
101
|
+
const rerun = shapes.reduce((a, s) => a + s.retried, 0)
|
|
102
|
+
const blind = shapes.reduce((a, s) => a + s.blind, 0)
|
|
103
|
+
out.push({ id: 'failed', ms: failed.map(callMs).reduce((a, b) => a + b, 0), estimate: false, text: `${failed.length.toLocaleString('en-US')} failed Bash runs (non zero exit, error, timeout), ${rerun} run again, ${blind} of them unchanged` })
|
|
104
|
+
}
|
|
105
|
+
if (wall.approval > 0) {
|
|
106
|
+
const n = calls.filter((c) => c.approval).length
|
|
107
|
+
const top = [...shapes].filter((s) => s.approvals > 0).sort((a, b) => b.approvalMs - a.approvalMs).slice(0, 3).map((s) => `${s.shape} ${fmt(s.approvalMs)}`).join(', ')
|
|
108
|
+
out.push({ id: 'approval', ms: wall.approval, estimate: false, text: `waiting for you to allow ${n} call${n === 1 ? '' : 's'}: ${top}` })
|
|
109
|
+
}
|
|
110
|
+
return out.sort((a, b) => b.ms - a.ms)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type Group = { key: string; session: string; agent: string; files: FileRow[]; stream: Stream; closed: Closed[] }
|
|
114
|
+
|
|
115
|
+
/** one stream per session and agent, every copy of it merged */
|
|
116
|
+
export function mergeStreams(loaded: Loaded): Group[] {
|
|
117
|
+
const groups = new Map<string, Group>()
|
|
118
|
+
for (const f of loaded.files) {
|
|
119
|
+
const key = `${f.session}/${f.agent}`
|
|
120
|
+
const g = groups.get(key) ?? { key, session: f.session, agent: f.agent, files: [], stream: { calls: [], marks: [] }, closed: [] }
|
|
121
|
+
g.files.push(f)
|
|
122
|
+
groups.set(key, g)
|
|
123
|
+
}
|
|
124
|
+
for (const g of groups.values()) {
|
|
125
|
+
const calls = new Map<string, Call>()
|
|
126
|
+
const marks = new Map<string, Mark>()
|
|
127
|
+
for (const f of g.files) {
|
|
128
|
+
for (const c of loaded.calls.get(f.id) ?? []) {
|
|
129
|
+
const have = calls.get(c.id)
|
|
130
|
+
if (have == null || (have.end == null && c.end != null)) calls.set(c.id, c)
|
|
131
|
+
}
|
|
132
|
+
for (const m of loaded.marks.get(f.id) ?? []) marks.set(`${m.at}/${m.kind}`, m)
|
|
133
|
+
}
|
|
134
|
+
g.stream = { calls: [...calls.values()], marks: [...marks.values()] }
|
|
135
|
+
g.closed = closeCalls(g.stream)
|
|
136
|
+
}
|
|
137
|
+
return [...groups.values()]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function pushTo<K, V>(m: Map<K, V[]>, k: K, v: V): void {
|
|
141
|
+
const list = m.get(k)
|
|
142
|
+
if (list == null) m.set(k, [v])
|
|
143
|
+
else list.push(v)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** the k items of largest `key`, largest first, without sorting the list */
|
|
147
|
+
function topBy<T>(xs: T[], k: number, key: (x: T) => number): T[] {
|
|
148
|
+
const top: { x: T; v: number }[] = []
|
|
149
|
+
for (const x of xs) {
|
|
150
|
+
const v = key(x)
|
|
151
|
+
if (top.length === k && v <= (top[k - 1] as { v: number }).v) continue
|
|
152
|
+
let i = top.length
|
|
153
|
+
while (i > 0 && (top[i - 1] as { v: number }).v < v) i--
|
|
154
|
+
top.splice(i, 0, { x, v })
|
|
155
|
+
if (top.length > k) top.pop()
|
|
156
|
+
}
|
|
157
|
+
return top.map((t) => t.x)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const isFailed = (o: Outcome | null): boolean => o != null && FAILED.has(o)
|
|
161
|
+
|
|
162
|
+
function rowsBy(calls: { c: Closed; key: string }[], retriesByKey: Map<string, { retried: number; blind: number }>): ShapeRow[] {
|
|
163
|
+
const by = new Map<string, Closed[]>()
|
|
164
|
+
for (const { c, key } of calls) {
|
|
165
|
+
const list = by.get(key)
|
|
166
|
+
if (list == null) by.set(key, [c])
|
|
167
|
+
else list.push(c)
|
|
168
|
+
}
|
|
169
|
+
return [...by].map(([key, cs]): ShapeRow => {
|
|
170
|
+
const ms = new Float64Array(cs.length)
|
|
171
|
+
const row: ShapeRow = { shape: key, tool: cs[0]?.tool ?? '', calls: cs.length, ms: 0, approvals: 0, approvalMs: 0, p50: 0, p95: 0, max: 0, failed: 0, timeouts: 0, blocked: 0, retried: 0, blind: 0, failedMs: 0 }
|
|
172
|
+
cs.forEach((c, i) => {
|
|
173
|
+
const d = callMs(c)
|
|
174
|
+
ms[i] = d
|
|
175
|
+
row.ms += d
|
|
176
|
+
if (c.approval) {
|
|
177
|
+
row.approvals++
|
|
178
|
+
row.approvalMs += d
|
|
179
|
+
}
|
|
180
|
+
if (isFailed(c.outcome)) {
|
|
181
|
+
row.failed++
|
|
182
|
+
row.failedMs += d
|
|
183
|
+
}
|
|
184
|
+
if (c.outcome === 'timeout') row.timeouts++
|
|
185
|
+
if (c.outcome === 'blocked' || c.outcome === 'denied') row.blocked++
|
|
186
|
+
})
|
|
187
|
+
ms.sort()
|
|
188
|
+
const sorted = Array.from(ms)
|
|
189
|
+
const r = retriesByKey.get(key)
|
|
190
|
+
return { ...row, p50: quantileSorted(sorted, 0.5), p95: quantileSorted(sorted, 0.95), max: sorted[sorted.length - 1] ?? 0, retried: r?.retried ?? 0, blind: r?.blind ?? 0 }
|
|
191
|
+
}).sort((a, b) => b.ms - a.ms || b.calls - a.calls)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function buildReport(loaded: Loaded, q: { repos: string[]; from: number | null; to: number; repoOf: (project: string) => string }, mask: Masker): TimeReport {
|
|
195
|
+
const from = q.from ?? -Infinity
|
|
196
|
+
const groups = mergeStreams(loaded)
|
|
197
|
+
const accounts = new Set<string>()
|
|
198
|
+
const wall = emptyBreakdown()
|
|
199
|
+
let subagentWork = 0
|
|
200
|
+
const all: { c: Closed; g: Group }[] = []
|
|
201
|
+
const retryShape = new Map<string, { retried: number; blind: number }>()
|
|
202
|
+
const sessionRows: SessionRow[] = []
|
|
203
|
+
const subsBySession = new Map<string, number>()
|
|
204
|
+
for (const g of groups) if (g.agent !== '') subsBySession.set(g.session, (subsBySession.get(g.session) ?? 0) + 1)
|
|
205
|
+
const promptsByFile = new Map<number, Loaded['prompts']>()
|
|
206
|
+
for (const p of loaded.prompts) pushTo(promptsByFile, p.file_id, p)
|
|
207
|
+
const mainTurns = emptyTurns()
|
|
208
|
+
const subTurns = emptyTurns()
|
|
209
|
+
const subWork = emptyBreakdown()
|
|
210
|
+
const runMs: number[] = []
|
|
211
|
+
const runCalls: number[] = []
|
|
212
|
+
const turnRows: TurnRow[] = []
|
|
213
|
+
const readCosts = new Map<string, ReadCost>()
|
|
214
|
+
|
|
215
|
+
for (const g of groups) {
|
|
216
|
+
for (const f of g.files) accounts.add(f.account)
|
|
217
|
+
const turnModel = new Float64Array(g.closed.length)
|
|
218
|
+
const b = wallBreakdown(g.stream, from, q.to, IDLE_MS, g.closed, turnModel)
|
|
219
|
+
const t = turnsOf(g.closed, b.model, turnModel, readCosts)
|
|
220
|
+
const work = b.model + b.tools + b.approval + b.subagent + b.background
|
|
221
|
+
if (t.calls > 0) turnRows.push({ session: g.session, agent: g.agent, account: g.files[0]?.account ?? '', start: g.closed[0]?.start ?? 0, work: b.model + b.tools + b.approval, wall: work, turns: t })
|
|
222
|
+
if (g.agent === '') addTurns(mainTurns, t)
|
|
223
|
+
else {
|
|
224
|
+
addTurns(subTurns, t)
|
|
225
|
+
addBreakdown(subWork, b)
|
|
226
|
+
runMs.push(work)
|
|
227
|
+
runCalls.push(t.calls)
|
|
228
|
+
}
|
|
229
|
+
if (g.agent === '') {
|
|
230
|
+
addBreakdown(wall, b)
|
|
231
|
+
const firstPrompt = g.files.flatMap((f) => promptsByFile.get(f.id) ?? []).sort((a, b2) => a.at - b2.at)[0]
|
|
232
|
+
sessionRows.push({
|
|
233
|
+
session: g.session,
|
|
234
|
+
account: g.files[0]?.account ?? '',
|
|
235
|
+
repo: q.repoOf(g.files[0]?.project ?? ''),
|
|
236
|
+
start: [...g.stream.marks.map((m) => m.at), ...g.stream.calls.map((c) => c.start)].reduce((a, b) => (b < a ? b : a), Infinity),
|
|
237
|
+
wall: b,
|
|
238
|
+
calls: g.stream.calls.length,
|
|
239
|
+
subagents: subsBySession.get(g.session) ?? 0,
|
|
240
|
+
first: firstPrompt == null ? '' : mask(firstPrompt.text.replace(/\s+/g, ' ').slice(0, 70)),
|
|
241
|
+
})
|
|
242
|
+
} else subagentWork += work
|
|
243
|
+
for (const c of g.closed) all.push({ c, g })
|
|
244
|
+
for (const r of retries(g.closed)) {
|
|
245
|
+
const x = retryShape.get(r.failed.shape) ?? { retried: 0, blind: 0 }
|
|
246
|
+
x.retried++
|
|
247
|
+
if (r.blind) x.blind++
|
|
248
|
+
retryShape.set(r.failed.shape, x)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const bash = all.filter(({ c }) => c.tool === 'Bash')
|
|
253
|
+
const shapes = rowsBy(all.filter(({ c }) => c.tool !== 'Agent' && c.tool !== 'Task').map(({ c }) => ({ c, key: c.shape })), retryShape)
|
|
254
|
+
// the Bash shapes first: they are the commands; other tools stay in the table
|
|
255
|
+
shapes.sort((a, b) => b.ms - a.ms)
|
|
256
|
+
const tools = rowsBy(all.map(({ c }) => ({ c, key: c.tool })), new Map())
|
|
257
|
+
const slowest: SlowCall[] = topBy(bash, 40, ({ c }) => callMs(c))
|
|
258
|
+
.map(({ c, g }) => ({ ms: callMs(c), shape: c.shape, cmd: mask(c.cmd.replace(/\s+/g, ' ').slice(0, 120)), at: c.start, account: g.files[0]?.account ?? '', session: g.session, agent: g.agent !== '', outcome: c.outcome, open: c.open }))
|
|
259
|
+
|
|
260
|
+
// feedback: his prompts, deduped across copies, signals by rule, and for a
|
|
261
|
+
// slowness complaint what every stream of that session was running
|
|
262
|
+
const fileById = new Map(loaded.files.map((f) => [f.id, f]))
|
|
263
|
+
const groupsBySession = new Map<string, Group[]>()
|
|
264
|
+
for (const g of groups) pushTo(groupsBySession, g.session, g)
|
|
265
|
+
const counts: Record<Signal, number> = { status: 0, slow: 0, correction: 0 }
|
|
266
|
+
const rules: Record<string, number> = {}
|
|
267
|
+
const hits: Complaint[] = []
|
|
268
|
+
const typed: (PromptEntry & { file: FileRow })[] = []
|
|
269
|
+
for (const p of loaded.prompts) {
|
|
270
|
+
const f = fileById.get(p.file_id)
|
|
271
|
+
if (f == null || f.agent !== '') continue
|
|
272
|
+
typed.push({ uuid: `${p.at}/${p.origin}/${p.text}`, timestamp: new Date(p.at).toISOString(), sessionId: f.session, account: f.account, text: p.text, origin: p.origin, file: f })
|
|
273
|
+
}
|
|
274
|
+
typed.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
|
275
|
+
const kept = dedupePrompts(typed) as (PromptEntry & { file: FileRow })[]
|
|
276
|
+
const repoGroups = (session: string): Group[] => groupsBySession.get(session) ?? []
|
|
277
|
+
let sortedAll: Closed[] | null = null
|
|
278
|
+
const allSorted = (): Closed[] => (sortedAll ??= all.map(({ c }) => c).sort((a, b) => a.start - b.start))
|
|
279
|
+
for (const p of kept) {
|
|
280
|
+
const signals = signalsOf(p.text)
|
|
281
|
+
if (signals.length === 0) continue
|
|
282
|
+
for (const s of signals) {
|
|
283
|
+
counts[s.signal]++
|
|
284
|
+
rules[s.rule] = (rules[s.rule] ?? 0) + 1
|
|
285
|
+
}
|
|
286
|
+
const at = Date.parse(p.timestamp)
|
|
287
|
+
const notAgent = (c: Closed) => c.tool !== 'Agent' && c.tool !== 'Task'
|
|
288
|
+
// his session first; a fresh session had nothing running, so then every
|
|
289
|
+
// session of the repo in those minutes
|
|
290
|
+
let ran = repoGroups(p.file.session).flatMap((g) => ranBefore(g.closed, at)).filter(notAgent)
|
|
291
|
+
if (ran.length === 0) ran = ranBefore(allSorted(), at).filter(notAgent)
|
|
292
|
+
hits.push({
|
|
293
|
+
at,
|
|
294
|
+
session: p.file.session,
|
|
295
|
+
account: p.account,
|
|
296
|
+
text: mask(p.text.replace(/\s+/g, ' ').slice(0, 160)),
|
|
297
|
+
signals,
|
|
298
|
+
ran: ran.sort((a, b) => callMs(b) - callMs(a)).slice(0, 3).map((c) => ({ shape: c.shape, cmd: mask(c.cmd.replace(/\s+/g, ' ').slice(0, 80)), ms: callMs(c), open: c.open })),
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
const prompts = kept.length
|
|
302
|
+
const queued = kept.filter((p) => p.origin === 'queued').length
|
|
303
|
+
|
|
304
|
+
const mains = groups.filter((g) => g.agent === '')
|
|
305
|
+
return {
|
|
306
|
+
repos: q.repos,
|
|
307
|
+
from: q.from,
|
|
308
|
+
to: q.to,
|
|
309
|
+
accounts: [...accounts].sort(),
|
|
310
|
+
sessions: mains.length,
|
|
311
|
+
subagentRuns: groups.length - mains.length,
|
|
312
|
+
prunedSessions: mains.filter((g) => g.files.every((f) => f.gone === 1)).length,
|
|
313
|
+
wall,
|
|
314
|
+
subagentWork,
|
|
315
|
+
callCount: all.length,
|
|
316
|
+
callMs: all.reduce((a, { c }) => a + callMs(c), 0),
|
|
317
|
+
shapes,
|
|
318
|
+
tools,
|
|
319
|
+
slowest,
|
|
320
|
+
feedback: { prompts, queued, counts, rules, hits },
|
|
321
|
+
sessionRows: sessionRows.sort((a, b) => total(b.wall, false) - total(a.wall, false)),
|
|
322
|
+
turns: {
|
|
323
|
+
main: mainTurns,
|
|
324
|
+
sub: { ...subTurns, runs: runMs.length, work: subWork, runP50: quantileSorted(runMs.sort((a, b) => a - b), 0.5), runP90: quantileSorted(runMs, 0.9), callsP50: quantileSorted(runCalls.sort((a, b) => a - b), 0.5) },
|
|
325
|
+
rows: topBy(turnRows, 200, (r) => r.work),
|
|
326
|
+
reads: [...readCosts.values()].sort((a, b) => b.model - a.model),
|
|
327
|
+
},
|
|
328
|
+
insights: insightsOf(all.map(({ c }) => c), wall, shapes),
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** the wall of a breakdown; idle left out unless asked */
|
|
333
|
+
export const total = (b: Breakdown, idle: boolean): number => b.model + b.tools + b.approval + b.subagent + b.background + b.human + (idle ? b.idle : 0)
|
|
334
|
+
|
|
335
|
+
export { IDLE_MS }
|