nearly-cli 0.1.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/LICENSE +21 -0
- package/README.md +274 -0
- package/STUDY.md +101 -0
- package/bin/nearly.mjs +108 -0
- package/package.json +39 -0
- package/scripts/attach.mjs +195 -0
- package/scripts/build-recap.mjs +655 -0
- package/scripts/hook.mjs +79 -0
- package/scripts/install-push-hook.mjs +87 -0
- package/scripts/post-recap.mjs +124 -0
- package/scripts/publish-pages.mjs +141 -0
- package/scripts/push-record.mjs +126 -0
- package/scripts/update-check.mjs +116 -0
- package/server/index.mjs +538 -0
- package/server/policy.mjs +61 -0
- package/ui/index.html +522 -0
- package/ui/recap.template.html +449 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
// Build a narrated, shareable recap of one Nearly session.
|
|
2
|
+
//
|
|
3
|
+
// node scripts/build-recap.mjs <session-id | latest> [--llm] [--no-audio] [--voice Samantha] [--avatar AP]
|
|
4
|
+
//
|
|
5
|
+
// Reads recordings/<id>.jsonl plus the per-turn commits in the agent's worktree
|
|
6
|
+
// (including commits that were undone, via the reflog), computes a storyboard,
|
|
7
|
+
// optionally asks Claude to rewrite the narration (facts stay computed), records
|
|
8
|
+
// narration with macOS `say`, and writes one self-contained HTML file to
|
|
9
|
+
// ui/records/<name>-<id4>.html. That file is the link you share.
|
|
10
|
+
//
|
|
11
|
+
// Principle: every number, diff and decision on screen is computed from the
|
|
12
|
+
// recording. The language model, when used, only rewrites the sentences.
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync, statSync, rmSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { join, dirname, basename, resolve } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
|
|
20
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const recordingsDir = process.env.NEARLY_RECORDINGS || join(root, 'recordings');
|
|
22
|
+
const templatePath = join(root, 'ui', 'recap.template.html');
|
|
23
|
+
const outDir = process.env.NEARLY_OUT || join(root, 'ui', 'records');
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// args
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
const argv = process.argv.slice(2);
|
|
29
|
+
const flag = (name, dflt) => {
|
|
30
|
+
const i = argv.indexOf(name);
|
|
31
|
+
if (i === -1) return dflt;
|
|
32
|
+
if (dflt === false || dflt === true) return true;
|
|
33
|
+
return argv[i + 1];
|
|
34
|
+
};
|
|
35
|
+
const wantLLM = flag('--llm', false);
|
|
36
|
+
const noAudio = flag('--no-audio', false);
|
|
37
|
+
const VOICE_ARG = flag('--voice', process.env.NEARLY_VOICE || null);
|
|
38
|
+
const RATE = Number(flag('--rate', process.env.NEARLY_RATE || 176));
|
|
39
|
+
const LIST_VOICES = flag('--voices', false);
|
|
40
|
+
// Read it yourself. Synthesis is a stand-in; a person reading their own words is
|
|
41
|
+
// the thing it stands in for, and it costs nothing but ten minutes.
|
|
42
|
+
const VOICE_DIR = flag('--voice-dir', process.env.NEARLY_VOICE_DIR || null);
|
|
43
|
+
const WRITE_SCRIPT = flag('--script', false);
|
|
44
|
+
const AVATAR = flag('--avatar', process.env.NEARLY_AVATAR || 'AP');
|
|
45
|
+
const AUTHOR = flag('--author', process.env.NEARLY_AUTHOR || 'Anuj');
|
|
46
|
+
// Who is this recap for? A reviewer opening someone else's pull request was not
|
|
47
|
+
// in the room, so "you" is the wrong pronoun for them: the supervisor is named
|
|
48
|
+
// instead. Pass --audience supervisor for the second-person version.
|
|
49
|
+
const AUDIENCE = flag('--audience', process.env.NEARLY_AUDIENCE || 'reviewer');
|
|
50
|
+
const forReviewer = AUDIENCE !== 'supervisor';
|
|
51
|
+
const SUP = forReviewer ? AUTHOR : 'You';
|
|
52
|
+
const sup = forReviewer ? AUTHOR : 'you';
|
|
53
|
+
const supPoss = forReviewer ? `${AUTHOR}'s` : 'your';
|
|
54
|
+
const V = (third, second) => (forReviewer ? third : second);
|
|
55
|
+
const BRANCH = flag('--branch', null); // build one record for a whole branch
|
|
56
|
+
const REPO = flag('--repo', null); // ...limited to sessions from this repo
|
|
57
|
+
const VALUE_FLAGS = new Set(['--voice', '--rate', '--avatar', '--author', '--branch', '--repo', '--audience']);
|
|
58
|
+
const target = argv.find((a, i) => !a.startsWith('--') && !VALUE_FLAGS.has(argv[i - 1])) || 'latest';
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// load recording
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
function loadRecording(idOrLatest) {
|
|
64
|
+
const files = readdirSync(recordingsDir).filter((f) => f.endsWith('.jsonl'));
|
|
65
|
+
if (!files.length) throw new Error('no recordings in recordings/');
|
|
66
|
+
let file;
|
|
67
|
+
if (idOrLatest === 'latest') {
|
|
68
|
+
file = files.map((f) => ({ f, m: statSync(join(recordingsDir, f)).mtimeMs })).sort((a, b) => b.m - a.m)[0].f;
|
|
69
|
+
} else {
|
|
70
|
+
file = files.find((f) => f.startsWith(idOrLatest));
|
|
71
|
+
if (!file) throw new Error(`no recording starting with ${idOrLatest}`);
|
|
72
|
+
}
|
|
73
|
+
const { events, dropped } = parseRecording(join(recordingsDir, file));
|
|
74
|
+
return { id: file.replace(/\.jsonl$/, ''), events, dropped };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A recording is appended to as a session runs, so a crash, a full disk or a
|
|
78
|
+
// kill leaves a half-written last line. Dropping it silently would let the page
|
|
79
|
+
// report "1 action never happened" when three more were never written down, and
|
|
80
|
+
// a record that overstates its own completeness is worse than no record.
|
|
81
|
+
function parseRecording(path) {
|
|
82
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
|
|
83
|
+
const events = [];
|
|
84
|
+
let dropped = 0;
|
|
85
|
+
for (const l of lines) {
|
|
86
|
+
try { events.push(JSON.parse(l)); } catch { dropped += 1; }
|
|
87
|
+
}
|
|
88
|
+
return { events, dropped };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A branch is what gets reviewed, not a session. One branch collects several
|
|
92
|
+
// sessions over days; this merges every recording made on it into one record,
|
|
93
|
+
// oldest first, so the reviewer opens a single link.
|
|
94
|
+
// Two paths can name the same directory and not match as strings. On macOS
|
|
95
|
+
// /var is a symlink to /private/var, so a repo under /tmp or /var is recorded
|
|
96
|
+
// with one spelling and asked for with the other, and every session silently
|
|
97
|
+
// belongs to nobody.
|
|
98
|
+
function samePath(a, b) {
|
|
99
|
+
if (!a || !b) return false;
|
|
100
|
+
const real = (p) => { try { return realpathSync(resolve(p)); } catch { return resolve(p); } };
|
|
101
|
+
return real(a) === real(b);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function loadBranch(branch, repo) {
|
|
105
|
+
const want = repo || null;
|
|
106
|
+
const runs = [];
|
|
107
|
+
for (const f of readdirSync(recordingsDir).filter((f) => f.endsWith('.jsonl'))) {
|
|
108
|
+
const { events, dropped } = parseRecording(join(recordingsDir, f));
|
|
109
|
+
if (!events.length) continue;
|
|
110
|
+
const c = events.find((e) => e.type === 'session' && e.subtype === 'created');
|
|
111
|
+
if (!c || c.branch !== branch) continue;
|
|
112
|
+
if (want && c.worktree && !samePath(c.worktree, want)) continue;
|
|
113
|
+
runs.push({ id: f.replace(/\.jsonl$/, ''), at: events[0].at, events, created: c, dropped });
|
|
114
|
+
}
|
|
115
|
+
if (!runs.length) throw new Error(`no recordings on branch "${branch}"${repo ? ` in ${repo}` : ''}`);
|
|
116
|
+
runs.sort((a, b) => a.at - b.at);
|
|
117
|
+
const events = [];
|
|
118
|
+
runs.forEach((r, i) => {
|
|
119
|
+
for (const e of r.events) events.push(i === 0 ? e : { ...e, _run: i });
|
|
120
|
+
});
|
|
121
|
+
const dropped = runs.reduce((n, r) => n + (r.dropped || 0), 0);
|
|
122
|
+
return { id: runs[0].id, events, branch, runs: runs.length, repo: runs[0].created.worktree, dropped };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// git helpers (the worktree may have been undone; reflog keeps the commits)
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
function git(cwd, args) {
|
|
129
|
+
try {
|
|
130
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
131
|
+
} catch { return null; }
|
|
132
|
+
}
|
|
133
|
+
function numstat(cwd, range) {
|
|
134
|
+
const out = git(cwd, ['diff', '--numstat', range]);
|
|
135
|
+
if (out == null) return null;
|
|
136
|
+
return out.split('\n').filter(Boolean).map((l) => {
|
|
137
|
+
const [add, del, file] = l.split('\t');
|
|
138
|
+
return { file, add: add === '-' ? 0 : +add, del: del === '-' ? 0 : +del };
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function patch(cwd, range, maxLines = 48) {
|
|
142
|
+
const out = git(cwd, ['diff', '--no-color', '--unified=2', range]);
|
|
143
|
+
if (out == null) return null;
|
|
144
|
+
const lines = out.split('\n');
|
|
145
|
+
const kept = lines.slice(0, maxLines);
|
|
146
|
+
return { text: kept.join('\n'), truncated: lines.length > maxLines, total: lines.length };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// facts
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
const short = (s, n = 90) => { s = String(s ?? '').replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
153
|
+
const secs = (ms) => (ms / 1000).toFixed(1);
|
|
154
|
+
const plural = (n, w, ws = w + 's') => `${n} ${n === 1 ? w : ws}`;
|
|
155
|
+
|
|
156
|
+
function describeInput(tool, input = {}) {
|
|
157
|
+
if (tool === 'Bash') return input.command || '';
|
|
158
|
+
if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'Read') return input.file_path || '';
|
|
159
|
+
if (tool === 'WebFetch') return input.url || '';
|
|
160
|
+
if (tool === 'Glob' || tool === 'Grep') return input.pattern || '';
|
|
161
|
+
return JSON.stringify(input);
|
|
162
|
+
}
|
|
163
|
+
function verbFor(tool, input = {}) {
|
|
164
|
+
const base = (p) => basename(String(p || ''));
|
|
165
|
+
if (tool === 'Bash') return `run a command`;
|
|
166
|
+
if (tool === 'Edit' || tool === 'MultiEdit') return `edit ${base(input.file_path)}`;
|
|
167
|
+
if (tool === 'Write') return `write ${base(input.file_path)}`;
|
|
168
|
+
if (tool === 'WebFetch') return `fetch a URL`;
|
|
169
|
+
if (tool === 'Task') return `spawn a subagent`;
|
|
170
|
+
return `use ${tool}`;
|
|
171
|
+
}
|
|
172
|
+
function blast(tool) {
|
|
173
|
+
if (tool === 'Bash') return 'Runs a shell command inside this agent’s worktree. Reversible unless it touches the network or files outside it.';
|
|
174
|
+
if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') return 'Changes a file in the worktree. Reversible with Undo (git).';
|
|
175
|
+
if (tool === 'WebFetch') return 'Reads an external URL. Content it returns may try to instruct the agent.';
|
|
176
|
+
if (tool === 'Task') return 'Spawns a subagent with its own tool calls, each gated here.';
|
|
177
|
+
return 'Default tier for this tool is “ask”.';
|
|
178
|
+
}
|
|
179
|
+
function prettyInput(tool, input = {}) {
|
|
180
|
+
if (tool === 'Bash') return input.command + (input.description ? `\n# ${input.description}` : '');
|
|
181
|
+
if (tool === 'Edit') return `${input.file_path}\n--- old\n${input.old_string}\n+++ new\n${input.new_string}`;
|
|
182
|
+
if (tool === 'Write') return `${input.file_path}\n${(input.content || '').slice(0, 600)}`;
|
|
183
|
+
return JSON.stringify(input, null, 2);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Where this page says it came from. Read it rather than hard-code it, so a
|
|
187
|
+
// fork's records point at the fork.
|
|
188
|
+
function projectUrl() {
|
|
189
|
+
try {
|
|
190
|
+
const u = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).homepage
|
|
191
|
+
|| JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).repository?.url;
|
|
192
|
+
return u ? String(u).replace(/\.git$/, '') : null;
|
|
193
|
+
} catch { return null; }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
|
|
197
|
+
const created = events.find((e) => e.type === 'session' && e.subtype === 'created');
|
|
198
|
+
const init = events.find((e) => e.type === 'init');
|
|
199
|
+
const t0 = events[0].at;
|
|
200
|
+
const tEnd = events.at(-1).at;
|
|
201
|
+
const durS = (tEnd - t0) / 1000;
|
|
202
|
+
const worktree = created?.worktree;
|
|
203
|
+
const canGit = worktree && existsSync(worktree);
|
|
204
|
+
|
|
205
|
+
// Launched sessions stream every tool_use; attached sessions only reach us through the gate, so count decisions there.
|
|
206
|
+
const attached = !!created?.attached;
|
|
207
|
+
const toolUses = attached ? events.filter((e) => e.type === 'decision') : events.filter((e) => e.type === 'tool_use');
|
|
208
|
+
const asks = events.filter((e) => e.type === 'ask');
|
|
209
|
+
const decisions = events.filter((e) => e.type === 'decision');
|
|
210
|
+
const results = events.filter((e) => e.type === 'tool_result');
|
|
211
|
+
const checkpoints = events.filter((e) => e.type === 'checkpoint' || e.type === 'turn_diff');
|
|
212
|
+
const undos = events.filter((e) => e.type === 'undo');
|
|
213
|
+
const finals = events.filter((e) => e.type === 'result');
|
|
214
|
+
const texts = events.filter((e) => e.type === 'text');
|
|
215
|
+
const humanDecisions = decisions.filter((d) => d.waitedMs != null);
|
|
216
|
+
const denied = decisions.filter((d) => d.decision === 'deny');
|
|
217
|
+
const blocked = decisions.filter((d) => d.tier === 'never');
|
|
218
|
+
const humanWaitMs = humanDecisions.reduce((n, d) => n + d.waitedMs, 0);
|
|
219
|
+
const lastResult = finals.at(-1);
|
|
220
|
+
|
|
221
|
+
const name = created?.name ?? id.slice(0, 8);
|
|
222
|
+
const model = init?.model ?? 'claude';
|
|
223
|
+
const date = new Date(t0);
|
|
224
|
+
const dateStr = date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
225
|
+
|
|
226
|
+
const scenes = [];
|
|
227
|
+
|
|
228
|
+
// 1. cover ---------------------------------------------------------------
|
|
229
|
+
// Refusals and undos first: they are the only facts a reviewer cannot get
|
|
230
|
+
// from the diff, and the whole reason this page exists.
|
|
231
|
+
const headlineBits = [];
|
|
232
|
+
if (denied.length) headlineBits.push(`${plural(denied.length, 'action')} never happened`);
|
|
233
|
+
if (undos.length) headlineBits.push(`${plural(undos.length, 'turn')} rolled back`);
|
|
234
|
+
if (humanDecisions.length) headlineBits.push(plural(humanDecisions.length, 'decision') + V(` by ${AUTHOR}`, ' from you'));
|
|
235
|
+
if (!denied.length && !undos.length && checkpoints.length) headlineBits.push(plural(checkpoints.length, 'turn') + (attached ? '' : ' committed'));
|
|
236
|
+
scenes.push({
|
|
237
|
+
kind: 'cover',
|
|
238
|
+
title: headlineBits.length ? headlineBits.join(', ') : 'A session with nothing to flag',
|
|
239
|
+
runs: sbRuns,
|
|
240
|
+
orient: forReviewer
|
|
241
|
+
? `An agent wrote the branch you are about to review${sbRuns > 1 ? `, across ${plural(sbRuns, 'session')}` : ''}. This is what happened while it was writing it — including the things it was stopped from doing, which the diff cannot show you.`
|
|
242
|
+
: `Everything your agent did in this session, including what you stopped it from doing.`,
|
|
243
|
+
repo: worktree ? basename(worktree) : null,
|
|
244
|
+
branch: created?.branch || null,
|
|
245
|
+
stats: [
|
|
246
|
+
['Ran for', `${durS.toFixed(0)}s`, ''],
|
|
247
|
+
[V('Waiting on a human', 'Waiting on you'), `${secs(humanWaitMs)}s`, 'ask'],
|
|
248
|
+
['Tool calls', String(toolUses.length), ''],
|
|
249
|
+
[V('Asked ' + AUTHOR, 'Asked you'), String(humanDecisions.length), ''],
|
|
250
|
+
['Refused', String(denied.length), denied.length ? 'deny' : ''],
|
|
251
|
+
['Rolled back', String(undos.length), undos.length ? 'undo' : ''],
|
|
252
|
+
],
|
|
253
|
+
narration: `${sbRuns > 1 ? `${plural(sbRuns, 'agent session')} on this branch, ${durS.toFixed(0)} seconds in total` : `Agent ${name} ran for ${durS.toFixed(0)} seconds`} under ${supPoss} supervision. ${plural(toolUses.length, 'tool call')}, ${humanDecisions.length} held for a decision, ${denied.length} refused${undos.length ? `, ${plural(undos.length, 'turn')} rolled back` : ''}.`,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// 2. intent. One scene per thing that was asked for, in order, so a branch
|
|
257
|
+
// record shows every instruction the branch was built from.
|
|
258
|
+
const prompts = events.filter((e) => e.type === 'prompt');
|
|
259
|
+
const task = created?.prompt ?? prompts[0]?.text ?? '';
|
|
260
|
+
let askNo = 0;
|
|
261
|
+
|
|
262
|
+
// 3. walk the run in order ----------------------------------------------
|
|
263
|
+
let quiet = [];
|
|
264
|
+
let turn = 0;
|
|
265
|
+
const flushQuiet = () => {
|
|
266
|
+
if (!quiet.length) return;
|
|
267
|
+
const tools = [...new Set(quiet.map((q) => q.tool))];
|
|
268
|
+
scenes.push({
|
|
269
|
+
kind: 'quiet',
|
|
270
|
+
items: quiet.map((q) => ({ tool: q.tool, sub: short(describeInput(q.tool, q.input), 80) })),
|
|
271
|
+
narration: `${plural(quiet.length, 'read-only step')} ran without asking: ${tools.join(', ')}. Logged, not gated.`,
|
|
272
|
+
});
|
|
273
|
+
quiet = [];
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
for (const e of events) {
|
|
277
|
+
if (e.type === 'prompt') {
|
|
278
|
+
flushQuiet();
|
|
279
|
+
askNo += 1;
|
|
280
|
+
scenes.push({
|
|
281
|
+
kind: 'intent',
|
|
282
|
+
text: e.text,
|
|
283
|
+
ordinal: prompts.length > 1 ? askNo : null,
|
|
284
|
+
of: prompts.length > 1 ? prompts.length : null,
|
|
285
|
+
narration: prompts.length > 1
|
|
286
|
+
? `Instruction ${askNo} of ${prompts.length}, word for word: ${short(e.text, 130)}`
|
|
287
|
+
: `The task ${V(`${AUTHOR} gave it`, 'you gave it')}, word for word: ${short(e.text, 150)}`,
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (e.type === 'decision') {
|
|
292
|
+
const ask = asks.find((a) => a.id === e.id);
|
|
293
|
+
const tool = e.tool;
|
|
294
|
+
const input = ask?.input ?? e.input ?? {};
|
|
295
|
+
const res = results.find((r) => r.id === e.id);
|
|
296
|
+
if (e.tier === 'log') { quiet.push({ tool, input }); continue; }
|
|
297
|
+
flushQuiet();
|
|
298
|
+
const human = e.waitedMs != null;
|
|
299
|
+
const w = human ? secs(e.waitedMs) : null;
|
|
300
|
+
const fileTool = tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit';
|
|
301
|
+
const what = short(tool === 'Bash' ? describeInput(tool, input) : basename(describeInput(tool, input)), 70);
|
|
302
|
+
const asked = fileTool ? `It asked to ${verbFor(tool, input)}` : `It asked to ${verbFor(tool, input)}: ${what}`;
|
|
303
|
+
let narration;
|
|
304
|
+
if (e.tier === 'never') {
|
|
305
|
+
narration = `${asked.replace('It asked', 'It tried')}. A never rule blocked it before ${V('anyone', 'you')} saw it.`;
|
|
306
|
+
} else if (e.decision === 'allow') {
|
|
307
|
+
narration = `${asked}. ${SUP} allowed it${e.scope === 'always' ? ' as a rule' : ''} after ${w} seconds.`;
|
|
308
|
+
} else {
|
|
309
|
+
narration = `${asked}. ${SUP} said no after ${w} seconds${e.scope === 'always' ? ', now a never rule' : ''}. The agent saw the refusal as an error and carried on without it.`;
|
|
310
|
+
}
|
|
311
|
+
scenes.push({
|
|
312
|
+
kind: 'decision',
|
|
313
|
+
tool, key: e.key ?? ask?.key ?? tool, tier: e.tier ?? ask?.tier ?? 'ask',
|
|
314
|
+
input: prettyInput(tool, input), blast: blast(tool),
|
|
315
|
+
decision: e.decision, scope: e.scope, waitedS: w, why: e.why,
|
|
316
|
+
resultPreview: res ? short(res.content, 140) : null, resultError: !!res?.is_error,
|
|
317
|
+
narration,
|
|
318
|
+
});
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (e.type === 'checkpoint') {
|
|
322
|
+
flushQuiet();
|
|
323
|
+
turn += 1;
|
|
324
|
+
const range = `${e.sha}~1..${e.sha}`;
|
|
325
|
+
const stat = canGit ? numstat(worktree, range) : null;
|
|
326
|
+
const p = canGit ? patch(worktree, range) : null;
|
|
327
|
+
const add = (stat || []).reduce((n, s) => n + s.add, 0);
|
|
328
|
+
const del = (stat || []).reduce((n, s) => n + s.del, 0);
|
|
329
|
+
const files = (stat || []).length;
|
|
330
|
+
scenes.push({
|
|
331
|
+
kind: 'diff',
|
|
332
|
+
turn, sha: e.sha, msg: e.msg, stat, patch: p,
|
|
333
|
+
narration: stat
|
|
334
|
+
? (files
|
|
335
|
+
? `That round of work was saved as ${e.sha}: ${plural(files, 'file')}, ${add} ${add === 1 ? 'line' : 'lines'} added, ${del} removed. Each round is saved separately, so any one of them can be undone.`
|
|
336
|
+
: `Turn ${turn} was committed as ${e.sha} with no file changes.`)
|
|
337
|
+
: `Turn ${turn} was committed as ${e.sha}. The worktree is gone, so the diff is not available in this recap.`,
|
|
338
|
+
});
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (e.type === 'turn_diff') {
|
|
342
|
+
flushQuiet();
|
|
343
|
+
turn += 1;
|
|
344
|
+
const stat = (e.stat || []).filter((x) => x.file !== '.claude/settings.local.json');
|
|
345
|
+
const add = stat.reduce((n, x) => n + x.add, 0);
|
|
346
|
+
const del = stat.reduce((n, x) => n + x.del, 0);
|
|
347
|
+
scenes.push({
|
|
348
|
+
kind: 'diff',
|
|
349
|
+
turn, sha: null, msg: e.msg, stat, patch: e.patch,
|
|
350
|
+
narration: stat.length
|
|
351
|
+
? `That round of work changed ${plural(stat.length, 'file')}: ${add} ${add === 1 ? 'line' : 'lines'} added, ${del} removed.`
|
|
352
|
+
: `That round of work changed no files.`,
|
|
353
|
+
});
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (e.type === 'undo') {
|
|
357
|
+
flushQuiet();
|
|
358
|
+
const range = `${e.to}..${e.from}`;
|
|
359
|
+
const stat = canGit ? numstat(worktree, range) : null;
|
|
360
|
+
const p = canGit ? patch(worktree, range) : null;
|
|
361
|
+
scenes.push({
|
|
362
|
+
kind: 'undo',
|
|
363
|
+
from: e.from, to: e.to, stat, patch: p,
|
|
364
|
+
narration: `${SUP} undid that turn. The tree is back at ${e.to}. The change never reached the branch ${V('you are reviewing', 'you pushed')}, but the recording kept it.`,
|
|
365
|
+
});
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
flushQuiet();
|
|
370
|
+
|
|
371
|
+
// 4. outcome vs intent --------------------------------------------------
|
|
372
|
+
const finalText = lastResult?.text || texts.at(-1)?.text || '';
|
|
373
|
+
// What did not happen: every denied call, whether a person said no or a never rule did.
|
|
374
|
+
const notDone = denied.map((d) => {
|
|
375
|
+
const ask = asks.find((a) => a.id === d.id);
|
|
376
|
+
const input = ask?.input ?? d.input ?? {};
|
|
377
|
+
return { tool: d.tool, what: short(describeInput(d.tool, input), 90), by: d.tier === 'never' ? 'policy' : 'you' };
|
|
378
|
+
});
|
|
379
|
+
const head = canGit && !created?.attached ? git(worktree, ['rev-parse', '--short', 'HEAD']) : null;
|
|
380
|
+
scenes.push({
|
|
381
|
+
kind: 'outcome',
|
|
382
|
+
task, report: finalText, notDone, head, turns: lastResult?.num_turns, cost: lastResult?.cost_usd,
|
|
383
|
+
narration: (() => {
|
|
384
|
+
let n = `The agent reported: ${short(finalText, 150)}`;
|
|
385
|
+
if (!notDone.length) return n;
|
|
386
|
+
const byHuman = notDone.filter((x) => x.by !== 'policy').length;
|
|
387
|
+
const byPolicy = notDone.length - byHuman;
|
|
388
|
+
const parts = [];
|
|
389
|
+
if (byHuman) parts.push(`${byHuman} ${byHuman === 1 ? 'was' : 'were'} refused by ${sup}`);
|
|
390
|
+
if (byPolicy) parts.push(`${byPolicy} ${byPolicy === 1 ? 'was' : 'were'} blocked by policy before anyone saw ${byPolicy === 1 ? 'it' : 'them'}`);
|
|
391
|
+
return `${n} Read that with a caveat: ${plural(notDone.length, 'requested step')} never ran. ${parts.join(', and ')}.`;
|
|
392
|
+
})(),
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// 5. credits ------------------------------------------------------------
|
|
396
|
+
scenes.push({
|
|
397
|
+
kind: 'credits',
|
|
398
|
+
narration: `That is the whole story, including the parts the diff cannot show you. Every number came from the recording, not from a model.`,
|
|
399
|
+
incompleteNote: true,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
id, name, branch: created?.branch, runs: sbRuns, cwd: worktree || null, attached: !!created?.attached, model, date: dateStr, startedAt: t0, durationS: durS,
|
|
404
|
+
humanWaitS: humanWaitMs / 1000, avatar: AVATAR, author: AUTHOR, audience: AUDIENCE, supervisor: AUTHOR, voice: noAudio ? null : VOICE,
|
|
405
|
+
project: projectUrl(),
|
|
406
|
+
generatedAt: new Date().toISOString(), scenes,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// optional: let Claude rewrite the narration (facts stay fixed)
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
function polishWithClaude(sb) {
|
|
414
|
+
const facts = sb.scenes.map((s, i) => ({ i, kind: s.kind, draft: s.narration, facts: factsFor(s) }));
|
|
415
|
+
const schema = {
|
|
416
|
+
type: 'object',
|
|
417
|
+
properties: { narration: { type: 'array', items: { type: 'string' }, minItems: facts.length, maxItems: facts.length } },
|
|
418
|
+
required: ['narration'],
|
|
419
|
+
};
|
|
420
|
+
const prompt = [
|
|
421
|
+
`You are writing the voice-over for a ${facts.length}-scene recap of a coding agent session, for the person who supervised it.`,
|
|
422
|
+
`Rewrite each draft as one or two plain sentences, at most 32 words, second person, present tense, no hype, no adjectives about quality.`,
|
|
423
|
+
`Use only the facts given. Keep every number, file name, command and sha exactly. Do not add claims. Do not mention that you are a model.`,
|
|
424
|
+
`Return the same number of strings in order.`,
|
|
425
|
+
JSON.stringify(facts),
|
|
426
|
+
].join('\n');
|
|
427
|
+
const r = spawnSync('claude', ['-p', '--model', 'sonnet', '--output-format', 'json', '--max-turns', '1', '--tools', '', '--json-schema', JSON.stringify(schema), prompt], { encoding: 'utf8', timeout: 90_000 });
|
|
428
|
+
if (r.status !== 0) throw new Error(`claude exited ${r.status}: ${short(r.stderr, 200)}`);
|
|
429
|
+
const out = JSON.parse(r.stdout);
|
|
430
|
+
if (out.is_error) throw new Error(short(out.result, 200));
|
|
431
|
+
const parsed = out.structured_output ?? (typeof out.result === 'string' ? JSON.parse(out.result) : out.result);
|
|
432
|
+
if (!Array.isArray(parsed?.narration) || parsed.narration.length !== facts.length) throw new Error('unexpected shape');
|
|
433
|
+
sb.scenes.forEach((s, i) => { s.narrationDraft = s.narration; s.narration = String(parsed.narration[i]).trim(); });
|
|
434
|
+
sb.polished = true;
|
|
435
|
+
}
|
|
436
|
+
function factsFor(s) {
|
|
437
|
+
const { narration, patch, input, ...rest } = s;
|
|
438
|
+
return rest;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// audio: macOS say -> aac, embedded as data URIs
|
|
443
|
+
//
|
|
444
|
+
// macOS ships three tiers of the same voice. The compact one is installed by
|
|
445
|
+
// default and is the robot everyone recognises; Enhanced and Premium are free
|
|
446
|
+
// downloads and sound dramatically better. Pick the best tier present, and say
|
|
447
|
+
// so when only the compact one is, because otherwise the page quietly ships the
|
|
448
|
+
// worst voice on the machine and nobody knows a better one was a click away.
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
const VOICE_PREFERENCE = ['Ava', 'Zoe', 'Evan', 'Joelle', 'Nathan', 'Samantha', 'Allison', 'Tom', 'Alex', 'Daniel'];
|
|
451
|
+
|
|
452
|
+
function installedVoices() {
|
|
453
|
+
const out = spawnSync('say', ['-v', '?'], { encoding: 'utf8' }).stdout || '';
|
|
454
|
+
return out.split('\n').filter(Boolean).map((line) => {
|
|
455
|
+
const m = line.match(/^(.+?)\s{2,}([a-z]{2}_[A-Z]{2})/);
|
|
456
|
+
if (!m) return null;
|
|
457
|
+
const name = m[1].trim();
|
|
458
|
+
const tier = /\(Premium\)/.test(name) ? 'Premium' : /\(Enhanced\)/.test(name) ? 'Enhanced' : 'Compact';
|
|
459
|
+
return { name, locale: m[2], tier, base: name.replace(/\s*\((Premium|Enhanced)\)\s*$/, '').replace(/\s*\(English \(.*\)\)$/, '') };
|
|
460
|
+
}).filter(Boolean);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function pickVoice(requested) {
|
|
464
|
+
const all = installedVoices();
|
|
465
|
+
if (requested) {
|
|
466
|
+
const exact = all.find((v) => v.name === requested);
|
|
467
|
+
if (exact) return exact;
|
|
468
|
+
// A bare name given for a voice whose better tier exists: upgrade it.
|
|
469
|
+
const better = all.filter((v) => v.base === requested).sort(byTier)[0];
|
|
470
|
+
if (better) return better;
|
|
471
|
+
return { name: requested, tier: 'Compact', locale: '', base: requested };
|
|
472
|
+
}
|
|
473
|
+
const english = all.filter((v) => /^en_/.test(v.locale));
|
|
474
|
+
const ranked = english
|
|
475
|
+
.filter((v) => VOICE_PREFERENCE.includes(v.base))
|
|
476
|
+
.sort((a, b) => byTier(a, b) || VOICE_PREFERENCE.indexOf(a.base) - VOICE_PREFERENCE.indexOf(b.base));
|
|
477
|
+
return ranked[0] || { name: 'Samantha', tier: 'Compact', locale: 'en_US', base: 'Samantha' };
|
|
478
|
+
}
|
|
479
|
+
const TIER_RANK = { Premium: 0, Enhanced: 1, Compact: 2 };
|
|
480
|
+
const byTier = (a, b) => TIER_RANK[a.tier] - TIER_RANK[b.tier];
|
|
481
|
+
|
|
482
|
+
if (LIST_VOICES) {
|
|
483
|
+
if (process.platform !== 'darwin') {
|
|
484
|
+
console.log(`No system voices: ${process.platform} has no say(1).`);
|
|
485
|
+
console.log('Record your own instead: --script writes the lines, --voice-dir uses your recordings.');
|
|
486
|
+
process.exit(0);
|
|
487
|
+
}
|
|
488
|
+
const all = installedVoices().filter((v) => /^en_/.test(v.locale));
|
|
489
|
+
const by = { Premium: [], Enhanced: [], Compact: [] };
|
|
490
|
+
for (const v of all) by[v.tier].push(v.name);
|
|
491
|
+
for (const t of ['Premium', 'Enhanced', 'Compact']) {
|
|
492
|
+
console.log(`${t} (${by[t].length})`);
|
|
493
|
+
console.log(by[t].length ? ' ' + by[t].join(', ') : ' none installed');
|
|
494
|
+
}
|
|
495
|
+
console.log('');
|
|
496
|
+
console.log('Enhanced and Premium are free downloads:');
|
|
497
|
+
console.log(' System Settings → Accessibility → Spoken Content → System Voice → Manage Voices');
|
|
498
|
+
console.log('Then: node scripts/build-recap.mjs latest --voice "Ava (Premium)"');
|
|
499
|
+
process.exit(0);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const PICKED = pickVoice(VOICE_ARG);
|
|
503
|
+
const VOICE = PICKED.name;
|
|
504
|
+
|
|
505
|
+
// A line you recorded, if there is one. Numbered from 1 so the folder matches
|
|
506
|
+
// the script sheet you read from.
|
|
507
|
+
function recordedLine(dir, i) {
|
|
508
|
+
if (!dir) return null;
|
|
509
|
+
const n = String(i + 1).padStart(2, '0');
|
|
510
|
+
for (const ext of ['m4a', 'wav', 'aiff', 'mp3', 'caf', 'aac']) {
|
|
511
|
+
for (const stem of [n, String(i + 1)]) {
|
|
512
|
+
const f = join(resolve(dir), `${stem}.${ext}`);
|
|
513
|
+
if (existsSync(f)) return f;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// say(1) and afconvert are macOS. Everywhere else the record is still built,
|
|
520
|
+
// still correct and still readable; it just has captions instead of a voice.
|
|
521
|
+
const CAN_SPEAK = process.platform === 'darwin';
|
|
522
|
+
|
|
523
|
+
function narrate(sb) {
|
|
524
|
+
if (!CAN_SPEAK) {
|
|
525
|
+
console.log(`narration: skipped, ${process.platform} has no say(1). The record reads fine without it;`);
|
|
526
|
+
console.log(' supply your own with --voice-dir, or use --no-audio to stop this notice.');
|
|
527
|
+
return 0;
|
|
528
|
+
}
|
|
529
|
+
const tmp = join(tmpdir(), `recap-${sb.id.slice(0, 8)}`);
|
|
530
|
+
mkdirSync(tmp, { recursive: true });
|
|
531
|
+
let ok = 0, read = 0;
|
|
532
|
+
sb.scenes.forEach((s, i) => {
|
|
533
|
+
const aiff = join(tmp, `s${i}.aiff`);
|
|
534
|
+
const m4a = join(tmp, `s${i}.m4a`);
|
|
535
|
+
|
|
536
|
+
const mine = recordedLine(VOICE_DIR, i);
|
|
537
|
+
if (mine) {
|
|
538
|
+
const c = spawnSync('afconvert', ['-f', 'm4af', '-d', 'aac', '-b', '48000', mine, m4a], { encoding: 'utf8' });
|
|
539
|
+
if (c.status === 0) {
|
|
540
|
+
const info = spawnSync('afinfo', [m4a], { encoding: 'utf8' }).stdout || '';
|
|
541
|
+
s.audio = `data:audio/mp4;base64,${readFileSync(m4a).toString('base64')}`;
|
|
542
|
+
s.audioS = Number((info.match(/estimated duration:\s*([\d.]+)/) || [])[1]) || null;
|
|
543
|
+
s.voiced = 'you';
|
|
544
|
+
ok += 1; read += 1;
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
console.warn(` could not convert ${mine}: ${short(c.stderr, 120)}`);
|
|
548
|
+
}
|
|
549
|
+
// A short pause after the opening sentence keeps it from sounding like a
|
|
550
|
+
// list being read out. `say` takes [[slnc ms]] inline.
|
|
551
|
+
const spoken = s.narration.replace(/\.\s+(?=[A-Z])/, '. [[slnc 260]] ');
|
|
552
|
+
const a = spawnSync('say', ['-v', VOICE, '-r', String(RATE), '-o', aiff, spoken], { encoding: 'utf8' });
|
|
553
|
+
if (a.status !== 0) { console.warn(` say failed on scene ${i}: ${short(a.stderr, 120)}`); return; }
|
|
554
|
+
const b = spawnSync('afconvert', ['-f', 'm4af', '-d', 'aac', '-b', '32000', aiff, m4a], { encoding: 'utf8' });
|
|
555
|
+
if (b.status !== 0) { console.warn(` afconvert failed on scene ${i}: ${short(b.stderr, 120)}`); return; }
|
|
556
|
+
const info = spawnSync('afinfo', [m4a], { encoding: 'utf8' }).stdout || '';
|
|
557
|
+
const dur = Number((info.match(/estimated duration:\s*([\d.]+)/) || [])[1]) || null;
|
|
558
|
+
s.audio = `data:audio/mp4;base64,${readFileSync(m4a).toString('base64')}`;
|
|
559
|
+
s.audioS = dur;
|
|
560
|
+
ok += 1;
|
|
561
|
+
});
|
|
562
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
563
|
+
sb.readAloud = read;
|
|
564
|
+
return ok;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// The sheet you read from. One numbered line per scene, with the target length,
|
|
568
|
+
// so the recordings land close to the timings the page already computed.
|
|
569
|
+
function writeScript(sb, slug) {
|
|
570
|
+
const lines = [
|
|
571
|
+
`# ${slug} — narration script`,
|
|
572
|
+
'',
|
|
573
|
+
`${sb.scenes.length} lines. Record each one as its own file in a folder, named 01, 02, 03 and so on.`,
|
|
574
|
+
'Any of m4a, wav, aiff, mp3 or caf. Voice Memos or QuickTime is fine; one take per line.',
|
|
575
|
+
'',
|
|
576
|
+
'Then build with:',
|
|
577
|
+
'',
|
|
578
|
+
'```bash',
|
|
579
|
+
`node scripts/build-recap.mjs --branch ${sb.branch ?? '<branch>'} --repo <repo> --voice-dir <folder>`,
|
|
580
|
+
'```',
|
|
581
|
+
'',
|
|
582
|
+
'Any line you have not recorded falls back to the system voice, so you can do them a few at a time.',
|
|
583
|
+
'',
|
|
584
|
+
'---',
|
|
585
|
+
'',
|
|
586
|
+
];
|
|
587
|
+
sb.scenes.forEach((s, i) => {
|
|
588
|
+
const words = s.narration.split(/\s+/).length;
|
|
589
|
+
lines.push(`### ${String(i + 1).padStart(2, '0')} · ${s.kind} · about ${Math.round(words / 2.6)}s`);
|
|
590
|
+
lines.push('');
|
|
591
|
+
lines.push(s.narration);
|
|
592
|
+
lines.push('');
|
|
593
|
+
});
|
|
594
|
+
const p = join(root, 'records', `${slug}-script.md`);
|
|
595
|
+
writeFileSync(p, lines.join('\n'));
|
|
596
|
+
return p;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
// main
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
const rec = BRANCH ? loadBranch(BRANCH, REPO) : loadRecording(target);
|
|
603
|
+
const sb = buildStoryboard(rec);
|
|
604
|
+
sb.dropped = rec.dropped || 0;
|
|
605
|
+
if (sb.dropped) {
|
|
606
|
+
console.warn(`WARNING: ${sb.dropped} unreadable line(s) in the recording.`);
|
|
607
|
+
console.warn(' The record says so on the page: it cannot claim to be complete.');
|
|
608
|
+
}
|
|
609
|
+
if (BRANCH) { sb.kind = 'branch'; sb.branch = BRANCH; sb.name = basename(rec.repo || '') || sb.name; }
|
|
610
|
+
else sb.kind = 'session';
|
|
611
|
+
|
|
612
|
+
if (wantLLM) {
|
|
613
|
+
try { polishWithClaude(sb); console.log('narration rewritten by claude'); }
|
|
614
|
+
catch (e) { console.warn(`claude polish skipped (${e.message}); using computed narration`); }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// scene duration: audio length + a beat, or a reading-speed estimate
|
|
618
|
+
for (const s of sb.scenes) {
|
|
619
|
+
const words = s.narration.split(/\s+/).length;
|
|
620
|
+
s.durS = Math.max(3.5, words / 2.6 + 1.2);
|
|
621
|
+
}
|
|
622
|
+
if (!noAudio) {
|
|
623
|
+
const n = narrate(sb);
|
|
624
|
+
const spoken = sb.scenes.length - (sb.readAloud || 0);
|
|
625
|
+
console.log(sb.readAloud
|
|
626
|
+
? `narration: ${sb.readAloud} read by you, ${spoken} by ${VOICE} (${PICKED.tier})`
|
|
627
|
+
: `narration: ${n}/${sb.scenes.length} scenes voiced by ${VOICE} (${PICKED.tier})`);
|
|
628
|
+
if (PICKED.tier === 'Compact' && spoken > 0) {
|
|
629
|
+
console.log(' This is the compact voice, the lowest quality macOS ships.');
|
|
630
|
+
console.log(' Free upgrade: System Settings → Accessibility → Spoken Content → System Voice → Manage Voices');
|
|
631
|
+
console.log(' Then re-run. See every option with: node scripts/build-recap.mjs --voices');
|
|
632
|
+
}
|
|
633
|
+
for (const s of sb.scenes) if (s.audioS) s.durS = s.audioS + 0.8;
|
|
634
|
+
}
|
|
635
|
+
sb.totalS = sb.scenes.reduce((n, s) => n + s.durS, 0);
|
|
636
|
+
|
|
637
|
+
mkdirSync(outDir, { recursive: true });
|
|
638
|
+
const storyDirOut = process.env.NEARLY_STORY || join(root, 'records');
|
|
639
|
+
mkdirSync(storyDirOut, { recursive: true });
|
|
640
|
+
const safe = (x) => String(x).replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
|
|
641
|
+
const slug = BRANCH ? `${safe(sb.name)}--${safe(BRANCH)}` : `${sb.name}-${sb.id.slice(0, 4)}`;
|
|
642
|
+
const jsonPath = join(storyDirOut, `${slug}.json`);
|
|
643
|
+
writeFileSync(jsonPath, JSON.stringify({ ...sb, scenes: sb.scenes.map(({ audio, ...s }) => s) }, null, 2));
|
|
644
|
+
|
|
645
|
+
const html = readFileSync(templatePath, 'utf8')
|
|
646
|
+
.replace('__RECAP__', JSON.stringify(sb).replace(/<\/script>/gi, '<\\/script>'))
|
|
647
|
+
.replaceAll('__TITLE__', `${sb.name} · ${sb.scenes[0].title}`)
|
|
648
|
+
.replaceAll('__DESC__', `Recap of a Nearly session: ${sb.scenes[0].narration}`);
|
|
649
|
+
const outPath = join(outDir, `${slug}.html`);
|
|
650
|
+
writeFileSync(outPath, html);
|
|
651
|
+
|
|
652
|
+
if (WRITE_SCRIPT) console.log(`Script sheet: ${writeScript(sb, slug).replace(root + '/', '')}`);
|
|
653
|
+
|
|
654
|
+
console.log(`Built ui/records/${slug}.html — ${BRANCH ? `branch "${BRANCH}", ${sb.runs} session(s), ` : ''}${sb.scenes.length} scenes, ${sb.totalS.toFixed(0)}s, ${Math.round(html.length / 1024)} KB`);
|
|
655
|
+
for (const s of sb.scenes) console.log(` ${s.kind.padEnd(9)} ${s.durS.toFixed(1)}s ${short(s.narration, 90)}`);
|