demobite 1.1.0 → 1.2.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/launcher/index.mjs +15 -0
- package/package.json +1 -1
- package/skill/SKILL.md +83 -2
- package/skill/scripts/briefs.mjs +164 -0
- package/skill/scripts/cleanup.mjs +104 -0
- package/skill/scripts/stage-wait.mjs +87 -0
- package/skill/scripts/status.mjs +72 -0
- package/skill/scripts/upload.mjs +75 -79
- package/skill/scripts/vocab.mjs +88 -0
package/launcher/index.mjs
CHANGED
|
@@ -156,6 +156,21 @@ if (arg === "retake") {
|
|
|
156
156
|
process.exit(r.status ?? 1);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// Batch of briefs (2026-09-13): `npx demobite briefs list <batchId>` etc. and
|
|
160
|
+
// `npx demobite status <takeDir|stagingId>` hand straight to the skill scripts.
|
|
161
|
+
if (arg === "briefs" || arg === "status") {
|
|
162
|
+
let cfg = readCfg();
|
|
163
|
+
if (!cfg?.api_key) {
|
|
164
|
+
console.log("\n Not connected yet — linking this machine to DemoBites first…\n");
|
|
165
|
+
const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs")], { stdio: "inherit", cwd: process.cwd() });
|
|
166
|
+
if (r.status !== 0) process.exit(r.status ?? 1);
|
|
167
|
+
cfg = readCfg();
|
|
168
|
+
}
|
|
169
|
+
if (!cfg?.api_key) { warn("Login did not complete — run: npx demobite login"); process.exit(1); }
|
|
170
|
+
const r = spawnSync("node", [path.join(dest, "scripts", `${arg}.mjs`), ...process.argv.slice(3)], { stdio: "inherit", cwd: process.cwd() });
|
|
171
|
+
process.exit(r.status ?? 1);
|
|
172
|
+
}
|
|
173
|
+
|
|
159
174
|
if (arg === "mcp") {
|
|
160
175
|
let cfg = readCfg();
|
|
161
176
|
if (!cfg?.api_key) {
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -82,6 +82,29 @@ When a page you need shows a login wall (login form, auth redirect, checkpoint p
|
|
|
82
82
|
|
|
83
83
|
Write the storyboard as JSON before touching the camera.
|
|
84
84
|
|
|
85
|
+
### Phase 3a: Harvest the product's vocabulary FIRST
|
|
86
|
+
|
|
87
|
+
The demo speaks the product's CURRENT words, never the brief's, never the pull request's, never your memory's. Products get renamed between the moment a brief is written and the moment you film (founder, 2026-09-14: a take said "Release Readiness" while the app's rail said "Assignments").
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
node scripts/vocab.mjs <takeDir> <url of every screen the take visits>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
It opens each screen on the recorder profile, without video, hovers the rail so tooltips render, and writes `<takeDir>/vocab.json`: nav labels with their tooltips and aria labels, page headings, button and link labels, dialog titles. **Every noun in `narration` and `on_screen` must appear in vocab.json.** The brief's and the PR's words are hints about WHAT changed and where to look; when a brief's noun is missing from the app, say so in the storyboard presentation ("the brief says allowlist, the app says Who can enter") and use the app's word.
|
|
94
|
+
|
|
95
|
+
### LAW: the camera shows an action to its end
|
|
96
|
+
|
|
97
|
+
The agent sits on the running product with a signed-in account. It knows the flow. It performs it. A take that walks into an empty page and narrates "if there were something here" is forbidden; so is "here you would see" (founder, 2026-09-14).
|
|
98
|
+
|
|
99
|
+
- **a. Reversible actions are performed for real.** Create the briefing, add the bites, create the assignment with safe people, move the zoom, press Save. Reversible means you can return the workspace to its prior state after the cut.
|
|
100
|
+
- **b. Every take returns the workspace to its initial state, after the camera stops.** The storyboard declares the plan in `cleanup[]` (steps, same schema, run headless by `cleanup.mjs` after `record.mjs`, before `upload.mjs`) and in `cleanup_plan[]` (plain sentences the human reads: "After the cut: delete briefing X, remove assignment Y"). `checks.after[]` proves it (expect / absent selectors). A step you cannot revert is not performed.
|
|
101
|
+
- **c. Irreversible actions are not performed.** An export that spends minutes, an email to real people, a payment, a publish to a real customer's live page, deleting existing content. The cursor goes to the control, the narration names what it does, the button is not pressed. That is the 99 percent rule: bring the viewer to the last click and name it. Mark these beats in the storyboard with `"pointed": true` and in the presentation with "pointed at, not pressed".
|
|
102
|
+
- **d. Cancel is never a beat.** Never say "we cancel because this is a demo", never zoom on a Cancel button, never make the escape a scene. A dialog that must close without committing closes through the X, the backdrop or Escape, off narration, without a zoom, in the gap between beats. When the dialog's confirm IS reversible, press it (rule a).
|
|
103
|
+
- **e. Empty states are a failure of preparation, not a scene.** If the flow needs data, `prep[]` creates it before the camera (`node scripts/cleanup.mjs <takeDir> --prep`, checked by `checks.before[]`), the take shows the flow, `cleanup[]` removes it.
|
|
104
|
+
- **f. Never present a screen you did not reach.**
|
|
105
|
+
|
|
106
|
+
`upload.mjs` refuses to stage a take whose storyboard declares `cleanup[]` until `cleanup.json` says the cleanup ran and its checks passed (`--allow-uncleaned` overrides, and prints that it did).
|
|
107
|
+
|
|
85
108
|
### LAW: the video is the metronome, not the script
|
|
86
109
|
|
|
87
110
|
**The narration is INTENT, never final copy.** In the DemoBites ending it is handed to the ingestion, which rescripts it and refits it to the video exactly as it does for a customer's own uploaded voice. So never stretch a shot to cover a sentence. A shot is as long as the ACTION needs, and the words get fitted to it afterwards.
|
|
@@ -132,6 +155,34 @@ Storyboard schema:
|
|
|
132
155
|
|
|
133
156
|
Step fields: `action` is one of `goto | settle | scroll | click | hover | type | expect`. **Durations (`dwell`, `after`, settle `ms`, scroll `ms`) are milliseconds; a value under 60 is read as seconds** (write `"dwell": 3400` or `"dwell": 3.4`, never `"dwell": 3` meaning 3 ms). `goto` needs `url`. `settle` takes `ms` and an optional `focus` selector. `scroll` needs `dy` and takes `ms`. `click`/`hover` need `selector` and take `minY` (minimum Y for the visible instance pick), `dwell`, `after`, `waitLoad`. Every step takes `label` and `narration`.
|
|
134
157
|
|
|
158
|
+
Beyond `steps`, a storyboard may carry the off-camera blocks (Phase 3a/law above); `cleanup.mjs` runs them on the same profile without video:
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"prep": [ { "action": "goto", "url": "https://app.acme.com/briefings" }, { "action": "click", "selector": "button:has-text('Create')", "after": 2000 } ],
|
|
163
|
+
"checks": { "before": [ { "action": "expect", "selector": "text=Demo briefing" } ], "after": [ { "action": "absent", "selector": "text=Demo briefing" } ] },
|
|
164
|
+
"cleanup": [ { "action": "goto", "url": "https://app.acme.com/briefings" }, { "action": "click", "selector": "button:has-text('Delete')", "after": 1500 } ],
|
|
165
|
+
"cleanup_plan": [ "After the cut: delete the briefing 'Demo briefing' created for this take.", "After the cut: remove the assignment for demo@acme.com." ]
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Off-camera steps add `press` (`"key": "Escape"`), `wait` (`"ms"`), `expect` and `absent` (checks).
|
|
170
|
+
|
|
171
|
+
A real cleanup, taken from a filmed take (DemoBites, the Enablement Center list): the row menu is a button with `title="Briefing options"`, Delete opens a dialog that asks the name to be typed, then "Permanently Delete".
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
"cleanup": [
|
|
175
|
+
{ "action": "goto", "url": "https://app.demobites.com/enablement-center", "after": 4000 },
|
|
176
|
+
{ "action": "click", "selector": "div.group:has-text('Onboarding briefing (demo)') button[title='Briefing options']", "after": 800 },
|
|
177
|
+
{ "action": "click", "selector": "[role='menu'] [role='menuitem']:has-text('Delete')", "after": 1200 },
|
|
178
|
+
{ "action": "type", "selector": "[role='dialog'] input", "text": "Onboarding briefing (demo)" },
|
|
179
|
+
{ "action": "click", "selector": "[role='dialog'] button:has-text('Permanently Delete')", "after": 4000 }
|
|
180
|
+
],
|
|
181
|
+
"checks": { "after": [ { "action": "absent", "selector": "text=Onboarding briefing (demo)" } ] }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A step with `"required": false` may fail without stopping the rest. A camera step with `"pointed": true` is an irreversible action the cursor reaches and names but never presses (law c).
|
|
185
|
+
|
|
135
186
|
Two fields carry the whole advantage of this lane, so fill them in:
|
|
136
187
|
|
|
137
188
|
- **`on_screen`** describes what the viewer is looking at during the beat. It rides into the ingestion's rescripting stage, so the model writes narration while KNOWING the cursor is on the degree badge and the menu just opened. A microphone can never supply this. Write it for every narrated beat.
|
|
@@ -141,6 +192,8 @@ Use `hideCss` for chat widgets and cookie banners that would pollute the picture
|
|
|
141
192
|
|
|
142
193
|
**Show the storyboard inline and get approval before filming.** Present it as a numbered shot list, not raw JSON. Say the target length out loud so the human can push back on pacing before you burn a take. Iterate until they say go.
|
|
143
194
|
|
|
195
|
+
The presentation has three blocks, always: the shot list (irreversible beats marked "pointed at, not pressed"), **"Before the camera"** (what `prep[]` creates) and **"After the cut"** (the `cleanup_plan[]` sentences). A storyboard whose flow needs data and has no prep, or creates anything and has no cleanup plan, is not ready to show.
|
|
196
|
+
|
|
144
197
|
## LAW: bot walls — one human checkpoint, never a disguise
|
|
145
198
|
|
|
146
199
|
Some sites challenge automated browsers. The protocol, in order, no
|
|
@@ -192,7 +245,9 @@ Only come back to the human when a PRODUCT question remains that you cannot deci
|
|
|
192
245
|
## Phase 5: The take
|
|
193
246
|
|
|
194
247
|
```bash
|
|
248
|
+
node scripts/cleanup.mjs <takeDir> --prep # only when the storyboard has prep[]: creates the data, runs checks.before
|
|
195
249
|
node scripts/record.mjs <takeDir> <storyboard.json>
|
|
250
|
+
node scripts/cleanup.mjs <takeDir> # only when the storyboard has cleanup[]: reverts, runs checks.after, writes cleanup.json
|
|
196
251
|
```
|
|
197
252
|
|
|
198
253
|
Outputs `raw.webm` and `manifest.json` (internal schema, absolute times) into `<takeDir>`. The recorder stamps `record_from`: the moment the first page was FULLY loaded (networkidle plus a beat). Everything before it gets trimmed in both endings, so the published cut always opens on a loaded page.
|
|
@@ -218,7 +273,7 @@ Send the TRIMMED CLEAN take into DemoBites. The studio owns the look: NO backdro
|
|
|
218
273
|
node scripts/trim.mjs <takeDir> # raw.webm -> clean.mp4, trim from record_from ONLY
|
|
219
274
|
node scripts/calibrate.mjs <takeDir> # anchor-measure the clock against the footage
|
|
220
275
|
node scripts/manifest.mjs <takeDir> # internal manifest -> manifest.demobites.json (wire schema)
|
|
221
|
-
node scripts/upload.mjs <takeDir> # STAGE the take + open the in-app preview
|
|
276
|
+
node scripts/upload.mjs <takeDir> # STAGE the take + open the in-app preview (refuses an uncleaned take)
|
|
222
277
|
```
|
|
223
278
|
|
|
224
279
|
**The human word lives in the product now.** `upload.mjs` stages the take (the
|
|
@@ -260,6 +315,32 @@ Laws for a re-take:
|
|
|
260
315
|
- **The human approves in-app.** The preview page says "Re-take of <bite>". Approve replaces the recording in
|
|
261
316
|
that bite; the previous recording is kept for rollback, never overwritten.
|
|
262
317
|
|
|
318
|
+
## Batch of briefs (GitHub PR → demos)
|
|
319
|
+
|
|
320
|
+
The human pastes a bundle of approved briefs into the chat: a header (batchId, workspaceId, the target URL, the 90 second rule) and one block per brief (briefId, revision, contentHash, title, audience, outcome, flowIntent). Up to five briefs. The pasted text is a copy; the server holds the truth.
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
node scripts/briefs.mjs list <batchId> [--paste bundle.txt] # the approved briefs; warns when the paste drifted
|
|
324
|
+
node scripts/briefs.mjs claim <batchId> <briefId> # mints an attempt, creates take-<briefId>-r<revision>/brief.json
|
|
325
|
+
node scripts/briefs.mjs event <takeDir> planning|awaiting_storyboard_approval|recording|uploading|failed|cancelled [--note "..."]
|
|
326
|
+
node scripts/briefs.mjs release <takeDir> # give the brief back (cancelled)
|
|
327
|
+
node scripts/upload.mjs <takeDir> --stage-only --no-open # stage with the attempt riding along, do not wait
|
|
328
|
+
node scripts/status.mjs <takeDir> # later: wait for the word, then for the bite
|
|
329
|
+
node scripts/status.mjs --all # one look at every staged take here
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
The procedure, in order:
|
|
333
|
+
|
|
334
|
+
1. `list` first, always, with `--paste` when the human pasted text. Work from the server's briefs, never from the paste, and say so when they differ.
|
|
335
|
+
2. Claim the briefs you are about to film, one `claim` each. A claim answers "active attempt" when another agent or an earlier run holds the brief: show the human the attempt reference and its start time, and only with their word claim again with `--force`.
|
|
336
|
+
3. Run `vocab.mjs` over the screens each brief visits, then write every storyboard (Phase 3) with the brief as the spec and vocab.json as the only dictionary: the flowIntent lines are the beats, the outcome is the last beat, the exclusions are things the camera never shows, and the take stays under the brief's `maxSeconds` (90). Send `event <takeDir> planning` when you start a storyboard and `event <takeDir> awaiting_storyboard_approval` when it is ready.
|
|
337
|
+
4. **Show the storyboards together, get a word on each one.** One message can carry all of them, but every brief gets its own yes or no. Never take one yes as a yes for the batch. A brief the human declines gets `release`.
|
|
338
|
+
5. Film sequentially, never in parallel: one Chrome on the profile. Per take: `event recording` → Phase 4 dry run → `cleanup.mjs --prep` when declared → Phase 5 take → `cleanup.mjs` (revert, checks.after) → trim, calibrate, manifest → `upload.mjs <takeDir> --stage-only --no-open`. Report, per take, what was created and what was reverted, with the before/after checks. `upload.mjs` reads `brief.json`, moves the attempt to uploading and stages with the attempt on the payload; it writes `staged.json` with the staging id.
|
|
339
|
+
6. **A failed brief never stops the others.** On a failure send `event <takeDir> failed --note "<what happened>"`, keep the take directory for diagnosis, and continue with the next brief. Report every failure plainly at the end.
|
|
340
|
+
7. When all takes are staged, tell the human: N takes are waiting in the review queue (the `queueUrl` printed by the last stage), one Approve or Discard each. Then `status.mjs --all` shows where each stands; `status.mjs <takeDir>` waits for one.
|
|
341
|
+
|
|
342
|
+
Resume after an interruption from what is on disk and on the server: a `take-*` directory with `brief.json` is claimed; with `raw.webm` it was filmed; with `clean.mp4` and `manifest.demobites.json` it is ready to stage; with `staged.json` it is staged (check it with `status.mjs --no-wait`). `list` shows the server's view of every attempt. Never re-claim a brief that already has your own live attempt; never re-stage one that `staged.json` says is staged unless the human asked for a new take (`upload.mjs --supersede`).
|
|
343
|
+
|
|
263
344
|
## The wire manifest (fixed contract, version 2)
|
|
264
345
|
|
|
265
346
|
`manifest.mjs` produces exactly this shape. All times are relative to the UPLOADED file (record_from already subtracted, clamped at 0). `duration` is the duration of the uploaded clean.mp4.
|
|
@@ -329,4 +410,4 @@ The upload zip contains exactly one file: `clean.mp4` stored as `recording.mp4`.
|
|
|
329
410
|
- Anything the human sees (storyboard presentation, review page, questions) uses commas and periods only, no dashes, and real action words. Never orphan a single word on its own line in a heading.
|
|
330
411
|
- Never touch credentials. Never print the api_key. Config and key files are chmod 600.
|
|
331
412
|
- Never INGEST without the human's explicit word. For the DemoBites ending, staging for the in-app preview is HOW the word is asked — the take becomes a bite only when the human clicks Approve on that page.
|
|
332
|
-
- One take directory per take, keep failed takes for diagnosis, name them `take-<slug>`, `take-<slug>2`, and so on.
|
|
413
|
+
- One take directory per take, keep failed takes for diagnosis, name them `take-<slug>`, `take-<slug>2`, and so on. A take claimed from a brief is `take-<briefId>-r<revision>`.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Batch of briefs (GitHub PR → demos, 2026-09-13). The human pastes a bundle
|
|
2
|
+
// of approved briefs into the agent; this script is the agent's hands:
|
|
3
|
+
//
|
|
4
|
+
// node briefs.mjs list <batchId> [--paste <file>] the authenticated truth for the batch (warns when a pasted bundle drifted)
|
|
5
|
+
// node briefs.mjs claim <batchId> <briefId> [--force] claim one brief → mints an attempt, creates take-<briefId>-r<revision>/brief.json
|
|
6
|
+
// node briefs.mjs event <takeDir|attemptRef> <event> [--note "..."]
|
|
7
|
+
// planning | awaiting_storyboard_approval | recording | uploading | failed | cancelled
|
|
8
|
+
// node briefs.mjs release <takeDir|attemptRef> [--note "..."] = event cancelled (give the brief back)
|
|
9
|
+
//
|
|
10
|
+
// Server contract (dbrec_ key, scope record):
|
|
11
|
+
// GET /api/recorder/briefs?batch=<batchId>
|
|
12
|
+
// PUT /api/recorder/briefs/claim { briefId, revision, contentHash, idempotencyKey, force? }
|
|
13
|
+
// PUT /api/recorder/briefs/attempts/<attemptRef> { event, note? }
|
|
14
|
+
// The stage call (upload.mjs) sends the attempt from <takeDir>/brief.json.
|
|
15
|
+
//
|
|
16
|
+
// Laws: one storyboard approval per brief, never one word for the batch.
|
|
17
|
+
// Sequential takes, one Chrome on the profile. A failed brief never stops the
|
|
18
|
+
// others. Never print the api_key.
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import crypto from "node:crypto";
|
|
22
|
+
|
|
23
|
+
const [, , cmd, ...rest] = process.argv;
|
|
24
|
+
const flag = (name) => rest.includes(name);
|
|
25
|
+
const opt = (name) => { const i = rest.indexOf(name); return i >= 0 ? String(rest[i + 1] ?? "") : null; };
|
|
26
|
+
const positional = rest.filter((a, i) => !a.startsWith("--") && !(i > 0 && ["--paste", "--note"].includes(rest[i - 1])));
|
|
27
|
+
|
|
28
|
+
function usage(code = 2) {
|
|
29
|
+
console.error(`Usage:
|
|
30
|
+
node briefs.mjs list <batchId> [--paste <file>]
|
|
31
|
+
node briefs.mjs claim <batchId> <briefId> [--force]
|
|
32
|
+
node briefs.mjs event <takeDir|attemptRef> <event> [--note "..."]
|
|
33
|
+
node briefs.mjs release <takeDir|attemptRef> [--note "..."]`);
|
|
34
|
+
process.exit(code);
|
|
35
|
+
}
|
|
36
|
+
if (!cmd || !["list", "claim", "event", "release"].includes(cmd)) usage();
|
|
37
|
+
|
|
38
|
+
const cfgPath = path.resolve(".recorder", "config.json");
|
|
39
|
+
let cfg = {};
|
|
40
|
+
try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
41
|
+
if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
|
|
42
|
+
const base = cfg.base.replace(/\/+$/, "");
|
|
43
|
+
const headers = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
|
|
44
|
+
|
|
45
|
+
async function api(method, p, body) {
|
|
46
|
+
let res;
|
|
47
|
+
try {
|
|
48
|
+
res = await fetch(`${base}${p}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
|
49
|
+
} catch (e) { console.error(`DemoBites unreachable (${e.message}).`); process.exit(1); }
|
|
50
|
+
const json = await res.json().catch(() => null);
|
|
51
|
+
if (res.status === 401) { console.error("The recorder key was refused. Run: node scripts/login.mjs"); process.exit(1); }
|
|
52
|
+
return { status: res.status, ok: res.ok, json };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function takeDirFor(briefId, revision) {
|
|
56
|
+
const safe = String(briefId).replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
57
|
+
return `take-${safe}-r${revision}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The pasted bundle: one block per brief with briefId / revision / contentHash lines. */
|
|
61
|
+
function parsePaste(text) {
|
|
62
|
+
const out = new Map();
|
|
63
|
+
const blocks = text.split(/\n(?=\s*briefId\s*[:=])/i);
|
|
64
|
+
for (const b of blocks) {
|
|
65
|
+
const id = b.match(/briefId\s*[:=]\s*([A-Za-z0-9._-]+)/i)?.[1];
|
|
66
|
+
if (!id) continue;
|
|
67
|
+
const revision = b.match(/revision\s*[:=]\s*(\d+)/i)?.[1];
|
|
68
|
+
const hash = b.match(/contentHash\s*[:=]\s*([A-Za-z0-9:_-]+)/i)?.[1];
|
|
69
|
+
out.set(id, { revision: revision !== undefined ? Number(revision) : null, contentHash: hash ?? null });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function printBatch(data) {
|
|
75
|
+
const { batch, briefs } = data;
|
|
76
|
+
const src = batch?.source ? `${batch.source.repo}#${batch.source.prNumber}` : "";
|
|
77
|
+
console.log(`Batch ${batch?.id ?? "?"} ${src} target ${batch?.target?.url ?? "?"}${batch?.target?.environment ? ` (${batch.target.environment})` : ""}`);
|
|
78
|
+
console.log(`${briefs.length} brief${briefs.length === 1 ? "" : "s"}, each at most 90 seconds. One storyboard approval per brief.`);
|
|
79
|
+
for (const b of briefs) {
|
|
80
|
+
const att = b.attempt ? ` · attempt ${b.attempt.ref} (${b.attempt.state})` : "";
|
|
81
|
+
console.log(`\n ${b.briefId} r${b.revision} ${b.status}${att}\n ${b.title}\n audience: ${b.audience}\n outcome: ${b.outcome}${b.estimatedDurationSec ? `\n about ${b.estimatedDurationSec}s` : ""}`);
|
|
82
|
+
for (const f of b.flowIntent ?? []) console.log(` · ${f}`);
|
|
83
|
+
if (b.prerequisites?.length) console.log(` needs: ${b.prerequisites.join("; ")}`);
|
|
84
|
+
if (b.exclusions?.length) console.log(` never: ${b.exclusions.join("; ")}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function fetchBatch(batchId) {
|
|
89
|
+
const r = await api("GET", `/api/recorder/briefs?batch=${encodeURIComponent(batchId)}`);
|
|
90
|
+
if (r.status === 403 && r.json?.error === "workspace_mismatch") {
|
|
91
|
+
console.error("This key's workspace is not the batch's workspace. Log in to the right workspace (node scripts/login.mjs) and try again.");
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
if (!r.ok || !r.json?.briefs) { console.error(`Could not read batch ${batchId}: ${r.status} ${r.json ? JSON.stringify(r.json).slice(0, 200) : ""}`); process.exit(1); }
|
|
95
|
+
return r.json;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (cmd === "list") {
|
|
99
|
+
const batchId = positional[0];
|
|
100
|
+
if (!batchId) usage();
|
|
101
|
+
const data = await fetchBatch(batchId);
|
|
102
|
+
printBatch(data);
|
|
103
|
+
const pasteFile = opt("--paste");
|
|
104
|
+
if (pasteFile) {
|
|
105
|
+
let text = "";
|
|
106
|
+
try { text = fs.readFileSync(pasteFile, "utf8"); } catch (e) { console.error(`--paste ${pasteFile}: ${e.message}`); process.exit(2); }
|
|
107
|
+
const pasted = parsePaste(text);
|
|
108
|
+
let drift = 0;
|
|
109
|
+
for (const b of data.briefs) {
|
|
110
|
+
const p = pasted.get(String(b.briefId));
|
|
111
|
+
if (!p) { console.error(`\nWARNING: brief ${b.briefId} is in the batch but not in the pasted text.`); drift++; continue; }
|
|
112
|
+
if (p.contentHash && p.contentHash !== b.contentHash) { console.error(`\nWARNING: brief ${b.briefId}: the pasted contentHash differs from the approved revision r${b.revision}. Work from the approved text above, not the paste.`); drift++; }
|
|
113
|
+
if (p.revision !== null && p.revision !== b.revision) { console.error(`\nWARNING: brief ${b.briefId}: pasted revision r${p.revision}, approved revision r${b.revision}.`); drift++; }
|
|
114
|
+
}
|
|
115
|
+
for (const id of pasted.keys()) if (!data.briefs.some((b) => String(b.briefId) === id)) { console.error(`\nWARNING: pasted brief ${id} is not in batch ${batchId}.`); drift++; }
|
|
116
|
+
if (drift === 0) console.log("\nThe pasted bundle matches the approved briefs.");
|
|
117
|
+
else process.exitCode = 3;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (cmd === "claim") {
|
|
122
|
+
const [batchId, briefId] = positional;
|
|
123
|
+
if (!batchId || !briefId) usage();
|
|
124
|
+
const data = await fetchBatch(batchId);
|
|
125
|
+
const brief = data.briefs.find((b) => String(b.briefId) === String(briefId));
|
|
126
|
+
if (!brief) { console.error(`Brief ${briefId} is not in batch ${batchId}.`); process.exit(1); }
|
|
127
|
+
const idempotencyKey = crypto.createHash("sha256").update(`${batchId}:${brief.briefId}:${brief.revision}:${brief.contentHash}`).digest("hex").slice(0, 32);
|
|
128
|
+
const body = { briefId: brief.briefId, revision: brief.revision, contentHash: brief.contentHash, idempotencyKey, ...(flag("--force") ? { force: true } : {}) };
|
|
129
|
+
const r = await api("PUT", "/api/recorder/briefs/claim", body);
|
|
130
|
+
if (r.status === 409 && r.json?.error === "active_attempt") {
|
|
131
|
+
const a = r.json.active_attempt ?? r.json;
|
|
132
|
+
console.error(`Brief ${briefId} already has a live attempt (${a.attemptRef ?? "?"}${a.since ? `, since ${a.since}` : ""}). Show this to the human; with their word, claim again with --force to supersede it.`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
if (r.status === 409 && r.json?.error === "hash_mismatch") { console.error(`Brief ${briefId}: the content hash does not match the approved revision. Run list again and work from the approved text.`); process.exit(1); }
|
|
136
|
+
if (r.status === 410) { console.error(`Brief ${briefId} r${brief.revision} was superseded by a newer revision. Run list again.`); process.exit(1); }
|
|
137
|
+
if (!r.ok || !r.json?.attemptRef) { console.error(`Claim failed: ${r.status} ${r.json ? JSON.stringify(r.json).slice(0, 200) : ""}`); process.exit(1); }
|
|
138
|
+
const dir = takeDirFor(brief.briefId, brief.revision);
|
|
139
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
140
|
+
const record = {
|
|
141
|
+
batchId, briefId: brief.briefId, revision: brief.revision, contentHash: brief.contentHash, attemptRef: r.json.attemptRef,
|
|
142
|
+
brief: r.json.brief ?? brief, target: r.json.target ?? data.batch?.target ?? null, rules: r.json.rules ?? { maxSeconds: 90 },
|
|
143
|
+
source: data.batch?.source ?? null, claimedAt: new Date().toISOString(),
|
|
144
|
+
};
|
|
145
|
+
fs.writeFileSync(path.join(dir, "brief.json"), JSON.stringify(record, null, 2) + "\n");
|
|
146
|
+
console.log(`Claimed ${brief.briefId} r${brief.revision} → ${dir}/brief.json (attempt ${r.json.attemptRef}, at most ${record.rules.maxSeconds}s)`);
|
|
147
|
+
console.log(`Title: ${record.brief.title}\nTarget: ${record.target?.url ?? "?"}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (cmd === "event" || cmd === "release") {
|
|
151
|
+
const ref = positional[0];
|
|
152
|
+
const event = cmd === "release" ? "cancelled" : positional[1];
|
|
153
|
+
const EVENTS = ["planning", "awaiting_storyboard_approval", "recording", "uploading", "failed", "cancelled"];
|
|
154
|
+
if (!ref || !EVENTS.includes(event)) usage();
|
|
155
|
+
let attemptRef = ref;
|
|
156
|
+
if (fs.existsSync(ref) && fs.statSync(ref).isDirectory()) {
|
|
157
|
+
try { attemptRef = JSON.parse(fs.readFileSync(path.join(ref, "brief.json"), "utf8")).attemptRef; } catch { console.error(`${ref}/brief.json not found or unreadable.`); process.exit(1); }
|
|
158
|
+
}
|
|
159
|
+
const note = opt("--note");
|
|
160
|
+
const r = await api("PUT", `/api/recorder/briefs/attempts/${encodeURIComponent(attemptRef)}`, { event, ...(note ? { note: note.slice(0, 600) } : {}) });
|
|
161
|
+
if (r.status === 409) { console.error(`Event "${event}" refused for ${attemptRef}: ${r.json?.message ?? r.json?.error ?? "state does not allow it"} (current: ${r.json?.state ?? "?"}).`); process.exit(1); }
|
|
162
|
+
if (!r.ok) { console.error(`Event failed: ${r.status} ${r.json ? JSON.stringify(r.json).slice(0, 200) : ""}`); process.exit(1); }
|
|
163
|
+
console.log(`${attemptRef}: ${r.json?.state ?? event}`);
|
|
164
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Prep and cleanup around a take, off camera (law: the camera shows an action
|
|
2
|
+
// to its end, and every take returns the workspace to its initial state).
|
|
3
|
+
//
|
|
4
|
+
// node cleanup.mjs <takeDir> --prep run storyboard.prep[] then storyboard.checks.before[]
|
|
5
|
+
// node cleanup.mjs <takeDir> run storyboard.cleanup[] then storyboard.checks.after[]
|
|
6
|
+
//
|
|
7
|
+
// Steps use the storyboard's own step schema, no video, headless on the
|
|
8
|
+
// recorder profile: goto | click | type | press | hover | wait | expect | absent.
|
|
9
|
+
// { "action": "press", "key": "Escape" } close a dialog through the keyboard
|
|
10
|
+
// { "action": "wait", "ms": 1500 }
|
|
11
|
+
// { "action": "expect", "selector": "text=Foo" } must be visible (a check)
|
|
12
|
+
// { "action": "absent", "selector": "text=Foo" } must NOT be visible (a check)
|
|
13
|
+
// `checks.before` runs after prep, `checks.after` runs after cleanup; both are
|
|
14
|
+
// lists of expect/absent steps. Writes <takeDir>/prep.json or cleanup.json
|
|
15
|
+
// { ran, checks, ok, at }. upload.mjs refuses to stage a take whose storyboard
|
|
16
|
+
// declares cleanup[] until cleanup.json says ok:true (--allow-uncleaned to
|
|
17
|
+
// override, loudly).
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { chromium } from "playwright";
|
|
21
|
+
|
|
22
|
+
const dir = process.argv[2];
|
|
23
|
+
const prep = process.argv.includes("--prep");
|
|
24
|
+
if (!dir) { console.error("Usage: node cleanup.mjs <takeDir> [--prep]"); process.exit(2); }
|
|
25
|
+
const sb = JSON.parse(fs.readFileSync(path.join(dir, "storyboard.json"), "utf8"));
|
|
26
|
+
const steps = prep ? (sb.prep ?? []) : (sb.cleanup ?? []);
|
|
27
|
+
const checks = prep ? (sb.checks?.before ?? []) : (sb.checks?.after ?? []);
|
|
28
|
+
const outName = prep ? "prep.json" : "cleanup.json";
|
|
29
|
+
if (steps.length === 0 && checks.length === 0) {
|
|
30
|
+
console.log(`${prep ? "prep" : "cleanup"}: nothing declared in storyboard.json`);
|
|
31
|
+
fs.writeFileSync(path.join(dir, outName), JSON.stringify({ ran: [], checks: [], ok: true, nothingDeclared: true, at: new Date().toISOString() }, null, 2) + "\n");
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const profileDir = path.resolve(".recorder", "profile");
|
|
36
|
+
const ctx = await chromium.launchPersistentContext(profileDir, { channel: "chrome", headless: true, viewport: { width: 1920, height: 1080 } }).catch(async () =>
|
|
37
|
+
chromium.launchPersistentContext(profileDir, { headless: true, viewport: { width: 1920, height: 1080 } }),
|
|
38
|
+
);
|
|
39
|
+
const page = await ctx.newPage();
|
|
40
|
+
if (sb.hideCss) page.on("load", () => page.addStyleTag({ content: sb.hideCss }).catch(() => {}));
|
|
41
|
+
|
|
42
|
+
async function visibleTarget(selector, minY = 0) {
|
|
43
|
+
const els = page.locator(selector);
|
|
44
|
+
const deadline = Date.now() + 15000;
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
const n = await els.count();
|
|
47
|
+
for (let i = 0; i < n; i++) {
|
|
48
|
+
const el = els.nth(i);
|
|
49
|
+
if (await el.isVisible().catch(() => false)) {
|
|
50
|
+
const box = await el.boundingBox();
|
|
51
|
+
if (box && box.y >= minY) return el;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
await page.waitForTimeout(250);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const report = { ran: [], checks: [], ok: true, at: new Date().toISOString() };
|
|
60
|
+
async function run(step, isCheck) {
|
|
61
|
+
const label = step.label || `${step.action} ${step.selector || step.url || step.key || ""}`.trim();
|
|
62
|
+
const rec = { label, action: step.action, ok: true };
|
|
63
|
+
try {
|
|
64
|
+
if (step.action === "goto") { await page.goto(step.url, { waitUntil: "load", timeout: 60000 }); await page.waitForTimeout(step.after ?? 1500); }
|
|
65
|
+
else if (step.action === "wait") { await page.waitForTimeout(step.ms ?? 1000); }
|
|
66
|
+
else if (step.action === "press") { await page.keyboard.press(step.key || "Escape"); await page.waitForTimeout(step.after ?? 600); }
|
|
67
|
+
else if (step.action === "expect" || step.action === "absent") {
|
|
68
|
+
const el = await visibleTarget(step.selector, step.minY ?? 0);
|
|
69
|
+
const present = !!el;
|
|
70
|
+
rec.ok = step.action === "expect" ? present : !present;
|
|
71
|
+
rec.detail = present ? "visible" : "not visible";
|
|
72
|
+
}
|
|
73
|
+
else if (step.action === "click" || step.action === "hover" || step.action === "type") {
|
|
74
|
+
const el = await visibleTarget(step.selector, step.minY ?? 0);
|
|
75
|
+
if (!el) throw new Error(`no visible target for ${step.selector}`);
|
|
76
|
+
if (step.action === "hover") await el.hover();
|
|
77
|
+
if (step.action === "click") { await el.click(); await page.waitForTimeout(step.after ?? 1200); }
|
|
78
|
+
if (step.action === "type") { await el.click(); if (step.clear) { await el.fill("").catch(async () => { await page.keyboard.press("ControlOrMeta+A"); await page.keyboard.press("Backspace"); }); } await page.keyboard.type(String(step.text ?? ""), { delay: 20 }); if (step.enter) await page.keyboard.press("Enter"); await page.waitForTimeout(step.after ?? 500); }
|
|
79
|
+
}
|
|
80
|
+
else throw new Error(`unknown action ${step.action}`);
|
|
81
|
+
} catch (e) { rec.ok = false; rec.error = e.message; }
|
|
82
|
+
rec.url = page.url(); // where the step left the page (a failed prep leaves its draft's url here)
|
|
83
|
+
(isCheck ? report.checks : report.ran).push(rec);
|
|
84
|
+
// An optional step (required:false) may fail without failing the report:
|
|
85
|
+
// the checks decide. Required steps and checks decide the verdict.
|
|
86
|
+
if (!rec.ok && (isCheck || step.required !== false)) report.ok = false;
|
|
87
|
+
console.log(`${rec.ok ? "✓" : "✗"} ${isCheck ? "check " : ""}${label}${rec.detail ? ` (${rec.detail})` : ""}${rec.error ? ` — ${rec.error}` : ""}`);
|
|
88
|
+
return rec.ok;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const step of steps) { if (!(await run(step, false)) && step.required !== false) { console.error(`stopping: "${step.label || step.action}" failed and is required`); break; } }
|
|
92
|
+
// Checks need a page under them: when nothing navigated yet, open the
|
|
93
|
+
// storyboard's own url first (a prep with only checks, or a cleanup whose
|
|
94
|
+
// steps never left the blank tab).
|
|
95
|
+
if (checks.length > 0 && page.url() === "about:blank" && sb.url) { await page.goto(sb.url, { waitUntil: "load", timeout: 60000 }).catch(() => {}); await page.waitForTimeout(3500); }
|
|
96
|
+
for (const step of checks) await run(step, true);
|
|
97
|
+
await ctx.close();
|
|
98
|
+
// The verdict: when checks are declared they ARE the proof (a rerun after a
|
|
99
|
+
// partial revert legitimately finds nothing left to do); without checks, every
|
|
100
|
+
// required step must have passed.
|
|
101
|
+
if (checks.length > 0) report.ok = report.checks.every((c) => c.ok);
|
|
102
|
+
fs.writeFileSync(path.join(dir, outName), JSON.stringify(report, null, 2) + "\n");
|
|
103
|
+
console.log(`${outName} written: ${report.ok ? "ok" : "NOT ok"} (${report.ran.length} steps, ${report.checks.length} checks)`);
|
|
104
|
+
process.exit(report.ok ? 0 : 1);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Shared wait for a staged take: poll while the human decides in the app,
|
|
2
|
+
// then until the bite is READY, then print the receipt. Used by upload.mjs
|
|
3
|
+
// (single take) and status.mjs (a batch resumes here per take).
|
|
4
|
+
//
|
|
5
|
+
// LAW (founder 2026-08-08): never hand a human a studio link before the bite
|
|
6
|
+
// is finished. Approve only STARTS the pipeline; transcode, rescript, fit,
|
|
7
|
+
// synthesize and finalize happen after. The link exists ONLY behind a
|
|
8
|
+
// confirmed "completed".
|
|
9
|
+
//
|
|
10
|
+
// Returns { exitCode, status, biteId, studioUrl } and never throws.
|
|
11
|
+
|
|
12
|
+
export async function waitForDecision({ base, apiKey, stagingId, pageUrl, decisionTimeoutMs = 30 * 60 * 1000, pollMs = 4000 }) {
|
|
13
|
+
const headers = { Authorization: `Bearer ${apiKey}` };
|
|
14
|
+
const deadline = Date.now() + decisionTimeoutMs;
|
|
15
|
+
let announced = false;
|
|
16
|
+
let completed = false;
|
|
17
|
+
let approvedBiteId = null;
|
|
18
|
+
let finalStudioUrl = null;
|
|
19
|
+
process.stdout.write("Waiting for your word in the browser");
|
|
20
|
+
while (Date.now() < deadline) {
|
|
21
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
22
|
+
let res;
|
|
23
|
+
try {
|
|
24
|
+
res = await fetch(`${base}/api/recorder/stage?id=${encodeURIComponent(stagingId)}`, { headers });
|
|
25
|
+
} catch { process.stdout.write("."); continue; }
|
|
26
|
+
if (!res.ok) { process.stdout.write("."); continue; }
|
|
27
|
+
const st = await res.json().catch(() => null);
|
|
28
|
+
if (!st) { process.stdout.write("."); continue; }
|
|
29
|
+
if (st.status === "rejected") {
|
|
30
|
+
process.stdout.write("\n");
|
|
31
|
+
console.error("Discarded in the app. Adjust the storyboard and film again.");
|
|
32
|
+
return { exitCode: 1, status: "rejected", biteId: null, studioUrl: null };
|
|
33
|
+
}
|
|
34
|
+
if (st.status === "approved") {
|
|
35
|
+
if (!announced) {
|
|
36
|
+
process.stdout.write("\n");
|
|
37
|
+
console.log(`Approved — bite ${st.biteId} is being created`);
|
|
38
|
+
announced = true;
|
|
39
|
+
approvedBiteId = st.biteId;
|
|
40
|
+
finalStudioUrl = st.studioUrl ? new URL(st.studioUrl, base).toString() : null;
|
|
41
|
+
process.stdout.write("Waiting for the bite to finish");
|
|
42
|
+
}
|
|
43
|
+
if (st.biteStatus === "completed") { completed = true; process.stdout.write("\n"); break; }
|
|
44
|
+
if (st.biteStatus === "failed") {
|
|
45
|
+
process.stdout.write("\n");
|
|
46
|
+
console.error("The pipeline FAILED for this bite. Do not hand over any link — investigate.");
|
|
47
|
+
return { exitCode: 1, status: "failed", biteId: approvedBiteId, studioUrl: null };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
process.stdout.write(".");
|
|
51
|
+
}
|
|
52
|
+
if (!announced) {
|
|
53
|
+
process.stdout.write("\n");
|
|
54
|
+
console.error(`No decision yet. The preview stays available at:\n ${pageUrl}`);
|
|
55
|
+
return { exitCode: 1, status: "pending", biteId: null, studioUrl: null };
|
|
56
|
+
}
|
|
57
|
+
if (!completed) {
|
|
58
|
+
console.error("Approved, but the bite did not finish within the wait window. Do not share the link yet — poll /api/recorder/status or reload the preview page.");
|
|
59
|
+
return { exitCode: 1, status: "approved", biteId: approvedBiteId, studioUrl: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Final receipt via the status endpoint (same gate as before).
|
|
63
|
+
let last = null;
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(`${base}/api/recorder/status?biteId=${approvedBiteId}`, { headers });
|
|
66
|
+
if (res.ok) last = await res.json().catch(() => null);
|
|
67
|
+
} catch { /* summary is best-effort; readiness was confirmed above */ }
|
|
68
|
+
if (last && last.status === "completed") {
|
|
69
|
+
console.log(
|
|
70
|
+
`Ready: "${last.title}" — ${last.durationSec ? last.durationSec.toFixed(1) + "s, " : ""}` +
|
|
71
|
+
`${last.narrationReady}/${last.narrationTotal} narration segments with audio, ${last.zooms} camera shots`,
|
|
72
|
+
);
|
|
73
|
+
if (last.narrationTotal === 0) console.error("WARNING: no narration segments landed. The voice will be silent.");
|
|
74
|
+
else if (last.narrationReady < last.narrationTotal) console.error(`WARNING: ${last.narrationTotal - last.narrationReady} segment(s) have no audio behind them.`);
|
|
75
|
+
if (last.zooms === 0) console.error("WARNING: no camera shots landed.");
|
|
76
|
+
}
|
|
77
|
+
if (finalStudioUrl) console.log(`Studio: ${finalStudioUrl}`);
|
|
78
|
+
return { exitCode: 0, status: "completed", biteId: approvedBiteId, studioUrl: finalStudioUrl };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One look, no waiting: the staged take's current status as the server sees it. */
|
|
82
|
+
export async function peekStaged({ base, apiKey, stagingId }) {
|
|
83
|
+
const res = await fetch(`${base}/api/recorder/stage?id=${encodeURIComponent(stagingId)}`, { headers: { Authorization: `Bearer ${apiKey}` } });
|
|
84
|
+
if (!res.ok) return { ok: false, httpStatus: res.status };
|
|
85
|
+
const st = await res.json().catch(() => null);
|
|
86
|
+
return st ? { ok: true, ...st } : { ok: false, httpStatus: res.status };
|
|
87
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Where a staged take stands, and the wait for its word.
|
|
2
|
+
//
|
|
3
|
+
// node status.mjs <takeDir> wait for the decision, then for the bite to finish (reads <takeDir>/staged.json)
|
|
4
|
+
// node status.mjs <stagingId> same, by id
|
|
5
|
+
// node status.mjs <takeDir> --no-wait one look, no waiting: pending | approved (+bite status) | rejected
|
|
6
|
+
// node status.mjs --all one look at every take-*/staged.json under the current directory
|
|
7
|
+
//
|
|
8
|
+
// A batch stages every take with `upload.mjs --stage-only --no-open`, then the
|
|
9
|
+
// agent (or the human, later) comes back here per take. The same law as
|
|
10
|
+
// upload.mjs: no studio link before the bite is completed.
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { waitForDecision, peekStaged } from "./stage-wait.mjs";
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const noWait = args.includes("--no-wait");
|
|
17
|
+
const all = args.includes("--all");
|
|
18
|
+
const target = args.find((a) => !a.startsWith("--"));
|
|
19
|
+
if (!target && !all) {
|
|
20
|
+
console.error("Usage: node status.mjs <takeDir|stagingId> [--no-wait] | node status.mjs --all");
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const cfgPath = path.resolve(".recorder", "config.json");
|
|
25
|
+
let cfg = {};
|
|
26
|
+
try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
27
|
+
if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
|
|
28
|
+
const base = cfg.base.replace(/\/+$/, "");
|
|
29
|
+
|
|
30
|
+
function readStaged(dir) {
|
|
31
|
+
const p = path.join(dir, "staged.json");
|
|
32
|
+
if (!fs.existsSync(p)) return null;
|
|
33
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function describe(st) {
|
|
37
|
+
if (!st.ok) return `unreachable (${st.httpStatus})`;
|
|
38
|
+
if (st.status === "rejected") return "discarded in the app";
|
|
39
|
+
if (st.status === "approved") return `approved, bite ${st.biteId ?? "?"} ${st.biteStatus ?? "processing"}`;
|
|
40
|
+
return "waiting for the word in the app";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (all) {
|
|
44
|
+
const dirs = fs.readdirSync(".").filter((d) => d.startsWith("take-") && fs.existsSync(path.join(d, "staged.json")));
|
|
45
|
+
if (dirs.length === 0) { console.log("No staged takes here."); process.exit(0); }
|
|
46
|
+
for (const d of dirs) {
|
|
47
|
+
const staged = readStaged(d);
|
|
48
|
+
const st = staged?.stagingId ? await peekStaged({ base, apiKey: cfg.api_key, stagingId: staged.stagingId }) : { ok: false, httpStatus: 0 };
|
|
49
|
+
console.log(`${d.padEnd(40)} ${describe(st)}${staged?.previewUrl ? `\n${"".padEnd(40)} ${staged.previewUrl}` : ""}`);
|
|
50
|
+
}
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let stagingId = target;
|
|
55
|
+
let pageUrl = null;
|
|
56
|
+
if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
|
|
57
|
+
const staged = readStaged(target);
|
|
58
|
+
if (!staged?.stagingId) { console.error(`${target} has no staged.json. Stage it first: node scripts/upload.mjs ${target} --stage-only`); process.exit(1); }
|
|
59
|
+
stagingId = staged.stagingId;
|
|
60
|
+
pageUrl = staged.previewUrl ?? null;
|
|
61
|
+
}
|
|
62
|
+
if (!pageUrl) pageUrl = `${base}/recording-preview/agentic/${encodeURIComponent(stagingId)}`;
|
|
63
|
+
|
|
64
|
+
if (noWait) {
|
|
65
|
+
const st = await peekStaged({ base, apiKey: cfg.api_key, stagingId });
|
|
66
|
+
console.log(`${stagingId}: ${describe(st)}`);
|
|
67
|
+
if (st.ok && st.status === "approved" && st.biteStatus === "completed" && st.studioUrl) console.log(`Studio: ${new URL(st.studioUrl, base).toString()}`);
|
|
68
|
+
process.exit(st.ok ? 0 : 1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl });
|
|
72
|
+
process.exit(outcome.exitCode);
|
package/skill/scripts/upload.mjs
CHANGED
|
@@ -40,6 +40,28 @@ if (retakeIdx >= 0 && !(Number.isInteger(retakeOfBiteId) && retakeOfBiteId > 0))
|
|
|
40
40
|
// history show why this version was filmed. Never a secret, never required.
|
|
41
41
|
const noteIdx = process.argv.indexOf("--note");
|
|
42
42
|
const retakeNote = noteIdx >= 0 ? String(process.argv[noteIdx + 1] ?? "").trim().slice(0, 600) : "";
|
|
43
|
+
// BATCH OF BRIEFS (2026-09-13): a take claimed from a brief carries its attempt
|
|
44
|
+
// (briefId, revision, contentHash, attemptRef) so provenance rides into the
|
|
45
|
+
// staged take and the bite. Read from <takeDir>/brief.json (written by
|
|
46
|
+
// briefs.mjs claim) unless --attempt <file> points elsewhere or --no-attempt
|
|
47
|
+
// opts out. `--stage-only` returns right after the two uploads (the batch
|
|
48
|
+
// waits with status.mjs); `--supersede` replaces a stage already pinned to
|
|
49
|
+
// this attempt (the server refuses a second one otherwise).
|
|
50
|
+
const stageOnly = process.argv.includes("--stage-only");
|
|
51
|
+
const supersede = process.argv.includes("--supersede");
|
|
52
|
+
const attemptIdx = process.argv.indexOf("--attempt");
|
|
53
|
+
const attemptPath = process.argv.includes("--no-attempt")
|
|
54
|
+
? null
|
|
55
|
+
: attemptIdx >= 0 ? String(process.argv[attemptIdx + 1] ?? "") : path.join(dir, "brief.json");
|
|
56
|
+
let attempt = null;
|
|
57
|
+
if (attemptPath && fs.existsSync(attemptPath)) {
|
|
58
|
+
try {
|
|
59
|
+
const b = JSON.parse(fs.readFileSync(attemptPath, "utf8"));
|
|
60
|
+
if (b.briefId && b.revision !== undefined && b.contentHash && b.attemptRef) {
|
|
61
|
+
attempt = { briefId: String(b.briefId), revision: b.revision, contentHash: String(b.contentHash), attemptRef: String(b.attemptRef) };
|
|
62
|
+
} else console.error(`${attemptPath} is missing briefId/revision/contentHash/attemptRef; staging without an attempt`);
|
|
63
|
+
} catch (e) { console.error(`${attemptPath} unreadable (${e.message}); staging without an attempt`); }
|
|
64
|
+
} else if (attemptIdx >= 0) { console.error(`--attempt ${attemptPath} not found`); process.exit(2); }
|
|
43
65
|
const cfgPath = path.resolve(".recorder", "config.json");
|
|
44
66
|
let cfg = {};
|
|
45
67
|
try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
@@ -65,6 +87,22 @@ try {
|
|
|
65
87
|
if (retakeNote) recipe.config = { ...(recipe.config ?? {}), retake_note: retakeNote };
|
|
66
88
|
}
|
|
67
89
|
} catch (e) { console.error("recipe skipped:", e.message); }
|
|
90
|
+
// LAW (founder 2026-09-14): a take that created anything returns the workspace
|
|
91
|
+
// to its initial state before it is staged. The storyboard declares cleanup[];
|
|
92
|
+
// cleanup.mjs writes cleanup.json with the checks. No passing cleanup.json,
|
|
93
|
+
// no stage. --allow-uncleaned overrides, and says so out loud.
|
|
94
|
+
try {
|
|
95
|
+
const sbp = path.join(dir, "storyboard.json");
|
|
96
|
+
const sb = fs.existsSync(sbp) ? JSON.parse(fs.readFileSync(sbp, "utf8")) : {};
|
|
97
|
+
if (Array.isArray(sb.cleanup) && sb.cleanup.length > 0) {
|
|
98
|
+
const cp = path.join(dir, "cleanup.json");
|
|
99
|
+
const rep = fs.existsSync(cp) ? JSON.parse(fs.readFileSync(cp, "utf8")) : null;
|
|
100
|
+
if (!rep || rep.ok !== true) {
|
|
101
|
+
if (process.argv.includes("--allow-uncleaned")) console.error("WARNING: staging a take whose cleanup did not run or did not pass (--allow-uncleaned). The workspace may still carry what the take created.");
|
|
102
|
+
else { console.error(`This take declares cleanup[] but ${rep ? "cleanup.json says NOT ok" : "cleanup.json is missing"}. Run: node scripts/cleanup.mjs ${dir} (then stage again)`); process.exit(1); }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch (e) { console.error(`cleanup check skipped: ${e.message}`); }
|
|
68
106
|
if (!fs.existsSync(cleanPath)) { console.error(`${cleanPath} not found. Run: node scripts/trim.mjs ${dir}`); process.exit(1); }
|
|
69
107
|
if (!fs.existsSync(wirePath)) { console.error(`${wirePath} not found. Run: node scripts/manifest.mjs ${dir}`); process.exit(1); }
|
|
70
108
|
const manifest = JSON.parse(fs.readFileSync(wirePath, "utf8"));
|
|
@@ -144,13 +182,23 @@ if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId}
|
|
|
144
182
|
|
|
145
183
|
// ── stage ──────────────────────────────────────────────────────────────────
|
|
146
184
|
const authHeaders = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
|
|
185
|
+
if (attempt) {
|
|
186
|
+
// The attempt moves to "uploading" before the stage call; a 409 here means
|
|
187
|
+
// the server already sees it at or past that state, which is fine.
|
|
188
|
+
try {
|
|
189
|
+
const ev = await fetch(`${base}/api/recorder/briefs/attempts/${encodeURIComponent(attempt.attemptRef)}`, {
|
|
190
|
+
method: "PUT", headers: authHeaders, body: JSON.stringify({ event: "uploading" }),
|
|
191
|
+
});
|
|
192
|
+
if (!ev.ok && ev.status !== 409) console.error(`attempt event "uploading" → ${ev.status} (continuing)`);
|
|
193
|
+
} catch (e) { console.error(`attempt event "uploading" failed: ${e.message} (continuing)`); }
|
|
194
|
+
}
|
|
147
195
|
const previewSizeBytes = fs.statSync(cleanPath).size;
|
|
148
196
|
let stageRes;
|
|
149
197
|
try {
|
|
150
198
|
stageRes = await fetch(`${base}/api/recorder/stage`, {
|
|
151
199
|
method: "PUT",
|
|
152
200
|
headers: authHeaders,
|
|
153
|
-
body: JSON.stringify({ filename: "take.zip", sizeBytes, previewSizeBytes, manifest, ...(recipe ? { recipe } : {}), ...(retakeOfBiteId ? { retakeOfBiteId } : {}) }),
|
|
201
|
+
body: JSON.stringify({ filename: "take.zip", sizeBytes, previewSizeBytes, manifest, ...(recipe ? { recipe } : {}), ...(retakeOfBiteId ? { retakeOfBiteId } : {}), ...(attempt ? { attempt } : {}), ...(attempt && supersede ? { supersede: true } : {}) }),
|
|
154
202
|
});
|
|
155
203
|
} catch (e) {
|
|
156
204
|
console.error(`Could not reach ${base}: ${e.message}`);
|
|
@@ -168,6 +216,14 @@ if (!stageRes.ok) {
|
|
|
168
216
|
console.error(`DemoBites declined the stage. Check ${base}/bites and try again.`);
|
|
169
217
|
process.exit(1);
|
|
170
218
|
}
|
|
219
|
+
if (errBody?.error === "invalid_attempt") {
|
|
220
|
+
console.error(`DemoBites refused the attempt on this take: ${errBody.message ?? "invalid_attempt"}. Re-claim the brief (node scripts/briefs.mjs claim …) and stage again.`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
if (errBody?.error === "attempt_already_staged") {
|
|
224
|
+
console.error(`This attempt already has a staged take${errBody.stagingId ? ` (${errBody.stagingId})` : ""}. Review that one, or stage again with --supersede to replace it.`);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
171
227
|
console.error(`Stage failed: ${stageRes.status} ${errBody ? JSON.stringify(errBody) : ""}`);
|
|
172
228
|
process.exit(1);
|
|
173
229
|
}
|
|
@@ -191,6 +247,14 @@ async function putS3(url, contentType, filePath, label) {
|
|
|
191
247
|
}
|
|
192
248
|
await putS3(uploadUrl, "application/zip", zipPath, "ZIP");
|
|
193
249
|
await putS3(previewUploadUrl, "video/mp4", cleanPath, "Preview");
|
|
250
|
+
// The staging id used to be printed only; a batch resumes from disk, so it is
|
|
251
|
+
// persisted next to the take (status.mjs reads it).
|
|
252
|
+
try {
|
|
253
|
+
fs.writeFileSync(path.join(dir, "staged.json"), JSON.stringify({
|
|
254
|
+
stagingId, previewUrl: new URL(previewUrl, base).toString(), queueUrl: queueUrl ? new URL(queueUrl, base).toString() : null,
|
|
255
|
+
pendingCount: pendingCount ?? null, attemptRef: attempt?.attemptRef ?? null, briefId: attempt?.briefId ?? null, at: new Date().toISOString(),
|
|
256
|
+
}, null, 2) + "\n");
|
|
257
|
+
} catch (e) { console.error(`staged.json not written: ${e.message}`); }
|
|
194
258
|
|
|
195
259
|
// ── open the in-app preview — the review happens THERE ─────────────────────
|
|
196
260
|
// Batch etiquette (founder, 2026-08-11): when takes are stacked for a later
|
|
@@ -211,83 +275,15 @@ if (!noOpen) {
|
|
|
211
275
|
} catch { /* printing the URL above is the fallback */ }
|
|
212
276
|
}
|
|
213
277
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const POLL_MS = 4000;
|
|
218
|
-
const DECISION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
219
|
-
const deadline = Date.now() + DECISION_TIMEOUT_MS;
|
|
220
|
-
let announced = false;
|
|
221
|
-
let completed = false;
|
|
222
|
-
let approvedBiteId = null;
|
|
223
|
-
let finalStudioUrl = null;
|
|
224
|
-
process.stdout.write("Waiting for your word in the browser");
|
|
225
|
-
while (Date.now() < deadline) {
|
|
226
|
-
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
227
|
-
let res;
|
|
228
|
-
try {
|
|
229
|
-
res = await fetch(`${base}/api/recorder/stage?id=${stagingId}`, {
|
|
230
|
-
headers: { Authorization: `Bearer ${cfg.api_key}` },
|
|
231
|
-
});
|
|
232
|
-
} catch { process.stdout.write("."); continue; }
|
|
233
|
-
if (!res.ok) { process.stdout.write("."); continue; }
|
|
234
|
-
const st = await res.json().catch(() => null);
|
|
235
|
-
if (!st) { process.stdout.write("."); continue; }
|
|
236
|
-
if (st.status === "rejected") {
|
|
237
|
-
process.stdout.write("\n");
|
|
238
|
-
console.error("Discarded in the app. Adjust the storyboard and film again.");
|
|
239
|
-
process.exit(1);
|
|
240
|
-
}
|
|
241
|
-
if (st.status === "approved") {
|
|
242
|
-
if (!announced) {
|
|
243
|
-
process.stdout.write("\n");
|
|
244
|
-
console.log(`Approved — bite ${st.biteId} is being created`);
|
|
245
|
-
announced = true;
|
|
246
|
-
approvedBiteId = st.biteId;
|
|
247
|
-
finalStudioUrl = st.studioUrl ? new URL(st.studioUrl, base).toString() : null;
|
|
248
|
-
process.stdout.write("Waiting for the bite to finish");
|
|
249
|
-
}
|
|
250
|
-
if (st.biteStatus === "completed") {
|
|
251
|
-
completed = true;
|
|
252
|
-
process.stdout.write("\n");
|
|
253
|
-
break;
|
|
254
|
-
}
|
|
255
|
-
if (st.biteStatus === "failed") {
|
|
256
|
-
process.stdout.write("\n");
|
|
257
|
-
console.error("The pipeline FAILED for this bite. Do not hand over any link — investigate.");
|
|
258
|
-
process.exit(1);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
process.stdout.write(".");
|
|
262
|
-
}
|
|
263
|
-
if (!announced) {
|
|
264
|
-
process.stdout.write("\n");
|
|
265
|
-
console.error(`No decision yet. The preview stays available at:\n ${pageUrl}`);
|
|
266
|
-
process.exit(1);
|
|
267
|
-
}
|
|
268
|
-
// LAW: the studio link exists ONLY behind a confirmed 'completed'. A deadline
|
|
269
|
-
// expiry after approval is NOT completion (review finding: the fallthrough
|
|
270
|
-
// here once printed the link for an unfinished bite).
|
|
271
|
-
if (!completed) {
|
|
272
|
-
console.error("Approved, but the bite did not finish within the wait window. Do not share the link yet — poll /api/recorder/status or reload the preview page.");
|
|
273
|
-
process.exit(1);
|
|
278
|
+
if (stageOnly) {
|
|
279
|
+
console.log(`Staged only. Wait for the decision later with: node scripts/status.mjs ${dir}`);
|
|
280
|
+
process.exit(0);
|
|
274
281
|
}
|
|
275
282
|
|
|
276
|
-
// ──
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
} catch { /* summary is best-effort; readiness was confirmed above */ }
|
|
284
|
-
if (last && last.status === "completed") {
|
|
285
|
-
console.log(
|
|
286
|
-
`Ready: "${last.title}" — ${last.durationSec ? last.durationSec.toFixed(1) + "s, " : ""}` +
|
|
287
|
-
`${last.narrationReady}/${last.narrationTotal} narration segments with audio, ${last.zooms} camera shots`,
|
|
288
|
-
);
|
|
289
|
-
if (last.narrationTotal === 0) console.error("WARNING: no narration segments landed. The voice will be silent.");
|
|
290
|
-
else if (last.narrationReady < last.narrationTotal) console.error(`WARNING: ${last.narrationTotal - last.narrationReady} segment(s) have no audio behind them.`);
|
|
291
|
-
if (last.zooms === 0) console.error("WARNING: no camera shots landed.");
|
|
292
|
-
}
|
|
293
|
-
if (finalStudioUrl) console.log(`Studio: ${finalStudioUrl}`);
|
|
283
|
+
// ── poll while the human decides, then until the bite is READY ─────────────
|
|
284
|
+
// Shared with status.mjs (a batch waits there). The law it enforces: never
|
|
285
|
+
// hand a human a studio link before the bite is finished; Approve only
|
|
286
|
+
// STARTS the pipeline.
|
|
287
|
+
const { waitForDecision } = await import("./stage-wait.mjs");
|
|
288
|
+
const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl });
|
|
289
|
+
process.exit(outcome.exitCode);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Harvest the product's CURRENT vocabulary from the running app (Phase 3a).
|
|
2
|
+
//
|
|
3
|
+
// node vocab.mjs <takeDir> <url> [<url> ...] [--headless=false]
|
|
4
|
+
//
|
|
5
|
+
// Opens each URL headless on the recorder profile (signed in, no video) and
|
|
6
|
+
// writes <takeDir>/vocab.json: for every screen, the nav labels (visible text,
|
|
7
|
+
// aria-label, title, tooltips), the page headings, the button and link labels,
|
|
8
|
+
// and the titles of any open dialogs. The storyboard's narration and
|
|
9
|
+
// on_screen lines may only use nouns that appear here. The brief's and the
|
|
10
|
+
// PR's words are hints about WHAT changed, never the words the demo speaks
|
|
11
|
+
// (founder, 2026-09-14: a take said "Release Readiness" while the app said
|
|
12
|
+
// "Assignments").
|
|
13
|
+
//
|
|
14
|
+
// Prints a short inventory so the agent can read it in chat before writing.
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { chromium } from "playwright";
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const dir = args[0];
|
|
21
|
+
const urls = args.slice(1).filter((a) => /^https?:\/\//.test(a));
|
|
22
|
+
if (!dir || urls.length === 0) {
|
|
23
|
+
console.error("Usage: node vocab.mjs <takeDir> <url> [<url> ...]");
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
const profileDir = path.resolve(".recorder", "profile");
|
|
28
|
+
const headless = !args.includes("--headless=false");
|
|
29
|
+
|
|
30
|
+
const ctx = await chromium.launchPersistentContext(profileDir, { channel: "chrome", headless, viewport: { width: 1920, height: 1080 } }).catch(async () =>
|
|
31
|
+
chromium.launchPersistentContext(profileDir, { headless, viewport: { width: 1920, height: 1080 } }),
|
|
32
|
+
);
|
|
33
|
+
const page = await ctx.newPage();
|
|
34
|
+
|
|
35
|
+
const harvest = () => {
|
|
36
|
+
const clean = (s) => (s || "").replace(/\s+/g, " ").trim();
|
|
37
|
+
const vis = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== "hidden"; };
|
|
38
|
+
const labelOf = (el) => clean(el.getAttribute("aria-label") || el.getAttribute("title") || el.getAttribute("data-tooltip") || el.innerText);
|
|
39
|
+
const uniq = (xs) => [...new Set(xs.filter(Boolean))];
|
|
40
|
+
const inRail = (el) => el.getBoundingClientRect().x < 90;
|
|
41
|
+
const nav = uniq([...document.querySelectorAll("nav a, nav button, aside a, aside button, [role='navigation'] a, header a, header button")].filter(vis).map((el) => {
|
|
42
|
+
const label = labelOf(el);
|
|
43
|
+
const href = el.getAttribute("href") || "";
|
|
44
|
+
// Tooltips often live on a sibling/parent: look at the closest element that carries one.
|
|
45
|
+
const tip = clean(el.closest("[title],[aria-label],[data-tooltip]")?.getAttribute("title") || el.closest("[data-tooltip]")?.getAttribute("data-tooltip") || "");
|
|
46
|
+
return (label || tip) ? `${label || tip}${href ? ` (${href})` : ""}${inRail(el) ? " [rail]" : ""}` : "";
|
|
47
|
+
}));
|
|
48
|
+
const headings = uniq([...document.querySelectorAll("h1, h2, h3")].filter(vis).map((el) => clean(el.innerText)).filter((t) => t.length < 90));
|
|
49
|
+
// Editors keep their controls in header bars and side panels, so nothing is
|
|
50
|
+
// excluded by landmark; only the left rail (x < 90) is reported separately.
|
|
51
|
+
const buttons = uniq([...document.querySelectorAll("button, [role='button'], a[href], [role='tab'], [role='menuitem']")].filter(vis).filter((el) => !inRail(el)).map(labelOf).filter((t) => t && t.length < 60));
|
|
52
|
+
const fields = uniq([...document.querySelectorAll("input, textarea, [contenteditable='true']")].filter(vis).map((el) => clean(el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.closest("label")?.innerText || "")).filter((t) => t && t.length < 60));
|
|
53
|
+
const dialogs = uniq([...document.querySelectorAll("[role='dialog'], [role='alertdialog']")].map((d) => clean(d.querySelector("h1,h2,h3,[id*='title']")?.innerText || d.getAttribute("aria-label") || "")));
|
|
54
|
+
const tooltips = uniq([...document.querySelectorAll("[role='tooltip']")].map((el) => clean(el.innerText)));
|
|
55
|
+
return { title: document.title, nav, headings, buttons, fields, dialogs, tooltips };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const screens = [];
|
|
59
|
+
for (const url of urls) {
|
|
60
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: 60000 }).catch(() => page.goto(url, { waitUntil: "load", timeout: 60000 }).catch((e) => console.error(`${url}: ${e.message}`)));
|
|
61
|
+
// Entitlement-gated nav items paint late; give the app a moment, then hover
|
|
62
|
+
// every rail link (the icons carry their names as tooltips, not as text) so
|
|
63
|
+
// the tooltip portals render into the DOM.
|
|
64
|
+
await page.waitForTimeout(5000);
|
|
65
|
+
const rail = page.locator("a[href^='/']");
|
|
66
|
+
const n = Math.min(await rail.count(), 40);
|
|
67
|
+
const tips = new Set();
|
|
68
|
+
for (let i = 0; i < n; i++) {
|
|
69
|
+
const el = rail.nth(i);
|
|
70
|
+
const box = await el.boundingBox().catch(() => null);
|
|
71
|
+
if (!box || box.x > 90 || box.width > 80) continue;
|
|
72
|
+
await el.hover().catch(() => {});
|
|
73
|
+
await page.waitForTimeout(700);
|
|
74
|
+
const texts = await page.locator("[role='tooltip'], [data-slot='tooltip-content'], [data-slot='tooltip-popup']").allInnerTexts().catch(() => []);
|
|
75
|
+
for (const t of texts) if (t.trim()) tips.add(`${t.trim()} (${(await el.getAttribute("href")) || "?"})`);
|
|
76
|
+
}
|
|
77
|
+
await page.mouse.move(600, 600);
|
|
78
|
+
const h = await page.evaluate(harvest);
|
|
79
|
+
h.railTooltips = [...tips];
|
|
80
|
+
h.url = page.url();
|
|
81
|
+
screens.push(h);
|
|
82
|
+
console.log(`\n${h.url}\n title: ${h.title}\n headings: ${h.headings.join(" · ")}\n rail tooltips: ${h.railTooltips.join(" · ") || "(none rendered)"}\n nav: ${h.nav.slice(0, 20).join(" · ")}\n buttons/links: ${h.buttons.slice(0, 60).join(" · ")}${h.fields.length ? `\n fields: ${h.fields.join(" · ")}` : ""}${h.dialogs.filter(Boolean).length ? `\n dialogs: ${h.dialogs.filter(Boolean).join(" · ")}` : ""}`);
|
|
83
|
+
}
|
|
84
|
+
await ctx.close();
|
|
85
|
+
|
|
86
|
+
const out = { harvestedAt: new Date().toISOString(), screens };
|
|
87
|
+
fs.writeFileSync(path.join(dir, "vocab.json"), JSON.stringify(out, null, 2) + "\n");
|
|
88
|
+
console.log(`\nvocab.json written (${screens.length} screen${screens.length === 1 ? "" : "s"}). Narration may use only these nouns.`);
|