rvlib-shipkit 2.1.1 → 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 +50 -0
- package/package.json +2 -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 +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Commands.ts +163 -43
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +361 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +167 -0
- package/src/cli.ts +15 -2
- 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 +12 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/reflect/prompt-corpus.ts +15 -114
- 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/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- 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 +12 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +78 -0
- package/src/trailer/build.ts +603 -0
- package/src/trailer/ffmpeg.ts +72 -0
- package/src/trailer/footage.ts +173 -0
- package/src/trailer/grammar.ts +1290 -0
- package/src/trailer/graph.ts +508 -0
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +31 -0
- package/src/trailer/look.ts +124 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +192 -0
- 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 +233 -0
- package/src/trailer/titles.ts +379 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +59 -0
- package/src/trailer/words.ts +254 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// THE transcript reader: one Claude Code jsonl line in, the events it holds out.
|
|
2
|
+
// `sk reflect` keeps the prompts, `sk time` keeps everything. One parser for
|
|
3
|
+
// both, so a harness shape fixed here is fixed for every reader.
|
|
4
|
+
//
|
|
5
|
+
// A line is one entry. An assistant entry holds ONE content block (thinking,
|
|
6
|
+
// text or tool_use), the user entry after a tool_use holds its tool_result,
|
|
7
|
+
// and the harness writes many user-role entries the human never typed (tool
|
|
8
|
+
// results, command wrappers, compaction summaries, sub-agent hand-backs).
|
|
9
|
+
// Every prompt filter below exists because one of them polluted a real pass.
|
|
10
|
+
|
|
11
|
+
export type PromptOrigin = 'typed' | 'queued'
|
|
12
|
+
|
|
13
|
+
/** how a call was allowed: by the human (`user`), by the auto mode
|
|
14
|
+
* classifier (a model call before the tool runs), by a rule or a hook, by the
|
|
15
|
+
* permission mode, or not recorded */
|
|
16
|
+
export type Perm = 'user' | 'classifier' | 'rule' | 'mode' | ''
|
|
17
|
+
|
|
18
|
+
export function permOf(d: { source?: unknown; reasonType?: unknown } | undefined): Perm {
|
|
19
|
+
if (d == null) return ''
|
|
20
|
+
if (typeof d.source === 'string' && d.source.startsWith('user')) return 'user'
|
|
21
|
+
if (d.reasonType === 'classifier') return 'classifier'
|
|
22
|
+
if (d.reasonType === 'mode') return 'mode'
|
|
23
|
+
return typeof d.source === 'string' ? 'rule' : ''
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** the harness's wrapper around a message relayed into a running agent */
|
|
27
|
+
const RELAYED = /^[^\n]{1,80}? sent (?:a new |a )?message while you were working\b/
|
|
28
|
+
|
|
29
|
+
/** how a tool call ended, read from its tool_result */
|
|
30
|
+
export type Outcome = 'ok' | 'exit' | 'timeout' | 'blocked' | 'denied' | 'interrupted' | 'error'
|
|
31
|
+
|
|
32
|
+
export type TranscriptEvent =
|
|
33
|
+
/** a prompt the human typed; `queued` = typed while the agent worked */
|
|
34
|
+
| { kind: 'prompt'; at: number; timestamp: string; uuid: string | null; sessionId: string | null; text: string; origin: PromptOrigin }
|
|
35
|
+
/** a user-role turn nobody typed that wakes the agent: a task notification,
|
|
36
|
+
* a sub-agent hand-back, a peer message, a script-driven prompt */
|
|
37
|
+
| { kind: 'wake'; at: number }
|
|
38
|
+
/** a message relayed into the agent (the coordinator, the human, another
|
|
39
|
+
* session "sent a message while you were working"): it can end a park of
|
|
40
|
+
* hours after the agent handed back */
|
|
41
|
+
| { kind: 'message'; at: number }
|
|
42
|
+
| { kind: 'use'; at: number; id: string; name: string; detail: string }
|
|
43
|
+
/** `perm`: how the call was allowed; `user` = it waited for the human, so its time is his */
|
|
44
|
+
| { kind: 'result'; at: number; id: string; outcome: Outcome; code: number | null; perm: Perm }
|
|
45
|
+
/** an assistant text block: the agent talking, which can end its turn */
|
|
46
|
+
| { kind: 'reply'; at: number }
|
|
47
|
+
|
|
48
|
+
// wrappers the harness injects as user-role entries: none of them are typed
|
|
49
|
+
const NOISE_PREFIXES = [
|
|
50
|
+
'<local-command-caveat>',
|
|
51
|
+
'<local-command-stdout>',
|
|
52
|
+
'<command-name>',
|
|
53
|
+
'<command-message>',
|
|
54
|
+
'<system-reminder>',
|
|
55
|
+
'<task-notification>',
|
|
56
|
+
'Caveat: The messages below',
|
|
57
|
+
'[Request interrupted',
|
|
58
|
+
'This session is being continued from a previous conversation',
|
|
59
|
+
// model output parked in the queue (or wrapped as a meta entry): a
|
|
60
|
+
// sub-agent's hand-back, and a message another Claude session sent
|
|
61
|
+
'<agent-message',
|
|
62
|
+
'<cross-session-message',
|
|
63
|
+
'Another Claude session sent a message',
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
export function isTypedPrompt(text: string): boolean {
|
|
67
|
+
const trimmed = text.trim()
|
|
68
|
+
if (trimmed === '') return false
|
|
69
|
+
return !NOISE_PREFIXES.some((p) => trimmed.startsWith(p))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type Block = { type?: unknown; text?: unknown; id?: unknown; name?: unknown; input?: unknown; tool_use_id?: unknown; is_error?: unknown; content?: unknown }
|
|
73
|
+
|
|
74
|
+
export type RawEntry = {
|
|
75
|
+
type?: unknown
|
|
76
|
+
entrypoint?: unknown
|
|
77
|
+
uuid?: unknown
|
|
78
|
+
timestamp?: unknown
|
|
79
|
+
sessionId?: unknown
|
|
80
|
+
isMeta?: unknown
|
|
81
|
+
isSidechain?: unknown
|
|
82
|
+
isCompactSummary?: unknown
|
|
83
|
+
/** `peer` on a sub-agent hand-back or another session's message */
|
|
84
|
+
turnOrigin?: unknown
|
|
85
|
+
/** queue-operation entries only */
|
|
86
|
+
operation?: unknown
|
|
87
|
+
content?: unknown
|
|
88
|
+
message?: { role?: unknown; content?: unknown }
|
|
89
|
+
toolUseResult?: unknown
|
|
90
|
+
/** on a tool_result entry: who allowed the call (`config`, `user_temporary`, …) */
|
|
91
|
+
permissionDecision?: { source?: unknown; reasonType?: unknown }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const stripReminders = (text: string): string => text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
|
|
95
|
+
|
|
96
|
+
/** the user text of a user entry, reminders stripped, a slash command's typed
|
|
97
|
+
* args rescued; null when it carries no text at all (a pure tool_result turn).
|
|
98
|
+
* Content is a string for a plain prompt, an array of blocks when the message
|
|
99
|
+
* carried pastes or attachments (the text blocks in there are still his). */
|
|
100
|
+
function userText(entry: RawEntry): string | null {
|
|
101
|
+
const content = entry.message?.content
|
|
102
|
+
let text: string
|
|
103
|
+
if (typeof content === 'string') text = content
|
|
104
|
+
else if (Array.isArray(content)) {
|
|
105
|
+
const parts = (content as Block[]).filter((b) => b?.type === 'text' && typeof b?.text === 'string').map((b) => b.text as string)
|
|
106
|
+
if (parts.length === 0) return null
|
|
107
|
+
text = parts.join('\n')
|
|
108
|
+
} else return null
|
|
109
|
+
text = stripReminders(text)
|
|
110
|
+
// a slash-command entry is a harness WRAPPER around typed text:
|
|
111
|
+
// <command-name>/cmd</command-name><command-args>THE TEXT</command-args>.
|
|
112
|
+
// Dropping the whole entry throws away real instructions and repro pastes.
|
|
113
|
+
const args = text.match(/<command-args>([\s\S]*?)<\/command-args>/)
|
|
114
|
+
if (args?.[1] != null) {
|
|
115
|
+
const body = args[1].trim()
|
|
116
|
+
if (body === '') return ''
|
|
117
|
+
const name = text.match(/<command-name>([\s\S]*?)<\/command-name>/)
|
|
118
|
+
text = name?.[1] == null ? body : `${name[1].trim()} ${body}`
|
|
119
|
+
}
|
|
120
|
+
return text
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** the typed prompt out of a parsed user entry, or null when he did not type
|
|
124
|
+
* it. isSidechain = a subagent prompt the AGENT wrote; isMeta and
|
|
125
|
+
* isCompactSummary = harness-generated; entrypoint sdk-cli = a script drove it */
|
|
126
|
+
export function typedPromptOf(entry: RawEntry): string | null {
|
|
127
|
+
if (entry.type !== 'user') return null
|
|
128
|
+
if (entry.isMeta === true || entry.isSidechain === true || entry.isCompactSummary === true) return null
|
|
129
|
+
if (entry.entrypoint === 'sdk-cli') return null
|
|
130
|
+
if (entry.message?.role !== 'user') return null
|
|
131
|
+
const text = userText(entry)
|
|
132
|
+
if (text == null || !isTypedPrompt(text)) return null
|
|
133
|
+
return text
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A prompt he typed while the agent was still working: the harness parks it as
|
|
137
|
+
* `{type:'queue-operation', operation:'enqueue', content}` and, if the run ends
|
|
138
|
+
* first, it may NEVER become a user message. Skipping these loses exactly the
|
|
139
|
+
* mid-flight corrections and standing conventions a retrospective is for */
|
|
140
|
+
export function queuedPromptOf(entry: RawEntry): string | null {
|
|
141
|
+
if (entry.type !== 'queue-operation' || entry.operation !== 'enqueue') return null
|
|
142
|
+
if (typeof entry.content !== 'string') return null
|
|
143
|
+
const text = stripReminders(entry.content)
|
|
144
|
+
return isTypedPrompt(text) ? text : null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const BASH_EXIT = /^Exit code (\d+)/
|
|
148
|
+
const BLOCKED = [/^PreToolUse:/, /^Permission to use /, /^Permission for this action was denied/, /auto mode classifier/i, /hook error/i]
|
|
149
|
+
|
|
150
|
+
/** how a tool call ended. The harness marks every failure `is_error`, and the
|
|
151
|
+
* text says which kind: a non zero exit, a hook or permission block, the
|
|
152
|
+
* human saying no, a timeout (now moved to the background), anything else */
|
|
153
|
+
export function outcomeOf(r: { isError: boolean; text: string; extra: unknown }): { outcome: Outcome; code: number | null } {
|
|
154
|
+
const extra = r.extra != null && typeof r.extra === 'object' ? (r.extra as Record<string, unknown>) : {}
|
|
155
|
+
if (typeof extra.timedOutAfterMs === 'number' || /^Command timed out after/.test(r.text)) return { outcome: 'timeout', code: null }
|
|
156
|
+
if (extra.interrupted === true || r.text.startsWith('[Request interrupted')) return { outcome: 'interrupted', code: null }
|
|
157
|
+
if (!r.isError) return { outcome: 'ok', code: null }
|
|
158
|
+
const exit = BASH_EXIT.exec(r.text)
|
|
159
|
+
if (exit?.[1] != null) return { outcome: 'exit', code: Number(exit[1]) }
|
|
160
|
+
if (/^The user doesn't want to proceed/.test(r.text) || /^The user (rejected|denied)/.test(r.text)) return { outcome: 'denied', code: null }
|
|
161
|
+
if (BLOCKED.some((re) => re.test(r.text))) return { outcome: 'blocked', code: null }
|
|
162
|
+
return { outcome: 'error', code: null }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const DETAIL_MAX = 400
|
|
166
|
+
const clip = (s: string): string => (s.length > DETAIL_MAX ? s.slice(0, DETAIL_MAX) : s)
|
|
167
|
+
|
|
168
|
+
/** a long command keeps its head and its tail: what runs after a heredoc
|
|
169
|
+
* (`cat > x.ts <<EOF … EOF` then `bun x.ts`) is at the end */
|
|
170
|
+
const clipMiddle = (s: string): string => (s.length > 2 * DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n${s.slice(-DETAIL_MAX)}` : s)
|
|
171
|
+
|
|
172
|
+
/** the one input field that says what a call did: the Bash command, the
|
|
173
|
+
* sub-agent type and its description, the skill, the url */
|
|
174
|
+
export function detailOf(name: string, input: unknown): string {
|
|
175
|
+
const i = input != null && typeof input === 'object' ? (input as Record<string, unknown>) : {}
|
|
176
|
+
const str = (k: string): string => (typeof i[k] === 'string' ? (i[k] as string) : '')
|
|
177
|
+
if (name === 'Bash') return clipMiddle(str('command'))
|
|
178
|
+
if (name === 'Agent' || name === 'Task') return clip(`${str('subagent_type') || 'general-purpose'}: ${str('description')}`)
|
|
179
|
+
if (name === 'Skill') return clip(str('skill'))
|
|
180
|
+
if (name === 'WebFetch') return clip(str('url'))
|
|
181
|
+
if (name === 'Monitor' || name === 'TaskOutput' || name === 'TaskStop') return clip(str('description') || str('task_id'))
|
|
182
|
+
return ''
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const resultText = (content: unknown): string => {
|
|
186
|
+
if (typeof content === 'string') return content
|
|
187
|
+
if (Array.isArray(content)) {
|
|
188
|
+
const first = (content as Block[]).find((b) => b?.type === 'text' && typeof b.text === 'string')
|
|
189
|
+
return first == null ? '' : (first.text as string)
|
|
190
|
+
}
|
|
191
|
+
return ''
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// the entry types that carry nothing a reader wants and make most of the
|
|
195
|
+
// bytes (api-request alone is about 80 percent of a long session): a line
|
|
196
|
+
// that starts with one of these is skipped before JSON.parse
|
|
197
|
+
const KEPT_TYPE_PREFIXES = ['{"type":"user"', '{"type":"assistant"', '{"type":"queue-operation"']
|
|
198
|
+
|
|
199
|
+
/** every event one transcript line holds; [] for anything else, a truncated
|
|
200
|
+
* tail line of a live session included */
|
|
201
|
+
export function readTranscriptLine(line: string): TranscriptEvent[] {
|
|
202
|
+
if (line.startsWith('{"type":"') && !KEPT_TYPE_PREFIXES.some((p) => line.startsWith(p))) return []
|
|
203
|
+
let parsed: unknown
|
|
204
|
+
try {
|
|
205
|
+
parsed = JSON.parse(line)
|
|
206
|
+
} catch {
|
|
207
|
+
return []
|
|
208
|
+
}
|
|
209
|
+
if (parsed == null || typeof parsed !== 'object') return []
|
|
210
|
+
return eventsOf(parsed as RawEntry)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function eventsOf(entry: RawEntry): TranscriptEvent[] {
|
|
214
|
+
const timestamp = typeof entry.timestamp === 'string' ? entry.timestamp : ''
|
|
215
|
+
const at = Date.parse(timestamp)
|
|
216
|
+
const uuid = typeof entry.uuid === 'string' ? entry.uuid : null
|
|
217
|
+
const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId : null
|
|
218
|
+
const prompt = (text: string, origin: PromptOrigin): TranscriptEvent => ({ kind: 'prompt', at, timestamp, uuid, sessionId, text, origin })
|
|
219
|
+
|
|
220
|
+
if (entry.type === 'queue-operation') {
|
|
221
|
+
const q = queuedPromptOf(entry)
|
|
222
|
+
return q == null ? [] : [prompt(q, 'queued')]
|
|
223
|
+
}
|
|
224
|
+
if (Number.isNaN(at) && entry.type !== 'user') return []
|
|
225
|
+
if (entry.type === 'assistant') {
|
|
226
|
+
const blocks = Array.isArray(entry.message?.content) ? (entry.message.content as Block[]) : []
|
|
227
|
+
const out: TranscriptEvent[] = []
|
|
228
|
+
for (const b of blocks) {
|
|
229
|
+
if (b?.type === 'tool_use' && typeof b.id === 'string' && typeof b.name === 'string') out.push({ kind: 'use', at, id: b.id, name: b.name, detail: detailOf(b.name, b.input) })
|
|
230
|
+
else if (b?.type === 'text') out.push({ kind: 'reply', at })
|
|
231
|
+
}
|
|
232
|
+
return out
|
|
233
|
+
}
|
|
234
|
+
if (entry.type !== 'user') return []
|
|
235
|
+
const out: TranscriptEvent[] = []
|
|
236
|
+
const content = entry.message?.content
|
|
237
|
+
if (Array.isArray(content)) {
|
|
238
|
+
const perm = permOf(entry.permissionDecision)
|
|
239
|
+
for (const b of content as Block[]) {
|
|
240
|
+
if (b?.type !== 'tool_result' || typeof b.tool_use_id !== 'string' || Number.isNaN(at)) continue
|
|
241
|
+
const o = outcomeOf({ isError: b.is_error === true, text: resultText(b.content), extra: entry.toolUseResult })
|
|
242
|
+
out.push({ kind: 'result', at, id: b.tool_use_id, ...o, perm })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const typed = typedPromptOf(entry)
|
|
246
|
+
if (typed != null) out.push(prompt(typed, 'typed'))
|
|
247
|
+
else if (out.length === 0 && !Number.isNaN(at) && entry.isMeta === true && RELAYED.test(userText(entry) ?? '')) out.push({ kind: 'message', at })
|
|
248
|
+
// a peer message (a sub-agent hand-back, another session's message) is written isMeta, yet it wakes the agent
|
|
249
|
+
else if (out.length === 0 && !Number.isNaN(at) && (entry.isMeta !== true || entry.turnOrigin === 'peer') && entry.isCompactSummary !== true) {
|
|
250
|
+
const text = userText(entry)
|
|
251
|
+
if (text != null && text !== '') out.push({ kind: 'wake', at })
|
|
252
|
+
}
|
|
253
|
+
return out
|
|
254
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// a card's before and after as a looping animation to send (wiki build/shipkit/review.md, Verbs on the store): the one
|
|
2
|
+
// function behind the hub's « animation » and `sk review anim`. One cycle is drawn by ffmpeg's own filters (no per pixel
|
|
3
|
+
// expression: a card renders in a few seconds), an mp4 then repeats it with no second encode
|
|
4
|
+
import { createCanvas, GlobalFonts } from '@napi-rs/canvas'
|
|
5
|
+
import { existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { basename, join, resolve } from 'node:path'
|
|
7
|
+
import { ffmpeg } from '../trailer/ffmpeg.ts'
|
|
8
|
+
import { sizeOf } from './media.ts'
|
|
9
|
+
import { isGallery, isPair } from './model.ts'
|
|
10
|
+
import { imageFile, readItem } from './store.ts'
|
|
11
|
+
|
|
12
|
+
export const ANIM_MODES = ['cut', 'fade', 'wipe'] as const
|
|
13
|
+
export type AnimMode = (typeof ANIM_MODES)[number]
|
|
14
|
+
export const ANIM_FORMATS = ['mp4', 'gif'] as const
|
|
15
|
+
export type AnimFormat = (typeof ANIM_FORMATS)[number]
|
|
16
|
+
/** the corner labels' language, or none */
|
|
17
|
+
export const ANIM_LABELS = ['en', 'fr', 'none'] as const
|
|
18
|
+
export type AnimLabels = (typeof ANIM_LABELS)[number]
|
|
19
|
+
const LABEL_WORDS: Record<Exclude<AnimLabels, 'none'>, [string, string]> = { en: ['before', 'after'], fr: ['avant', 'après'] }
|
|
20
|
+
|
|
21
|
+
export interface AnimAsk {
|
|
22
|
+
/** the view's index on the card, 0 the first */
|
|
23
|
+
view: number
|
|
24
|
+
mode: AnimMode
|
|
25
|
+
format: AnimFormat
|
|
26
|
+
labels: AnimLabels
|
|
27
|
+
/** a font file the labels are drawn in; the system sans serif without one */
|
|
28
|
+
font?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Anim {
|
|
32
|
+
file: string
|
|
33
|
+
/** the name it is saved under: the card's title in ascii, the view past the first, the mode */
|
|
34
|
+
name: string
|
|
35
|
+
/** 0 when the cached file served */
|
|
36
|
+
ms: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** bump when a mode's look changes: every cached animation draws again */
|
|
40
|
+
const LOOK = 1
|
|
41
|
+
|
|
42
|
+
/** seconds: a hold on one image, the cross fade, the wipe */
|
|
43
|
+
const HOLD = 1.2
|
|
44
|
+
const FADE = 0.5
|
|
45
|
+
const WIPE_HOLD = 1.0
|
|
46
|
+
const WIPE = 1.2
|
|
47
|
+
const LOOPS = 3
|
|
48
|
+
const MP4 = { fps: 30, maxW: 1920, crf: 20 }
|
|
49
|
+
/** a gif stays a few MB: at most this wide, fewer frames (20 a second: a whole 5 cs each) */
|
|
50
|
+
const GIF = { fps: 20, maxW: 960 }
|
|
51
|
+
|
|
52
|
+
/** the cycle's length in seconds */
|
|
53
|
+
const cycleOf = (mode: AnimMode): number => (mode === 'cut' ? 2 * HOLD : mode === 'fade' ? 2 * (HOLD + FADE) : 2 * (WIPE_HOLD + WIPE))
|
|
54
|
+
|
|
55
|
+
export const isAnimMode = (s: unknown): s is AnimMode => ANIM_MODES.includes(s as AnimMode)
|
|
56
|
+
export const isAnimFormat = (s: unknown): s is AnimFormat => ANIM_FORMATS.includes(s as AnimFormat)
|
|
57
|
+
export const isAnimLabels = (s: unknown): s is AnimLabels => ANIM_LABELS.includes(s as AnimLabels)
|
|
58
|
+
|
|
59
|
+
/** the title as a file name: ascii, lowercase, words joined by a hyphen */
|
|
60
|
+
export const asciiName = (s: string, max = 60): string =>
|
|
61
|
+
s
|
|
62
|
+
.normalize('NFKD')
|
|
63
|
+
.replace(/[^\x20-\x7e]/g, '')
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
66
|
+
.slice(0, max)
|
|
67
|
+
.replace(/^-+|-+$/g, '') || 'before-after'
|
|
68
|
+
|
|
69
|
+
const fonts = new Set<string>()
|
|
70
|
+
/** one corner label, cream on a soft dark pill, as a png of its own size */
|
|
71
|
+
function labelPng(text: string, px: number, out: string, font: string | undefined): void {
|
|
72
|
+
let face = 'sans-serif'
|
|
73
|
+
if (font) {
|
|
74
|
+
if (!fonts.has(font)) {
|
|
75
|
+
if (!existsSync(font)) throw new Error(`anim.font ${font}: no such file`)
|
|
76
|
+
GlobalFonts.registerFromPath(font, 'ReviewAnim')
|
|
77
|
+
fonts.add(font)
|
|
78
|
+
}
|
|
79
|
+
face = 'ReviewAnim'
|
|
80
|
+
}
|
|
81
|
+
const probe = createCanvas(4, 4).getContext('2d')
|
|
82
|
+
probe.font = `800 ${px}px ${face}`
|
|
83
|
+
const padX = Math.round(px * 0.7)
|
|
84
|
+
const padY = Math.round(px * 0.32)
|
|
85
|
+
const w = Math.ceil(probe.measureText(text).width) + 2 * padX
|
|
86
|
+
const h = Math.round(px * 1.25) + 2 * padY
|
|
87
|
+
const c = createCanvas(w, h)
|
|
88
|
+
const g = c.getContext('2d')
|
|
89
|
+
g.fillStyle = 'rgba(42, 27, 15, 0.62)'
|
|
90
|
+
g.beginPath()
|
|
91
|
+
g.roundRect(0, 0, w, h, h / 2)
|
|
92
|
+
g.fill()
|
|
93
|
+
g.font = `800 ${px}px ${face}`
|
|
94
|
+
g.fillStyle = '#FCEFD4'
|
|
95
|
+
g.textAlign = 'center'
|
|
96
|
+
g.textBaseline = 'middle'
|
|
97
|
+
g.fillText(text, w / 2, h / 2 + px * 0.04)
|
|
98
|
+
writeFileSync(out, c.toBuffer('image/png'))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** the share of the after shown at time `t` of a wipe, as an ffmpeg expression: hold, an eased wipe, hold, back */
|
|
102
|
+
function shownExpr(): string {
|
|
103
|
+
const u = `mod(t,${cycleOf('wipe')})`
|
|
104
|
+
const ease = (v: string): string => `(${v})*(${v})*(3-2*(${v}))`
|
|
105
|
+
const a = WIPE_HOLD
|
|
106
|
+
const b = a + WIPE
|
|
107
|
+
const c = b + WIPE_HOLD
|
|
108
|
+
return `if(lt(${u},${a}),0,if(lt(${u},${b}),${ease(`(${u}-${a})/${WIPE}`)},if(lt(${u},${c}),1,1-${ease(`(${u}-${c})/${WIPE}`)})))`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** the filter graph of one cycle: [0] before and [1] after scaled, labelled ([2] and [3]), then the mode, out [v]; `pix`
|
|
112
|
+
* the frames' format through the mode: the mp4's own yuv420p (half the work of rgb at 1080p), rgb for a gif's palette */
|
|
113
|
+
function graph(mode: AnimMode, w: number, h: number, fps: number, labels: boolean, pix: 'yuv420p' | 'gbrp'): string {
|
|
114
|
+
const m = Math.round(w * 0.018)
|
|
115
|
+
const prep = (i: number, label: number, at: string, out: string): string =>
|
|
116
|
+
`[${i}:v]scale=${w}:${h}:flags=lanczos,setsar=1,format=rgba${labels ? `[p${i}];[p${i}][${label}:v]overlay=${at}` : ''},format=${pix}[${out}]`
|
|
117
|
+
// a wipe's after comes in from the left, so its label takes the top left and the before's the bottom right
|
|
118
|
+
const [early, late] = [`${m}:${m}`, `W-w-${m}:H-h-${m}`]
|
|
119
|
+
const parts = mode === 'wipe' ? [prep(0, 2, late, 'b'), prep(1, 3, early, 'a')] : [prep(0, 2, early, 'b'), prep(1, 3, late, 'a')]
|
|
120
|
+
const cut = (from: string, d: number, out: string): string => `[${from}]trim=duration=${d},setpts=PTS-STARTPTS[${out}]`
|
|
121
|
+
if (mode === 'cut') {
|
|
122
|
+
parts.push(cut('b', HOLD, 'x0'), cut('a', HOLD, 'x1'), '[x0][x1]concat=n=2:v=1:a=0[v]')
|
|
123
|
+
} else if (mode === 'fade') {
|
|
124
|
+
parts.push(
|
|
125
|
+
'[b]split[b1][b2]',
|
|
126
|
+
cut('b1', HOLD + FADE, 'x0'),
|
|
127
|
+
cut('a', 2 * FADE + HOLD, 'x1'),
|
|
128
|
+
cut('b2', FADE, 'x2'),
|
|
129
|
+
`[x0][x1]xfade=transition=fade:duration=${FADE}:offset=${HOLD}[y]`,
|
|
130
|
+
`[y][x2]xfade=transition=fade:duration=${FADE}:offset=${2 * HOLD + FADE}[v]`,
|
|
131
|
+
)
|
|
132
|
+
} else {
|
|
133
|
+
// the before keeps the right of the seam: a mask black then white, twice as wide, slid by a crop
|
|
134
|
+
const d = cycleOf('wipe')
|
|
135
|
+
const shown = shownExpr()
|
|
136
|
+
const seam = `${w}*${shown}`
|
|
137
|
+
const line = Math.max(2, Math.round(w / 640))
|
|
138
|
+
const glow = line + 4
|
|
139
|
+
// the line only while the seam moves: off screen on a hold
|
|
140
|
+
const at = (lw: number): string => `if(gt(${shown},0)*lt(${shown},1),${seam}-${lw / 2},-100)`
|
|
141
|
+
parts.push(
|
|
142
|
+
`color=c=white:s=${w}x${h}:r=${fps}:d=${d}[mw]`,
|
|
143
|
+
`color=c=black:s=${w}x${h}:r=${fps}:d=${d}[mb]`,
|
|
144
|
+
`[mb][mw]hstack,format=gray,crop=w=${w}:h=${h}:x='${w}-(${seam})':y=0[mask]`,
|
|
145
|
+
'[b][mask]alphamerge[ba]',
|
|
146
|
+
`[a][ba]overlay=0:0:shortest=1[c0]`,
|
|
147
|
+
`color=c=black@0.28:s=${glow}x${h}:r=${fps}:d=${d},format=rgba[g]`,
|
|
148
|
+
`color=c=0xFFF6DA:s=${line}x${h}:r=${fps}:d=${d},format=rgba[l]`,
|
|
149
|
+
`[c0][g]overlay=x='${at(glow)}':y=0:shortest=1[c1]`,
|
|
150
|
+
`[c1][l]overlay=x='${at(line)}':y=0:shortest=1[v]`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
return parts.join(';')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const making = new Map<string, Promise<Anim>>()
|
|
157
|
+
|
|
158
|
+
/** the animation of view `ask.view` of card `id`, made once and kept under `<dir>/.anims/` (readAll skips dot names) */
|
|
159
|
+
export function pairAnimation(dir: string, id: string, ask: AnimAsk): Promise<Anim> {
|
|
160
|
+
const x = readItem(dir, id)
|
|
161
|
+
if (!x) return Promise.reject(new Error(`no card ${id}`))
|
|
162
|
+
const v = x.item.views[ask.view]
|
|
163
|
+
if (!v) return Promise.reject(new Error(`card ${id} has no view ${ask.view} (it has ${x.item.views.length})`))
|
|
164
|
+
if (isGallery(v) || !isPair(v.files)) return Promise.reject(new Error(`view ${ask.view} of ${id} is not a before and after`))
|
|
165
|
+
if (v.clip) return Promise.reject(new Error(`view ${ask.view} of ${id} is a pair of clips: no animation`))
|
|
166
|
+
if (v.audio) return Promise.reject(new Error(`view ${ask.view} of ${id} is a pair of sounds: no animation`))
|
|
167
|
+
const before = imageFile(dir, id, v.files.before)
|
|
168
|
+
const after = imageFile(dir, id, v.files.after)
|
|
169
|
+
if (!before || !after) return Promise.reject(new Error(`view ${ask.view} of ${id}: an image is missing`))
|
|
170
|
+
const viewPart = ask.view > 0 ? `-${asciiName(v.label || `view-${ask.view + 1}`, 30)}` : ''
|
|
171
|
+
const name = `${asciiName(x.item.title)}${viewPart}-${ask.mode}.${ask.format}`
|
|
172
|
+
const out = join(dir, '.anims', id, `L${LOOK}-v${ask.view}-${ask.mode}-${ask.labels}.${ask.format}`)
|
|
173
|
+
const newest = Math.max(statSync(before).mtimeMs, statSync(after).mtimeMs)
|
|
174
|
+
if (existsSync(out) && statSync(out).mtimeMs >= newest) return Promise.resolve({ file: out, name, ms: 0 })
|
|
175
|
+
const pending = making.get(out)
|
|
176
|
+
if (pending) return pending
|
|
177
|
+
const job = render(before, after, out, v.size, ask)
|
|
178
|
+
.then((ms) => ({ file: out, name, ms }))
|
|
179
|
+
.finally(() => making.delete(out))
|
|
180
|
+
making.set(out, job)
|
|
181
|
+
return job
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** the same animation from two images of one size, no card: drawn each time under `scratch`, named after the after */
|
|
185
|
+
export async function fileAnimation(before: string, after: string, ask: AnimAsk, scratch: string): Promise<Anim> {
|
|
186
|
+
const [b, a] = await Promise.all([sizeOf(before), sizeOf(after)])
|
|
187
|
+
if (b.w !== a.w || b.h !== a.h) throw new Error(`before ${b.w}x${b.h} and after ${a.w}x${a.h} differ in size`)
|
|
188
|
+
const name = `${asciiName(basename(after).replace(/\.[a-z]+$/i, ''))}-${ask.mode}.${ask.format}`
|
|
189
|
+
mkdirSync(scratch, { recursive: true })
|
|
190
|
+
const out = join(scratch, `${process.pid}-${name}`)
|
|
191
|
+
const ms = await render(resolve(before), resolve(after), out, a, ask)
|
|
192
|
+
return { file: out, name, ms }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** draws one animation into `out`; its time in ms */
|
|
196
|
+
async function render(before: string, after: string, out: string, size: { w: number; h: number }, ask: AnimAsk): Promise<number> {
|
|
197
|
+
const t0 = performance.now()
|
|
198
|
+
const spec = ask.format === 'mp4' ? MP4 : GIF
|
|
199
|
+
const w = Math.min(spec.maxW, size.w) & ~1
|
|
200
|
+
const h = Math.round((size.h * w) / size.w / 2) * 2
|
|
201
|
+
const work = `${out}.${process.pid}.tmp`
|
|
202
|
+
mkdirSync(work, { recursive: true })
|
|
203
|
+
try {
|
|
204
|
+
const d = cycleOf(ask.mode)
|
|
205
|
+
const still = (f: string): string[] => ['-loop', '1', '-framerate', String(spec.fps), '-t', String(d), '-i', f]
|
|
206
|
+
const inputs = [...still(before), ...still(after)]
|
|
207
|
+
if (ask.labels !== 'none') {
|
|
208
|
+
const px = Math.max(14, Math.round(w * 0.019))
|
|
209
|
+
const [was, now] = LABEL_WORDS[ask.labels]
|
|
210
|
+
labelPng(was, px, join(work, 'before.png'), ask.font)
|
|
211
|
+
labelPng(now, px, join(work, 'after.png'), ask.font)
|
|
212
|
+
inputs.push('-i', join(work, 'before.png'), '-i', join(work, 'after.png'))
|
|
213
|
+
}
|
|
214
|
+
const g = graph(ask.mode, w, h, spec.fps, ask.labels !== 'none', ask.format === 'mp4' ? 'yuv420p' : 'gbrp')
|
|
215
|
+
const done = join(work, `out.${ask.format}`)
|
|
216
|
+
if (ask.format === 'gif') {
|
|
217
|
+
await ffmpeg([...inputs, '-filter_complex', `${g};[v]split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=full[pal];[s1][pal]paletteuse=dither=bayer:bayer_scale=4[o]`, '-map', '[o]', '-t', String(d), '-r', String(spec.fps), '-loop', '0', done])
|
|
218
|
+
} else {
|
|
219
|
+
const one = join(work, 'one.mp4')
|
|
220
|
+
await ffmpeg([...inputs, '-filter_complex', `${g};[v]format=yuv420p[o]`, '-map', '[o]', '-t', String(d), '-r', String(spec.fps), '-an', '-c:v', 'libx264', '-crf', String(MP4.crf), '-preset', 'veryfast', '-pix_fmt', 'yuv420p', one])
|
|
221
|
+
await ffmpeg(['-stream_loop', String(LOOPS - 1), '-i', one, '-c', 'copy', '-movflags', '+faststart', done])
|
|
222
|
+
}
|
|
223
|
+
renameSync(done, out)
|
|
224
|
+
return Math.round(performance.now() - t0)
|
|
225
|
+
} finally {
|
|
226
|
+
rmSync(work, { recursive: true, force: true })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// auto rules (wiki build/shipkit/review.md, A project's parts): a rule of the repo's review unit answers on a clock, and
|
|
2
|
+
// its answer is raised as an item to a lane, open from the start. One open item a rule and key. The hub's daemon runs
|
|
3
|
+
// them (startAuto), `sk review auto` reads their logs and runs one now
|
|
4
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { stateOf, type ReviewItem } from './model.ts'
|
|
7
|
+
import { raiseItem } from './store.ts'
|
|
8
|
+
import { followedIds } from './threads.ts'
|
|
9
|
+
import type { AutoRule, Raise } from './unit.ts'
|
|
10
|
+
|
|
11
|
+
/** a `when` slower than this prints a loud line: it runs inside the daemon */
|
|
12
|
+
export const AUTO_BUDGET_MS = 50
|
|
13
|
+
|
|
14
|
+
/** what one raise came to: a new item, held by the open item of its rule and key, or a dry run's */
|
|
15
|
+
export type Outcome = { raise: Raise; key: string } & ({ raised: string } | { held: string } | { dry: true })
|
|
16
|
+
|
|
17
|
+
/** one rule's log, `.shipkit/review-auto/<id>.json`: its last run and answer, the last item it raised */
|
|
18
|
+
export interface AutoLog {
|
|
19
|
+
at: string
|
|
20
|
+
ms: number
|
|
21
|
+
answer: string
|
|
22
|
+
error?: string
|
|
23
|
+
lastRaised?: { id: string; at: string }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** the logs sit beside the store, out of its watch */
|
|
27
|
+
export const autoLogDir = (dir: string): string => join(dir, '..', 'review-auto')
|
|
28
|
+
|
|
29
|
+
export function readAutoLog(dir: string, id: string): AutoLog | null {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(join(autoLogDir(dir), `${id}.json`), 'utf8')) as AutoLog
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeAutoLog(dir: string, id: string, log: AutoLog): void {
|
|
38
|
+
const folder = autoLogDir(dir)
|
|
39
|
+
mkdirSync(folder, { recursive: true })
|
|
40
|
+
const file = join(folder, `${id}.json`)
|
|
41
|
+
writeFileSync(`${file}.tmp`, `${JSON.stringify(log, null, 2)}\n`)
|
|
42
|
+
renameSync(`${file}.tmp`, file)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function everyMs(every: string): number {
|
|
46
|
+
const m = /^(\d+(?:\.\d+)?)m$/.exec(every)
|
|
47
|
+
const n = m ? Number(m[1]) : Number.NaN
|
|
48
|
+
if (!(n > 0)) throw new Error(`every: « ${every} » is no period, give minutes like '10m'`)
|
|
49
|
+
return n * 60_000
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** the item of this rule and key that is not closed, the one that holds a new raise */
|
|
53
|
+
function openOf(items: readonly ReviewItem[], rule: string, key: string): ReviewItem | undefined {
|
|
54
|
+
const followed = followedIds(items)
|
|
55
|
+
return items.find((x) => {
|
|
56
|
+
const o = x.item.origin
|
|
57
|
+
return o.by === 'auto' && o.rule === rule && o.key === key && stateOf(x, followed.has(x.item.id)).state !== 'closed'
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const answerText = (outcomes: readonly Outcome[]): string =>
|
|
62
|
+
outcomes.length === 0 ? 'nothing' : outcomes.map((o) => ('raised' in o ? `raised ${o.raised}` : 'held' in o ? `held by ${o.held}` : `would raise « ${o.raise.title} »`)).join('; ')
|
|
63
|
+
|
|
64
|
+
/** one rule evaluated now: its raises written as items, unless an open one holds the key; `dry` writes nothing, the log
|
|
65
|
+
* included. A rule that throws is logged and rethrown */
|
|
66
|
+
export async function runRule(dir: string, rule: AutoRule, o: { items: readonly ReviewItem[]; now?: Date; dry?: boolean }): Promise<Outcome[]> {
|
|
67
|
+
const now = o.now ?? new Date()
|
|
68
|
+
const before = readAutoLog(dir, rule.id)
|
|
69
|
+
const t0 = performance.now()
|
|
70
|
+
let answer: Raise | readonly Raise[] | null
|
|
71
|
+
try {
|
|
72
|
+
answer = rule.when({ items: o.items, now })
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (!o.dry) writeAutoLog(dir, rule.id, { ...before, at: now.toISOString(), ms: performance.now() - t0, answer: 'threw', error: String(e) })
|
|
75
|
+
throw e
|
|
76
|
+
}
|
|
77
|
+
const ms = performance.now() - t0
|
|
78
|
+
if (ms > AUTO_BUDGET_MS) console.error(`FAIL: review auto: rule ${rule.id} took ${ms.toFixed(0)} ms, past ${AUTO_BUDGET_MS} ms: it runs inside the daemon`)
|
|
79
|
+
const raises: readonly Raise[] = answer === null ? [] : Array.isArray(answer) ? answer : [answer as Raise]
|
|
80
|
+
const outcomes: Outcome[] = []
|
|
81
|
+
const taken = new Map<string, string>()
|
|
82
|
+
for (const raise of raises) {
|
|
83
|
+
const key = raise.key ?? rule.id
|
|
84
|
+
const held = taken.get(key) ?? openOf(o.items, rule.id, key)?.item.id
|
|
85
|
+
if (held !== undefined) outcomes.push({ raise, key, held })
|
|
86
|
+
else if (o.dry) {
|
|
87
|
+
outcomes.push({ raise, key, dry: true })
|
|
88
|
+
taken.set(key, 'this run')
|
|
89
|
+
} else {
|
|
90
|
+
const x = await raiseItem(dir, { subject: raise.subject, origin: { by: 'auto', rule: rule.id, key, lane: raise.lane }, title: raise.title, what: raise.what, now })
|
|
91
|
+
taken.set(key, x.item.id)
|
|
92
|
+
outcomes.push({ raise, key, raised: x.item.id })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!o.dry) {
|
|
96
|
+
const raised = outcomes.flatMap((x) => ('raised' in x ? [x.raised] : []))
|
|
97
|
+
const last = raised.at(-1)
|
|
98
|
+
const lastRaised = last !== undefined ? { id: last, at: now.toISOString() } : before?.lastRaised
|
|
99
|
+
writeAutoLog(dir, rule.id, { at: now.toISOString(), ms, answer: answerText(outcomes), ...(lastRaised ? { lastRaised } : {}) })
|
|
100
|
+
}
|
|
101
|
+
return outcomes
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** when a rule runs next: once its last run is `every` old, never before `from` */
|
|
105
|
+
export const nextRun = (rule: AutoRule, log: AutoLog | null, from: number): number => Math.max(from, log ? Date.parse(log.at) + everyMs(rule.every) : from)
|
|
106
|
+
|
|
107
|
+
/** one scheduled run: what it raised printed, a rule that throws printed loud and skipped, never thrown on */
|
|
108
|
+
export async function autoTick(dir: string, rule: AutoRule, items: () => readonly ReviewItem[], say: (l: string) => void = console.log): Promise<void> {
|
|
109
|
+
try {
|
|
110
|
+
for (const o of await runRule(dir, rule, { items: items() })) if ('raised' in o) say(`review auto: ${rule.id} raised ${o.raised} to ${o.raise.lane}: ${o.raise.title}`)
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.error(`FAIL: review auto: rule ${rule.id} threw, skipped until its next run: ${String(e)}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** a schedule of a store's rules: each on its own timer, never holding the process open, the first run once its last is
|
|
117
|
+
* `every` old. `stop` clears every timer (the unit changed, the hub closed) */
|
|
118
|
+
export function startAuto(dir: string, rules: readonly AutoRule[], items: () => readonly ReviewItem[]): { stop(): void } {
|
|
119
|
+
const timers = new Set<ReturnType<typeof setTimeout>>()
|
|
120
|
+
let stopped = false
|
|
121
|
+
// a start pays its boot first: no rule runs in its first seconds
|
|
122
|
+
const from = Date.now() + 5_000
|
|
123
|
+
for (const rule of rules) {
|
|
124
|
+
const tick = (): void =>
|
|
125
|
+
void autoTick(dir, rule, items).then(() => {
|
|
126
|
+
if (stopped) return
|
|
127
|
+
const t = setTimeout(tick, everyMs(rule.every))
|
|
128
|
+
t.unref()
|
|
129
|
+
timers.add(t)
|
|
130
|
+
})
|
|
131
|
+
const t = setTimeout(tick, nextRun(rule, readAutoLog(dir, rule.id), from) - Date.now())
|
|
132
|
+
t.unref()
|
|
133
|
+
timers.add(t)
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
stop(): void {
|
|
137
|
+
stopped = true
|
|
138
|
+
for (const t of timers) clearTimeout(t)
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|