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,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 }
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// A tool call's SHAPE: the name its time is grouped under. A Bash command
|
|
2
|
+
// becomes its program and subcommand (`git commit`, `bun test <file>`,
|
|
3
|
+
// `sk ci --only lint`), so five hundred variants group into a few dozen rows.
|
|
4
|
+
//
|
|
5
|
+
// The constraint a reader breaks first: a shape never keeps a free value (a
|
|
6
|
+
// path, a message, a hash), or every call becomes its own row again. The only
|
|
7
|
+
// value kept is `sk ci --only <check>`, a small closed set that says WHICH
|
|
8
|
+
// check is slow.
|
|
9
|
+
|
|
10
|
+
/** bumped with any rule below: the index shapes every stored call again */
|
|
11
|
+
export const SHAPE_VERSION = 4
|
|
12
|
+
|
|
13
|
+
/** programs whose first word is never a subcommand */
|
|
14
|
+
const NO_SUBCOMMAND = new Set([
|
|
15
|
+
'awk', 'cat', 'chmod', 'cp', 'curl', 'date', 'diff', 'du', 'echo', 'ffmpeg', 'ffprobe', 'file', 'find', 'grep', 'head',
|
|
16
|
+
'jq', 'kill', 'killall', 'less', 'ln', 'ls', 'lsof', 'mkdir', 'mv', 'open', 'pgrep', 'pkill', 'printf', 'ps', 'pwd', 'rg',
|
|
17
|
+
'rm', 'rmdir', 'sed', 'sleep', 'sort', 'sqlite3', 'stat', 'tail', 'tee', 'test', 'touch', 'tr', 'tree', 'uniq', 'uptime',
|
|
18
|
+
'wc', 'wget', 'which', 'xargs', 'perl', 'magick', 'convert', 'zip', 'unzip', 'tar', 'osascript', 'say', 'afplay', 'screencapture', 'sips', 'top',
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
/** programs whose shape keeps two subcommand words (`gh pr view`, `sk gp start`) */
|
|
22
|
+
const TWO_LEVELS = new Set(['sk', 'shipkit', 'gh', 'docker', 'kubectl', 'gcloud', 'wrangler', 'chi', 'comfy-ts', 'cflow', 'brew'])
|
|
23
|
+
|
|
24
|
+
/** bun's own subcommands; any other first word is a package script */
|
|
25
|
+
const BUN_BUILTINS = new Set(['test', 'run', 'x', 'install', 'i', 'add', 'remove', 'rm', 'build', 'pm', 'link', 'unlink', 'init', 'create', 'upgrade', 'lint', 'fmt', 'outdated', 'update', 'publish', 'patch', 'why', 'audit', 'exec', 'repl', 'info'])
|
|
26
|
+
|
|
27
|
+
/** a segment that only sets the scene for the next one */
|
|
28
|
+
const SETUP = new Set(['cd', 'export', 'set', 'source', '.', 'pushd', 'popd', 'unset', 'ulimit', 'shopt', 'true', ':'])
|
|
29
|
+
|
|
30
|
+
/** words that run the rest of the line as the real command */
|
|
31
|
+
const WRAPPERS: Record<string, number> = { nice: 0, time: 0, timeout: 1, command: 0, exec: 0, env: 0, sudo: 0, caffeinate: 0, stdbuf: 0, nohup: 0 }
|
|
32
|
+
|
|
33
|
+
/** flags that eat the next word, per program, before its subcommand */
|
|
34
|
+
const ARG_FLAGS: Record<string, Set<string>> = { git: new Set(['-C', '-c']), nice: new Set(['-n']), bun: new Set(['--cwd', '--filter']) }
|
|
35
|
+
|
|
36
|
+
/** shell words, quotes removed; enough of a shell to split a command line */
|
|
37
|
+
export function shellWords(segment: string): string[] {
|
|
38
|
+
const words: string[] = []
|
|
39
|
+
let cur = ''
|
|
40
|
+
let quote: string | null = null
|
|
41
|
+
let any = false
|
|
42
|
+
for (let i = 0; i < segment.length; i++) {
|
|
43
|
+
const c = segment[i] as string
|
|
44
|
+
if (quote != null) {
|
|
45
|
+
if (c === quote) quote = null
|
|
46
|
+
else if (c === '\\' && quote === '"' && i + 1 < segment.length) cur += segment[++i]
|
|
47
|
+
else cur += c
|
|
48
|
+
} else if (c === '"' || c === "'") {
|
|
49
|
+
quote = c
|
|
50
|
+
any = true
|
|
51
|
+
} else if (c === '\\' && i + 1 < segment.length) cur += segment[++i]
|
|
52
|
+
else if (/\s/.test(c)) {
|
|
53
|
+
if (cur !== '' || any) words.push(cur)
|
|
54
|
+
cur = ''
|
|
55
|
+
any = false
|
|
56
|
+
} else cur += c
|
|
57
|
+
}
|
|
58
|
+
if (cur !== '' || any) words.push(cur)
|
|
59
|
+
return words
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** a heredoc's body is data, never a command: `cat > f <<'EOF'` keeps its
|
|
63
|
+
* first line, the lines up to the marker go */
|
|
64
|
+
export function stripHeredocs(command: string): string {
|
|
65
|
+
const out: string[] = []
|
|
66
|
+
let marker: string | null = null
|
|
67
|
+
for (const line of command.split('\n')) {
|
|
68
|
+
if (marker != null) {
|
|
69
|
+
if (line.trim() === marker) marker = null
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
out.push(line)
|
|
73
|
+
const m = /<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/.exec(line)
|
|
74
|
+
if (m?.[1] != null) marker = m[1]
|
|
75
|
+
}
|
|
76
|
+
return out.join('\n')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** the statements of a command line (split on `&&`, `||`, `;` and newlines
|
|
80
|
+
* outside quotes), each one only its first pipeline part: in `grep x | head`
|
|
81
|
+
* the time is the producer's */
|
|
82
|
+
export function statements(command: string): string[] {
|
|
83
|
+
const out: string[] = []
|
|
84
|
+
let cur = ''
|
|
85
|
+
let piped = false
|
|
86
|
+
let quote: string | null = null
|
|
87
|
+
const text = stripHeredocs(command).replace(/\\\n/g, ' ')
|
|
88
|
+
const flush = (): void => {
|
|
89
|
+
out.push(cur)
|
|
90
|
+
cur = ''
|
|
91
|
+
piped = false
|
|
92
|
+
}
|
|
93
|
+
for (let i = 0; i < text.length; i++) {
|
|
94
|
+
const c = text[i] as string
|
|
95
|
+
if (quote != null) {
|
|
96
|
+
if (c === quote) quote = null
|
|
97
|
+
if (!piped) cur += c
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
if (c === '"' || c === "'") {
|
|
101
|
+
quote = c
|
|
102
|
+
if (!piped) cur += c
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
const two = text.slice(i, i + 2)
|
|
106
|
+
if (two === '&&' || two === '||') {
|
|
107
|
+
flush()
|
|
108
|
+
i++
|
|
109
|
+
} else if (c === ';' || c === '\n') flush()
|
|
110
|
+
else if (c === '|') piped = true
|
|
111
|
+
else if (!piped) cur += c
|
|
112
|
+
}
|
|
113
|
+
flush()
|
|
114
|
+
return out.map((x) => x.trim()).filter((x) => x !== '')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const isWord = (w: string): boolean => /^[a-z][a-z0-9:_-]*$/i.test(w) && !/\.[a-z0-9]+$/i.test(w)
|
|
118
|
+
const isPathLike = (w: string): boolean => w.includes('/') || /\.[a-z0-9]{1,5}$/i.test(w) || w.startsWith('~')
|
|
119
|
+
const isScript = (w: string): boolean => /\.(?:sh|bash|zsh|ts|tsx|js|mjs|cjs|py|rb|pl)$/i.test(w) || (w.includes('/') && !w.startsWith('-'))
|
|
120
|
+
const base = (w: string): string => w.replace(/\/+$/, '').split('/').pop() ?? w
|
|
121
|
+
|
|
122
|
+
/** a statement's words: redirections, a leading `X=$(` and env assignments gone */
|
|
123
|
+
function wordsOf(statement: string): string[] {
|
|
124
|
+
// `cat > f`, `cat a > b`: a file write, whatever it writes
|
|
125
|
+
if (/^cat\b[^|]*?(?<![0-9&>])>{1,2}(?!&)/.test(statement)) return ['cat', '>']
|
|
126
|
+
const w = shellWords(statement.replace(/^[A-Za-z_][A-Za-z0-9_]*=\$\(/, '').replace(/^\(+|\)+$/g, '').replace(/\s\d?>>?&?\d?\s*\S*/g, ' ').replace(/\s<\S+/g, ' '))
|
|
127
|
+
while (w.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0] as string)) w.shift()
|
|
128
|
+
return w
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** statements that cost nothing next to the one they come with: a shape skips
|
|
132
|
+
* them when a heavier statement follows (`uptime; sk ci`, `mkdir -p x && bun y.ts`) */
|
|
133
|
+
const LIGHT = new Set(['echo', 'printf', 'uptime', 'date', 'pwd', 'mkdir', 'touch', 'which', 'clear', 'rm', 'cat'])
|
|
134
|
+
|
|
135
|
+
const isLight = (w: string[]): boolean => LIGHT.has(w[0] as string) && (w[0] !== 'cat' || w.length === 1 || w[1] === '>' || w.every((x, i) => i === 0 || x.startsWith('-')))
|
|
136
|
+
|
|
137
|
+
/** a python heredoc that opens a file to write it: an edit through Bash */
|
|
138
|
+
const PY_EDIT = /\bpython3?\s+-\s*<</
|
|
139
|
+
const PY_WRITES = /open\([^)]*,\s*['"][wa]|\.write(?:_text)?\(/
|
|
140
|
+
|
|
141
|
+
/** the shape of one Bash command line */
|
|
142
|
+
export function commandShape(command: string): string {
|
|
143
|
+
const stmts = statements(command.trim()).map(wordsOf).filter((w) => w.length > 0)
|
|
144
|
+
if (stmts.length === 0) return '(empty)'
|
|
145
|
+
const shape = pick(stmts) ?? (base(stmts[0]?.[0] ?? '') || '(empty)')
|
|
146
|
+
if (shape.startsWith('python') && !shape.includes(' ') && PY_EDIT.test(command) && PY_WRITES.test(command)) return `${shape} (edit)`
|
|
147
|
+
return shape
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** the statement the time belongs to, shaped: loops by what they run */
|
|
151
|
+
function pick(stmts: string[][]): string | null {
|
|
152
|
+
let light: string[] | null = null
|
|
153
|
+
for (let i = 0; i < stmts.length; i++) {
|
|
154
|
+
const w = stmts[i] as string[]
|
|
155
|
+
const head = w[0] as string
|
|
156
|
+
if (head === 'for' || head === 'while' || head === 'until' || head === 'select') return loopShape(head, w, stmts.slice(i + 1))
|
|
157
|
+
if (head === 'if') return '(if)'
|
|
158
|
+
if (/^[A-Za-z_][A-Za-z0-9_-]*\(\)/.test(head) || head === 'function') return '(function)'
|
|
159
|
+
if (SETUP.has(head) || head === 'do' || head === 'done' || head === 'then' || head === 'fi') continue
|
|
160
|
+
if (isLight(w)) {
|
|
161
|
+
light ??= w
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
return shapeOfWords(w)
|
|
165
|
+
}
|
|
166
|
+
return light == null ? null : shapeOfWords(light)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** `(for) bun <file>`: a for loop by its body, a while or until loop by its
|
|
170
|
+
* condition (a `[ … ]` test says nothing, so by its body then) */
|
|
171
|
+
function loopShape(head: string, w: string[], after: string[][]): string {
|
|
172
|
+
const label = head === 'select' ? 'for' : head
|
|
173
|
+
const doAt = after.findIndex((x) => x[0] === 'do')
|
|
174
|
+
const body = doAt === -1 ? [] : [after[doAt]?.slice(1) ?? [], ...after.slice(doAt + 1)].filter((x) => x.length > 0)
|
|
175
|
+
const end = body.findIndex((x) => x[0] === 'done')
|
|
176
|
+
const inner = pick(end === -1 ? body : body.slice(0, end))
|
|
177
|
+
if (label === 'for') return `(for) ${inner ?? '…'}`
|
|
178
|
+
const cond = w.slice(1).filter((x) => x !== '!').map((x, i) => (i === 0 ? x.replace(/^[A-Za-z_][A-Za-z0-9_]*=\$\(/, '') : x))
|
|
179
|
+
const condShape = cond.length === 0 || cond[0] === '[' || cond[0] === '[[' || cond[0] === 'test' || cond[0] === 'true' || cond[0] === ':' ? null : shapeOfWords(cond)
|
|
180
|
+
return `(${label}) ${condShape ?? inner ?? '…'}`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function shapeOfWords(input: string[]): string {
|
|
184
|
+
let words = input
|
|
185
|
+
// unwrap `nice -n 15 timeout 60 env X=1 <cmd>`
|
|
186
|
+
for (;;) {
|
|
187
|
+
const head = words[0]
|
|
188
|
+
if (head == null || !(head in WRAPPERS)) break
|
|
189
|
+
let i = 1
|
|
190
|
+
const eats = ARG_FLAGS[head]
|
|
191
|
+
while (i < words.length && (words[i] as string).startsWith('-')) i += eats?.has(words[i] as string) ? 2 : 1
|
|
192
|
+
i += WRAPPERS[head] as number
|
|
193
|
+
while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i] as string)) i++
|
|
194
|
+
if (i >= words.length) break
|
|
195
|
+
words = words.slice(i)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (words.length === 0) return '(empty)'
|
|
199
|
+
if (isScript(words[0] as string)) return base(words[0] as string)
|
|
200
|
+
let program = base(words[0] as string)
|
|
201
|
+
let rest = words.slice(1)
|
|
202
|
+
if ((program === 'bunx' || program === 'npx' || program === 'pnpx') && rest.length > 0) {
|
|
203
|
+
const pkg = rest.findIndex((w) => !w.startsWith('-'))
|
|
204
|
+
if (pkg !== -1) {
|
|
205
|
+
program = `${program} ${base(rest[pkg] as string)}`
|
|
206
|
+
rest = rest.slice(pkg + 1)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (program.startsWith('python')) {
|
|
210
|
+
if (rest[0] === '-c' || rest[0] === '-m') return `${program} ${rest[0]}${rest[0] === '-m' && rest[1] != null ? ` ${rest[1]}` : ''}`
|
|
211
|
+
return rest.some((w) => isPathLike(w)) ? `${program} <file>` : program
|
|
212
|
+
}
|
|
213
|
+
if ((program === 'sed' || program === 'perl') && rest.some((w) => /^-[a-zA-Z]*i/.test(w) || w === '--in-place')) return `${program} -i (edit)`
|
|
214
|
+
if (program === 'cat' && rest[0] === '>') return 'cat > (write)'
|
|
215
|
+
if (NO_SUBCOMMAND.has(program)) return program
|
|
216
|
+
|
|
217
|
+
const parts = [program]
|
|
218
|
+
const eats = ARG_FLAGS[program]
|
|
219
|
+
const depth = TWO_LEVELS.has(program) || program === 'bun' || program === 'npm' || program === 'pnpm' ? 2 : 1
|
|
220
|
+
let subs = 0
|
|
221
|
+
for (let i = 0; i < rest.length; i++) {
|
|
222
|
+
const w = rest[i] as string
|
|
223
|
+
if (w.startsWith('-')) {
|
|
224
|
+
if (eats?.has(w)) i++
|
|
225
|
+
if (subs > 0) break
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
if (isWord(w) && subs < depth) {
|
|
229
|
+
parts.push(w)
|
|
230
|
+
subs++
|
|
231
|
+
// `bun test x`: one level, then the argument is what it runs.
|
|
232
|
+
// `bun run x y` and `bun x y` (a package script, often the
|
|
233
|
+
// project CLI) keep the script and its first word
|
|
234
|
+
if (program === 'bun' && subs === 1 && BUN_BUILTINS.has(w) && w !== 'run') break
|
|
235
|
+
if (program === 'bun' && subs === 2 && parts[1] === 'run' && i + 1 < rest.length && isWord(rest[i + 1] as string)) {
|
|
236
|
+
parts.push(rest[i + 1] as string)
|
|
237
|
+
break
|
|
238
|
+
}
|
|
239
|
+
if ((program === 'npm' || program === 'pnpm') && subs === 1 && w !== 'run') break
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
if (isPathLike(w) && subs === 0) parts.push('<file>')
|
|
243
|
+
break
|
|
244
|
+
}
|
|
245
|
+
if ((program === 'sk' || program === 'shipkit') && parts[1] === 'ci') {
|
|
246
|
+
const only = onlyValues(rest)
|
|
247
|
+
if (only.length > 0) return `${program} ci --only ${only.join(',')}`
|
|
248
|
+
}
|
|
249
|
+
if (program === 'bun' && parts[1] === 'test' && parts.length === 2 && rest.some((w) => isPathLike(w))) parts.push('<file>')
|
|
250
|
+
return parts.join(' ')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** the `--only` values of an `sk ci` line, sorted: `--only a --only b,c` */
|
|
254
|
+
function onlyValues(words: string[]): string[] {
|
|
255
|
+
const out: string[] = []
|
|
256
|
+
for (let i = 0; i < words.length; i++) {
|
|
257
|
+
const w = words[i] as string
|
|
258
|
+
if (w === '--only' && words[i + 1] != null) out.push(...(words[++i] as string).split(','))
|
|
259
|
+
else if (w.startsWith('--only=')) out.push(...w.slice(7).split(','))
|
|
260
|
+
}
|
|
261
|
+
return [...new Set(out.filter((x) => x !== ''))].sort()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** the shape of any tool call: Bash by its command, a sub-agent by its type,
|
|
265
|
+
* a skill by its name, everything else by the tool name */
|
|
266
|
+
export function toolShape(name: string, detail: string): string {
|
|
267
|
+
if (name === 'Bash') return commandShape(detail)
|
|
268
|
+
if (name === 'Agent' || name === 'Task') return `Agent ${detail.split(':')[0] || 'general-purpose'}`
|
|
269
|
+
if (name === 'Skill' && detail !== '') return `Skill ${detail}`
|
|
270
|
+
return name
|
|
271
|
+
}
|