demobite 1.3.1 → 1.5.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/README.md +10 -13
- package/launcher/index.mjs +2 -1
- package/package.json +1 -1
- package/scripts/record.mjs +38 -2
- package/skill/SKILL.md +68 -34
- package/skill/scripts/briefs.mjs +3 -1
- package/skill/scripts/manifest.mjs +13 -5
- package/skill/scripts/retake.mjs +2 -2
- package/skill/scripts/rules.mjs +60 -0
- package/skill/scripts/stage-wait.mjs +53 -8
- package/skill/scripts/status.mjs +33 -17
- package/skill/scripts/upload.mjs +64 -67
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Ask your coding agent to record your product. Edit the result. Download the MP4.**
|
|
4
4
|
|
|
5
|
-
Your agent drives a real browser on your machine
|
|
5
|
+
Your agent drives a real browser on your machine, records the flow you describe, and delivers the take into your free DemoBites account as an editable video, with text-to-speech, zooms, captions, and MP4 export included.
|
|
6
6
|
|
|
7
7
|
**Sign in to your app locally. You do not hand DemoBites your app password or upload your recording browser profile.**
|
|
8
8
|
|
|
@@ -28,7 +28,7 @@ Before installing:
|
|
|
28
28
|
|
|
29
29
|
- **Node.js 18 or newer** and npm.
|
|
30
30
|
- **Google Chrome**, recommended. The recorder also supports Playwright's Chromium.
|
|
31
|
-
- A **free DemoBites account
|
|
31
|
+
- A **free DemoBites account**. The recorder works only when this project is connected to it.
|
|
32
32
|
|
|
33
33
|
Run these commands from your project directory:
|
|
34
34
|
|
|
@@ -61,7 +61,7 @@ For Cursor or Codex, make the [recorder instructions](skill/SKILL.md) available
|
|
|
61
61
|
1. **Describe the flow.** Give your agent the app URL and what you want to demonstrate.
|
|
62
62
|
2. **Approve the storyboard.** Review the steps and estimated length before filming. If your app needs a login, sign in yourself in the recorder's local browser window.
|
|
63
63
|
3. **Let it record.** The agent checks the flow and films it. Recording normally runs in the background.
|
|
64
|
-
4. **
|
|
64
|
+
4. **Watch it arrive.** The take is delivered by itself and becomes a Bite, an editable video project. The terminal prints a link to your Demos grid in DemoBites, where the take's card shows it coming in. If your account is out of recording minutes, the take is kept and waits on the same grid until minutes are back.
|
|
65
65
|
5. **Tweak and download.** In the Studio, change a sentence, generate its voice, adjust the zooms or timing, and export your MP4.
|
|
66
66
|
|
|
67
67
|
The storyboard's narration is a starting point. DemoBites rewrites and fits it to the footage; you can edit the final wording in the Studio.
|
|
@@ -103,24 +103,18 @@ The recorder uses a separate, persistent Chrome profile at `.recorder/profile/`.
|
|
|
103
103
|
| --- | --- |
|
|
104
104
|
| Your app's browser session | Stays in the local recording profile; the standard local workflow does not upload that profile to DemoBites. |
|
|
105
105
|
| DemoBites recorder key | Saved in `.recorder/config.json` and used to authenticate recorder requests to DemoBites. This is separate from your app login. |
|
|
106
|
-
| Footage and recording metadata | Uploaded to DemoBites for
|
|
106
|
+
| Footage and recording metadata | Uploaded to DemoBites for processing, including the storyboard, narration intent, cursor events, and camera targets. |
|
|
107
107
|
|
|
108
108
|
Recording happens locally. Studio processing and editing use DemoBites online. Anything visible or typed into the recorded flow can appear in the footage or recording metadata, so use suitable demo data. Your coding agent's own data handling still follows its provider and your settings.
|
|
109
109
|
|
|
110
110
|
Keep `.recorder/` and take directories out of version control. To disconnect the recorder, run `npx demobite logout`.
|
|
111
111
|
|
|
112
|
-
## Local output without a DemoBites account
|
|
113
|
-
|
|
114
|
-
The repository also contains a [standalone recorder skill](recorder/SKILL.md) and local finishing scripts. These produce a local MP4; optional voiceover uses your own ElevenLabs key.
|
|
115
|
-
|
|
116
|
-
This is an advanced, manual workflow. The default `npx demobite@latest` command installs the Studio-connected skill, not the standalone skill. The standalone finishing reference contains legacy instructions; use the standalone skill as the entry point rather than treating it as a second ending of the installed Studio skill.
|
|
117
|
-
|
|
118
112
|
## A few boundaries
|
|
119
113
|
|
|
120
114
|
- The agentic recorder films **browser workflows**. It does not record native desktop apps or phone screens.
|
|
121
115
|
- The Studio-connected skill is designed for short demos, usually **30–45 seconds**, with a **90-second maximum per Bite**. Split longer stories into separate videos.
|
|
122
116
|
- Some sites block automated browsers. The workflow may need a human sign-in or verification step, and some sites may refuse recording.
|
|
123
|
-
- Filming does not publish anything. You
|
|
117
|
+
- Filming does not publish anything. You approve the storyboard before anything is filmed; the delivered Bite stays in your workspace, and public publishing is a separate action.
|
|
124
118
|
|
|
125
119
|
## Optional: Retake and management
|
|
126
120
|
|
|
@@ -130,7 +124,11 @@ When your UI changes, **Retake** can refilm an existing Bite from its saved reci
|
|
|
130
124
|
npx demobite retake <biteId> --note "Export moved to the header"
|
|
131
125
|
```
|
|
132
126
|
|
|
133
|
-
**
|
|
127
|
+
**In your app's repository.** Run the skill from the project that is your app and the agent reads the routes, the navigation, the control labels and the data handlers before it plans, then verifies everything on the live page. Without the code it works from the live app alone and asks when it cannot find a screen.
|
|
128
|
+
|
|
129
|
+
**Workspace rules.** A workspace admin can write standing rules for the recorder in plain words in the DemoBites settings tab Agentic Recorder Rules, one per line: what to mask, which pages never to open, which words to use. The agent reads them at the start of every take and applies them under its own filming laws; `npx demobite rules` prints them. Every take records the rules version it was filmed under.
|
|
130
|
+
|
|
131
|
+
**Batches of briefs.** When a pull request in DemoBites produces approved briefs, your agent claims them, films one take per brief after you approve each storyboard, and delivers each take. Like every take, a delivered take becomes a Bite in DemoBites by itself and shows up on your Demos grid. Nothing is published or shared.
|
|
134
132
|
|
|
135
133
|
The package also includes a DemoBites management MCP for releases and centers:
|
|
136
134
|
|
|
@@ -149,7 +147,6 @@ The default installer also attempts to register that MCP with Claude Code when t
|
|
|
149
147
|
| `launcher/` | CLI setup, connection commands, and MCP registration. |
|
|
150
148
|
| `skill/` | Studio-connected recorder instructions, login, upload, and Retake. |
|
|
151
149
|
| `scripts/` | Shared browser recording, trimming, and timing calibration. |
|
|
152
|
-
| `recorder/` | Standalone skill and local video finishing tools. |
|
|
153
150
|
|
|
154
151
|
The same package is published under three names, `demobite`, `agentic-recorder` and `demobites`, always at the same version; `npx agentic-recorder@latest` and `npx demobites@latest` do exactly what `npx demobite@latest` does. Run it again to update the installed skill. For publishing instructions, see [RELEASING.md](RELEASING.md).
|
|
155
152
|
|
package/launcher/index.mjs
CHANGED
|
@@ -158,7 +158,8 @@ if (arg === "retake") {
|
|
|
158
158
|
|
|
159
159
|
// Batch of briefs (2026-09-13): `npx demobite briefs list <batchId>` etc. and
|
|
160
160
|
// `npx demobite status <takeDir|stagingId>` hand straight to the skill scripts.
|
|
161
|
-
|
|
161
|
+
// WORKSPACE RULES (1.4): `npx demobite rules` prints the workspace's standing rules.
|
|
162
|
+
if (arg === "briefs" || arg === "status" || arg === "rules") {
|
|
162
163
|
let cfg = readCfg();
|
|
163
164
|
if (!cfg?.api_key) {
|
|
164
165
|
console.log("\n Not connected yet — linking this machine to DemoBites first…\n");
|
package/package.json
CHANGED
package/scripts/record.mjs
CHANGED
|
@@ -20,6 +20,38 @@ if (!outArg || !storyArg) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const STORYBOARD = JSON.parse(fs.readFileSync(storyArg, "utf8"));
|
|
23
|
+
|
|
24
|
+
// BROWSER HEADER (founder ruling 2026-09-16, final): the recording stays
|
|
25
|
+
// 1920x1080 and DemoBites ADDS the dark macOS header on top at ingest, as it
|
|
26
|
+
// does for an uploaded video; the studio places the taller container inside
|
|
27
|
+
// its 16:9 canvas. The header is on by default; a workspace rule negating it
|
|
28
|
+
// ("No browser header on the takes.", same phrase test as the cloud runner)
|
|
29
|
+
// turns it off, and so does storyboard.browserHeader === false. The manifest
|
|
30
|
+
// and the recipe carry browserHeader for the ingest to read. The cloud runner
|
|
31
|
+
// passes --browser-header=on|off; --viewport=WxH and storyboard.viewport can
|
|
32
|
+
// still change the frame when a lane needs it, never by default.
|
|
33
|
+
const NO_HEADER_RE = /no browser header|without (a |the )?browser header|browser header off/i;
|
|
34
|
+
function resolveDesign() {
|
|
35
|
+
const flag = process.argv.slice(2).find((a) => a.startsWith("--viewport="));
|
|
36
|
+
const m = flag && /^--viewport=(\d{3,4})x(\d{3,4})$/.exec(flag);
|
|
37
|
+
if (m) return { width: Number(m[1]), height: Number(m[2]), why: "--viewport" };
|
|
38
|
+
const v = STORYBOARD.viewport;
|
|
39
|
+
if (v && Number.isInteger(v.width) && Number.isInteger(v.height)) return { width: v.width, height: v.height, why: "storyboard.viewport" };
|
|
40
|
+
return { width: 1920, height: 1080, why: "default" };
|
|
41
|
+
}
|
|
42
|
+
function resolveHeader() {
|
|
43
|
+
// --browser-header=on|off (the cloud runner passes it) → storyboard.browserHeader → the rule → on.
|
|
44
|
+
const flag = process.argv.slice(2).find((a) => a.startsWith("--browser-header="));
|
|
45
|
+
if (flag) { const v = flag.slice("--browser-header=".length).toLowerCase(); if (v === "on" || v === "off") return { on: v === "on", why: "--browser-header" }; console.error(`--browser-header must be on or off (got ${v}); ignoring it`); }
|
|
46
|
+
if (typeof STORYBOARD.browserHeader === "boolean") return { on: STORYBOARD.browserHeader, why: "storyboard.browserHeader" };
|
|
47
|
+
let rulesText = "";
|
|
48
|
+
try { rulesText = JSON.parse(fs.readFileSync(path.resolve(".recorder/rules.json"), "utf8")).text ?? ""; } catch {}
|
|
49
|
+
if (NO_HEADER_RE.test(rulesText)) return { on: false, why: "workspace rule: no browser header" };
|
|
50
|
+
return { on: true, why: "default, DemoBites adds the browser header at ingest" };
|
|
51
|
+
}
|
|
52
|
+
const { why: DESIGN_WHY, ...DESIGN } = resolveDesign();
|
|
53
|
+
const { on: BROWSER_HEADER, why: HEADER_WHY } = resolveHeader();
|
|
54
|
+
console.log(`viewport ${DESIGN.width}x${DESIGN.height} (${DESIGN_WHY}); browser header: ${BROWSER_HEADER ? "yes" : "no"} (${HEADER_WHY})`);
|
|
23
55
|
const ACTIONS = new Set(["goto", "settle", "scroll", "click", "hover", "type", "expect"]);
|
|
24
56
|
if (!Array.isArray(STORYBOARD.steps) || STORYBOARD.steps.length === 0) {
|
|
25
57
|
console.error("Storyboard has no steps.");
|
|
@@ -51,7 +83,11 @@ try {
|
|
|
51
83
|
const cfgPath = path.resolve(".recorder/config.json");
|
|
52
84
|
const cfg = fs.existsSync(cfgPath) ? JSON.parse(fs.readFileSync(cfgPath, "utf8")) : {};
|
|
53
85
|
// Only the public shape — NEVER the api_key or workspace.
|
|
54
|
-
|
|
86
|
+
// WORKSPACE RULES (1.4): the storyboard names the rules version it was written under
|
|
87
|
+
// (rules.mjs prints it); the recipe carries it so a re-take can refuse an older rule set.
|
|
88
|
+
let rulesVersion = Number.isInteger(STORYBOARD.rulesVersion) ? STORYBOARD.rulesVersion : null;
|
|
89
|
+
if (rulesVersion === null) { try { const r = JSON.parse(fs.readFileSync(path.resolve(".recorder/rules.json"), "utf8")); if (Number.isInteger(r.version)) rulesVersion = r.version; } catch {} }
|
|
90
|
+
const config = { app: STORYBOARD.app ?? cfg.app ?? null, url: STORYBOARD.url ?? cfg.url ?? null, frame: DESIGN, browserHeader: BROWSER_HEADER, base: cfg.base ?? "https://app.demobites.com", ...(rulesVersion !== null ? { rulesVersion } : {}) };
|
|
55
91
|
fs.writeFileSync(path.join(DIR, "recipe.json"), JSON.stringify({ version: 1, lane: (process.env.CDP_WS_URL || STORYBOARD.cdpWsUrl) ? "cloud" : "skill", engine: ENGINE_VERSION, config }, null, 2));
|
|
56
92
|
} catch (e) { console.error("recipe.json not written:", e.message); }
|
|
57
93
|
fs.mkdirSync(DIR, { recursive: true });
|
|
@@ -86,7 +122,6 @@ fs.mkdirSync(DIR, { recursive: true });
|
|
|
86
122
|
// display-level capture: a separate chapter. The coordinate plumbing below is
|
|
87
123
|
// kept so flipping this constant is the only change when it lands.
|
|
88
124
|
const SUPERSAMPLE = 1;
|
|
89
|
-
const DESIGN = { width: 1920, height: 1080 };
|
|
90
125
|
const VIEW = { width: DESIGN.width * SUPERSAMPLE, height: DESIGN.height * SUPERSAMPLE };
|
|
91
126
|
// Persistent camera-browser profile: the human's signed-in sessions live here.
|
|
92
127
|
// The auth checkpoint (SKILL.md) fills it; record only ever reads it.
|
|
@@ -281,6 +316,7 @@ const manifest = {
|
|
|
281
316
|
title: STORYBOARD.title ?? null,
|
|
282
317
|
url: STORYBOARD.url ?? null,
|
|
283
318
|
frame: DESIGN,
|
|
319
|
+
browserHeader: BROWSER_HEADER,
|
|
284
320
|
supersample: SUPERSAMPLE,
|
|
285
321
|
started_at: new Date(T0).toISOString(),
|
|
286
322
|
steps: [],
|
package/skill/SKILL.md
CHANGED
|
@@ -9,7 +9,7 @@ You are the camera operator, the director, and the editor. You film a real brows
|
|
|
9
9
|
|
|
10
10
|
All scripts live in `scripts/` beside this file. They are plain Node ESM. Requirements: Node 18+. `npx demobite` installs Playwright, ffmpeg and ffprobe beside the skill; every script resolves the media tools through `scripts/media-tools.mjs` (a compatible system build first, then the packaged one). Never call `ffmpeg` or `ffprobe` by bare name in a new script. Run every script from the project directory so `.recorder/` lands next to the project.
|
|
11
11
|
|
|
12
|
-
Follow the phases in order. Never skip the storyboard approval
|
|
12
|
+
Follow the phases in order. Never skip the storyboard approval: the human's yes on the storyboard, in the chat, is the word for the take. After that the take is delivered by itself. It becomes a bite in DemoBites without a second click, and the human watches it come in on their Demos grid (`<base>/demos`). Never send them to a preview page to approve it.
|
|
13
13
|
|
|
14
14
|
## Phase 0: Auth gate, ALWAYS FIRST — with the human's word
|
|
15
15
|
|
|
@@ -39,10 +39,13 @@ surprise the human with a browser page):**
|
|
|
39
39
|
for their word.
|
|
40
40
|
|
|
41
41
|
Gating first is deliberate: fail before minutes of filming and know the
|
|
42
|
-
target workspace up front.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
the
|
|
42
|
+
target workspace up front. Plan limits are NOT your concern and never block
|
|
43
|
+
you: staging always succeeds. When the account has no recording minutes
|
|
44
|
+
left, DemoBites KEEPS the take and it waits on the Demos grid, where a strip
|
|
45
|
+
shows the waiting takes with the upgrade door. `upload.mjs` prints one line
|
|
46
|
+
for that ("Kept. The take is waiting for recording minutes..."); relay it
|
|
47
|
+
as it is. Never add numbers, prices or quota talk of your own, the product
|
|
48
|
+
does the talking.
|
|
46
49
|
To sign out: `node scripts/login.mjs --logout` (revokes the key server-side
|
|
47
50
|
AND strips it locally). "Log me out of DemoBites" means exactly that command.
|
|
48
51
|
|
|
@@ -52,7 +55,7 @@ Look for `.recorder/config.json` next to the project. If it exists, use it and a
|
|
|
52
55
|
|
|
53
56
|
- **app**: the product's name as it should appear in titles.
|
|
54
57
|
- **url**: the starting URL of the flow.
|
|
55
|
-
- **frame**:
|
|
58
|
+
- **frame**: 1920x1080, never asked. DemoBites adds the dark macOS browser header on top of every take at ingest, as for an uploaded video; a workspace rule that negates it ("No browser header on the takes.") turns it off. The manifest and the recipe carry `frame` and `browserHeader` for the record.
|
|
56
59
|
- **base**: defaults to `https://app.demobites.com`, only ask if the human mentions a different environment.
|
|
57
60
|
|
|
58
61
|
Write the answers to `.recorder/config.json` and never ask again:
|
|
@@ -61,13 +64,29 @@ Write the answers to `.recorder/config.json` and never ask again:
|
|
|
61
64
|
{
|
|
62
65
|
"app": "Acme",
|
|
63
66
|
"url": "https://app.acme.com",
|
|
64
|
-
"frame": { "width": 1920, "height": 1080 },
|
|
65
67
|
"base": "https://app.demobites.com"
|
|
66
68
|
}
|
|
67
69
|
```
|
|
68
70
|
|
|
69
71
|
`login.mjs` later merges `api_key` and `workspace` into this same file and chmods it 600. Treat the file as secret once a key is in it. Never print `api_key`.
|
|
70
72
|
|
|
73
|
+
## Phase 1b: Workspace rules, EVERY run
|
|
74
|
+
|
|
75
|
+
The workspace admin can write standing rules for the recorder in plain words, one per line, in the DemoBites settings tab "Agentic Recorder Rules" ("Mask any number with a dollar sign.", "Never open the Billing page.", "Say Update Center, never changelog."). They apply to every take filmed in that workspace. At the start of EVERY run, batch or free prompt, right after the key resolves the workspace and BEFORE any storyboard:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
node scripts/rules.mjs [<takeDir>] # fetches the rules fresh, writes .recorder/rules.json, prints them numbered
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
It fetches `GET <base>/api/recorder/rules` with the recorder key (never cached). When the fetch fails it falls back to the snapshot the claim wrote into `<takeDir>/brief.json` (`workspaceRules`), then to no rules, and prints which of the three it used; repeat that line in your report. Then:
|
|
82
|
+
|
|
83
|
+
- Fold the rules into your storyboard thinking as **standing rules of the workspace, BELOW the filming laws**. A rule never lifts a law: an irreversible action stays pointed at and never pressed, Cancel is never a beat, the human still approves every storyboard, and nothing is published or shared.
|
|
84
|
+
- Every beat a rule shaped carries `"rules": [1, 3]` (the rule numbers as printed), and the storyboard carries `"rulesVersion": <version>` at the top. The presentation shows "Rules applied: 1, 3" on those beats and lists the rules once. A rule that cannot be honoured in this flow is said out loud in the presentation, never silently dropped.
|
|
85
|
+
- The prep, take and cleanup legs obey the rules too (a "never open" page is never opened, not even off camera).
|
|
86
|
+
- Masking rules ("mask emails", "hide amounts") become `hideCss` rules or text masks with what the skill has today: find the element on the live page (Phase 4) and blank it with `color: transparent` plus a soft `text-shadow`, `filter: blur(6px)`, or `display: none` when the element may vanish. Keep masked words out of `narration` and `on_screen` too.
|
|
87
|
+
- Vocabulary rules ("say X, never Y") win over vocab.json for that noun, as long as X appears in the app or the rule; the rule is the admin's word.
|
|
88
|
+
- `record.mjs` copies `rulesVersion` into `recipe.config`, and `manifest.mjs` into the stage manifest, so a re-take can tell which rule set it was filmed under.
|
|
89
|
+
|
|
71
90
|
## Phase 2: Target-app sign-in, only when a login wall appears
|
|
72
91
|
|
|
73
92
|
The camera browser uses a persistent profile at `.recorder/profile`. Signed in sessions survive between takes.
|
|
@@ -92,6 +111,17 @@ node scripts/vocab.mjs <takeDir> <url of every screen the take visits>
|
|
|
92
111
|
|
|
93
112
|
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
113
|
|
|
114
|
+
### Phase 3b: When you sit in the app's repository, read it FIRST
|
|
115
|
+
|
|
116
|
+
A developer runs this skill from the project that IS the app (founder, 2026-09-17: developers have the code; product and marketing people do not). When the working directory holds the app's source, use it before you write a storyboard, the way you would read a map:
|
|
117
|
+
|
|
118
|
+
- **Routes and navigation**: the router (app or pages directory, the route table, the nav component) tells you the address of every screen the brief names. Start each storyboard on that address; never open the root and search.
|
|
119
|
+
- **Controls and selectors**: the components give you the real labels, titles, roles and test ids of the controls the flow presses. Prefer a selector from the source (text, title, aria, data attributes) over one guessed from a screenshot; never a positional chain.
|
|
120
|
+
- **Data and its undo**: the handlers behind Create, Add, Move, Delete tell you what an action makes and what puts it back. That is the `prep[]` and `cleanup[]` plan; write it from the code, then verify it on the live page.
|
|
121
|
+
- **Gates**: feature flags and plan gates in the code tell you which screens need a plan or a role before the camera walks into a wall.
|
|
122
|
+
|
|
123
|
+
The live page stays the truth: what the code names a thing is not what the demo calls it (Phase 3a, vocab.json wins for every noun in narration and on_screen), and a route in the code is not a screen until you have seen it deployed at the target address. Read only what the flow touches (routing, navigation, UI strings, the mutation handlers), not the whole codebase. Never put code names, file paths, flags or internal state names into narration or on screen. Without a repository (a brief pasted into an empty project) you have the live app alone: harvest, probe, and ask when a screen cannot be found.
|
|
124
|
+
|
|
95
125
|
### LAW: the camera shows an action to its end
|
|
96
126
|
|
|
97
127
|
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).
|
|
@@ -134,7 +164,7 @@ Holding shots to cover estimated lines is what produced a 60 second take with th
|
|
|
134
164
|
|
|
135
165
|
**LAW: page transitions are cut and faded, never watched.** When the story moves to another page, the viewer sees page one, a short fade, page two — never the loading blank. record.mjs stamps every mid-take `goto` and manifest.mjs cuts that window out with a fade (`cuts` in the wire manifest); the ingestion lays it on the bite as a timeline cut. No zoom and no narration live inside a cut (the studio forbids both), so put the line about the new page on the beat AFTER it has landed, and say goodbye to the old page BEFORE the goto.
|
|
136
166
|
|
|
137
|
-
Storyboard schema:
|
|
167
|
+
Storyboard schema (`rulesVersion` and per-step `rules` come from Phase 1b):
|
|
138
168
|
|
|
139
169
|
```json
|
|
140
170
|
{
|
|
@@ -153,7 +183,7 @@ Storyboard schema:
|
|
|
153
183
|
}
|
|
154
184
|
```
|
|
155
185
|
|
|
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`.
|
|
186
|
+
Step fields: `action` is one of `goto | settle | scroll | click | hover | type | expect`. `rules` (optional, any step) lists the numbers of the workspace rules that shaped the beat; the storyboard's top-level `rulesVersion` names the rule set (Phase 1b). **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`.
|
|
157
187
|
|
|
158
188
|
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
189
|
|
|
@@ -273,23 +303,23 @@ Send the TRIMMED CLEAN take into DemoBites. The studio owns the look: NO backdro
|
|
|
273
303
|
node scripts/trim.mjs <takeDir> # raw.webm -> clean.mp4, trim from record_from ONLY
|
|
274
304
|
node scripts/calibrate.mjs <takeDir> # anchor-measure the clock against the footage
|
|
275
305
|
node scripts/manifest.mjs <takeDir> # internal manifest -> manifest.demobites.json (wire schema)
|
|
276
|
-
node scripts/upload.mjs <takeDir> # STAGE the take
|
|
306
|
+
node scripts/upload.mjs <takeDir> # STAGE + DELIVER the take, open the Demos grid (refuses an uncleaned take)
|
|
277
307
|
```
|
|
278
308
|
|
|
279
|
-
**
|
|
280
|
-
ZIP for ingestion plus a playable MP4 for the player), opens the DemoBites
|
|
281
|
-
preview page in the human's browser, and polls while they decide THERE.
|
|
282
|
-
Approve on that page runs the ingest; Discard deletes the staged take and this
|
|
283
|
-
script reports it so you adjust and refilm. There is no local review.html for
|
|
284
|
-
this ending — the preview page is the review.
|
|
309
|
+
**Every take is delivered by itself** (founder ruling 2026-09-25). `upload.mjs` stages the take (the ZIP for ingestion plus a playable MP4), uploads both, then calls the delivery door. There is no Approve click and no preview page to send the human to: the word was the storyboard yes in the chat. The script opens the human's Demos grid (`<base>/demos`, `--no-open` skips it) and prints one of two lines:
|
|
285
310
|
|
|
286
|
-
|
|
311
|
+
- **Delivered:** `Delivered. Watch it come in: <base>/demos`. The card for the take shows it ingesting on the grid. Without `--stage-only` the script then waits for the bite to finish and prints the receipt (see the law below).
|
|
312
|
+
- **Kept, waiting for minutes:** `Kept. The take is waiting for recording minutes (back on <date>). Watch it here: <base>/demos`. The account has no recording minutes left. The take is safe on the server and waits on the same grid, next to the upgrade door. Tell the human exactly that, nothing more; do not refilm, do not stage it again.
|
|
313
|
+
|
|
314
|
+
Any other answer is an error: the script says what failed and `node scripts/status.mjs <takeDir>` tries the delivery again (the server is idempotent). `staged.json` in the take directory records `stagingId`, `dashboardUrl`, `delivered`, `waiting`, `resetsAt` and `biteId` (plus `previewUrl`, kept only for older readers; never give it to the human). Tell the human to watch the Demos grid, never the preview link.
|
|
315
|
+
|
|
316
|
+
If the take is wrong (private data on screen, a missing step in the flow), say so, adjust and film again; the human removes the unwanted bite in the app.
|
|
287
317
|
|
|
288
318
|
### LAW: never hand over a studio link before the bite is ready
|
|
289
319
|
|
|
290
320
|
`ingest` only STARTS the pipeline. Transcode, rescript, fit, synthesize and finalize all happen after the call returns, so a link printed at that moment leads to a half built bite with grey silent rows, which is exactly what the founder walked into on 2026-08-08.
|
|
291
321
|
|
|
292
|
-
`upload.mjs`
|
|
322
|
+
After a delivered take, `upload.mjs` polls `/api/recorder/status` until the bite reaches `completed` and prints what actually landed. **Read that line before you say anything to the human.** It reports `narrationReady/narrationTotal` segments with real audio behind them, and the camera shot count. If narration is 0, or ready is below total, or shots are 0, say so plainly and investigate. Do not pass on a link with a warning above it as though it were a success.
|
|
293
323
|
|
|
294
324
|
## Phase 7: Re-take (Launch plan and up)
|
|
295
325
|
|
|
@@ -312,10 +342,9 @@ Laws for a re-take:
|
|
|
312
342
|
- **The narration in the recipe is the ORIGINAL intent.** Do not rewrite it to taste: the server replaces it
|
|
313
343
|
with the bite's current text per step. Only remove lines whose beats you dropped.
|
|
314
344
|
- **Same pacing laws apply** (intro, narrate the path, linger, cut and fade on page transitions).
|
|
315
|
-
- **The
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
the promoted export stays as it is until a version is published.
|
|
345
|
+
- **The re-take is delivered like every take.** The new recording replaces the current one in that bite by
|
|
346
|
+
itself; the previous recording is kept for rollback, never overwritten, and the promoted export stays as it
|
|
347
|
+
is until a version is published. The human watches it on the Demos grid.
|
|
319
348
|
|
|
320
349
|
## Batch of briefs (GitHub PR → demos)
|
|
321
350
|
|
|
@@ -326,22 +355,22 @@ node scripts/briefs.mjs list <batchId> [--paste bundle.txt] # the approve
|
|
|
326
355
|
node scripts/briefs.mjs claim <batchId> <briefId> # mints an attempt, creates take-<briefId>-r<revision>/brief.json
|
|
327
356
|
node scripts/briefs.mjs event <takeDir> planning|awaiting_storyboard_approval|recording|uploading|failed|cancelled [--note "..."]
|
|
328
357
|
node scripts/briefs.mjs release <takeDir> # give the brief back (cancelled)
|
|
329
|
-
node scripts/upload.mjs <takeDir> --stage-only --no-open # deliver: the take becomes a bite by itself, do not wait
|
|
358
|
+
node scripts/upload.mjs <takeDir> --stage-only --no-open # deliver: the take becomes a bite by itself (or waits for minutes), do not wait
|
|
330
359
|
node scripts/status.mjs <takeDir> # later: wait for the bite to finish (retries a failed delivery)
|
|
331
|
-
node scripts/status.mjs --all # one look at every delivered take here
|
|
360
|
+
node scripts/status.mjs --all # one look at every delivered or waiting take here
|
|
332
361
|
```
|
|
333
362
|
|
|
334
363
|
The procedure, in order:
|
|
335
364
|
|
|
336
365
|
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.
|
|
337
366
|
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`.
|
|
338
|
-
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.
|
|
367
|
+
3. Run `rules.mjs <takeDir>` (Phase 1b; the claim stored the batch's rules snapshot in brief.json as the fallback) and `vocab.mjs` over the screens each brief visits, then write every storyboard (Phase 3) with the brief as the spec, the workspace rules below the laws, 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.
|
|
339
368
|
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`.
|
|
340
|
-
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, stages with the attempt on the payload, and after the two uploads calls the delivery route: the take becomes a bite in DemoBites by itself, no Approve click, and the line reads `
|
|
369
|
+
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, stages with the attempt on the payload, and after the two uploads calls the delivery route, as for every take: the take becomes a bite in DemoBites by itself, no Approve click, and the line reads `Delivered. Watch it come in: <base>/demos`. With no recording minutes left the line reads `Kept. The take is waiting for recording minutes...`; the take waits on the grid, go on with the next brief. It writes `staged.json` with the staging id, the grid link, and the bite id or the waiting state.
|
|
341
370
|
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.
|
|
342
|
-
7. When all takes are delivered, tell the human: N takes were delivered and are becoming bites in DemoBites by themselves, with the bite ids. `status.mjs --all` shows where each stands; `status.mjs <takeDir>` waits for one to finish and prints what landed (the Phase 6 receipt law holds: no studio link before the bite is completed). A take the server would not deliver (upload.mjs printed the error)
|
|
371
|
+
7. When all takes are delivered, tell the human: N takes were delivered and are becoming bites in DemoBites by themselves, with the bite ids, and they can watch them come in on the Demos grid (`<base>/demos`). Name any take that is kept, waiting for recording minutes; it waits on the same grid. `status.mjs --all` shows where each stands; `status.mjs <takeDir>` waits for one to finish and prints what landed (the Phase 6 receipt law holds: no studio link before the bite is completed). A take the server would not deliver (upload.mjs printed the error): `status.mjs <takeDir>` tries the delivery again. Deliver each take; never publish, never share, never send invitations.
|
|
343
372
|
|
|
344
|
-
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 delivered or 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 delivered or staged unless the human asked for a new take (`upload.mjs --supersede`).
|
|
373
|
+
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 delivered, waiting for minutes, or 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 delivered, waiting or staged unless the human asked for a new take (`upload.mjs --supersede`).
|
|
345
374
|
|
|
346
375
|
## The wire manifest (fixed contract, version 2)
|
|
347
376
|
|
|
@@ -387,6 +416,9 @@ PUT <base>/api/recorder/device { device_code } (poll every `interval`
|
|
|
387
416
|
DELETE <base>/api/recorder/key (Authorization: Bearer <api_key>)
|
|
388
417
|
-> { revoked: true } (logout)
|
|
389
418
|
|
|
419
|
+
GET <base>/api/recorder/rules (Authorization: Bearer <api_key>) // WORKSPACE RULES (1.4): never cached
|
|
420
|
+
-> { workspaceId, rules: string | null, version, updatedAt } (the claim's api.rules is the same url; workspaceRules on the claim is the snapshot)
|
|
421
|
+
|
|
390
422
|
GET <base>/api/recorder/recipe?biteId=<id> (Authorization: Bearer <api_key>) // RE-TAKE: the bite's recipe
|
|
391
423
|
-> { storyboard, config:{app,url,frame}, manifest, engine } (404 no recipe; 402/403 plan gate)
|
|
392
424
|
|
|
@@ -396,11 +428,13 @@ PUT <base>/api/recorder/stage (Authorization: Bearer <api_key>)
|
|
|
396
428
|
-> { stagingId, uploadUrl, previewUploadUrl, videoKey, previewUrl }
|
|
397
429
|
|
|
398
430
|
GET <base>/api/recorder/stage?id=<stagingId> (Authorization: Bearer <api_key>)
|
|
399
|
-
-> { status: 'pending'|'approving'|'approved'|'delivered'|'rejected', biteId, biteUKey, biteStatus, studioUrl }
|
|
431
|
+
-> { status: 'pending'|'approving'|'approved'|'delivered'|'waiting'|'rejected', biteId, biteUKey, biteStatus, studioUrl, resetsAt? }
|
|
400
432
|
|
|
401
|
-
PUT <base>/api/recorder/stage/<stagingId>/uploaded (Authorization: Bearer <api_key>) // DELIVERY:
|
|
402
|
-
{} // the url comes from the claim's api.uploaded ("{origin}/api/recorder/stage/{id}/uploaded"); this path is the fallback
|
|
403
|
-
->
|
|
433
|
+
PUT <base>/api/recorder/stage/<stagingId>/uploaded (Authorization: Bearer <api_key>) // DELIVERY: EVERY take, after both uploads (1.5)
|
|
434
|
+
{} // the url comes from the claim's api.uploaded ("{origin}/api/recorder/stage/{id}/uploaded") when there is one; this path is the fallback
|
|
435
|
+
-> 202 { delivered:true, biteId, studioUrl, dashboardUrl } (also 200 { biteId, videoId } from older servers)
|
|
436
|
+
| 403 { kept:true, waiting:'minutes', resetsAt, dashboardUrl:'/demos', stagingId } (no recording minutes: kept, waits on the grid)
|
|
437
|
+
| 200 { pending:true } (older server: waits for the word in the app) | 404/409 { error }
|
|
404
438
|
// idempotent: a repeat returns the same bite
|
|
405
439
|
|
|
406
440
|
GET <base>/api/recorder/status?biteId=<id> (Authorization: Bearer <api_key>)
|
|
@@ -414,7 +448,7 @@ The upload zip contains exactly one file: `clean.mp4` stored as `recording.mp4`.
|
|
|
414
448
|
|
|
415
449
|
## Standing rules
|
|
416
450
|
|
|
417
|
-
- Anything the human sees (storyboard presentation,
|
|
451
|
+
- Anything the human sees (storyboard presentation, questions, reports) uses commas and periods only, no dashes, and real action words. Never orphan a single word on its own line in a heading.
|
|
418
452
|
- Never touch credentials. Never print the api_key. Config and key files are chmod 600.
|
|
419
|
-
- Never
|
|
453
|
+
- Never film without the human's explicit word on the storyboard. That yes, in the chat, is the word for the take: delivery ingests by itself, and the human watches the take on the Demos grid, never on a preview page. For a batch of briefs the word is given on the batch and on each storyboard. Never publish, never share, never send invitations.
|
|
420
454
|
- 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>`.
|
package/skill/scripts/briefs.mjs
CHANGED
|
@@ -142,7 +142,9 @@ if (cmd === "claim") {
|
|
|
142
142
|
const record = {
|
|
143
143
|
batchId, briefId: brief.briefId, revision: brief.revision, contentHash: brief.contentHash, attemptRef: r.json.attemptRef,
|
|
144
144
|
brief: r.json.brief ?? brief, target: r.json.target ?? data.batch?.target ?? null, rules: r.json.rules ?? { maxSeconds: 90 },
|
|
145
|
-
source: data.batch?.source ?? null, api: r.json.api ?? data.api ?? null,
|
|
145
|
+
source: data.batch?.source ?? null, api: r.json.api ?? data.api ?? null,
|
|
146
|
+
// WORKSPACE RULES (1.4): the claim carries a snapshot { version, text }; rules.mjs fetches fresh and falls back to it.
|
|
147
|
+
workspaceRules: r.json.workspaceRules ?? data.workspaceRules ?? null, claimedAt: new Date().toISOString(),
|
|
146
148
|
};
|
|
147
149
|
fs.writeFileSync(path.join(dir, "brief.json"), JSON.stringify(record, null, 2) + "\n");
|
|
148
150
|
console.log(`Claimed ${brief.briefId} r${brief.revision} → ${dir}/brief.json (attempt ${r.json.attemptRef}, at most ${record.rules.maxSeconds}s)`);
|
|
@@ -47,6 +47,8 @@ for (let i = 1; i < args.length; i++) {
|
|
|
47
47
|
const manPath = path.join(dir, "manifest.json");
|
|
48
48
|
if (!fs.existsSync(manPath)) { console.error(`${manPath} not found. Run record.mjs first.`); process.exit(1); }
|
|
49
49
|
const man = JSON.parse(fs.readFileSync(manPath, "utf8"));
|
|
50
|
+
// The recording's own frame (1920x1080; --viewport can change it).
|
|
51
|
+
const FW = man.frame?.width ?? 1920, FH = man.frame?.height ?? 1080;
|
|
50
52
|
const round2 = (x) => Math.round(x * 100) / 100;
|
|
51
53
|
|
|
52
54
|
// TIMEBASE — see the law in trim.mjs. Every time in manifest.json is WALL
|
|
@@ -233,7 +235,7 @@ if (fs.existsSync(cleanPath)) {
|
|
|
233
235
|
if (!m) return null;
|
|
234
236
|
const [w, h, x, y] = m.slice(1).map(Number);
|
|
235
237
|
if (!(w > 0 && h > 0)) return null;
|
|
236
|
-
const changedPx = yavg ? (parseFloat(yavg[1]) / 255) *
|
|
238
|
+
const changedPx = yavg ? (parseFloat(yavg[1]) / 255) * FW * FH * SS * SS : 0;
|
|
237
239
|
return { x: x / SS, y: y / SS, w: w / SS, h: h / SS, changedPx: changedPx / (SS * SS) };
|
|
238
240
|
};
|
|
239
241
|
for (const st of steps) {
|
|
@@ -256,7 +258,7 @@ if (fs.existsSync(cleanPath)) {
|
|
|
256
258
|
console.log(`consequence: step ${st.n} change too sparse (${Math.round(change.changedPx)}px over ${Math.round(change.w)}x${Math.round(change.h)}) — ignored as noise`);
|
|
257
259
|
continue;
|
|
258
260
|
}
|
|
259
|
-
if (change.w * change.h >
|
|
261
|
+
if (change.w * change.h > FW * FH * 0.85) {
|
|
260
262
|
// A navigation (the whole page swapped). The tight control shot must
|
|
261
263
|
// NOT linger clamped over the new page — reset to wide, so the camera
|
|
262
264
|
// "zooms back out" at the cut (founder, 2026-08-09: "you never zoomed
|
|
@@ -267,7 +269,7 @@ if (fs.existsSync(cleanPath)) {
|
|
|
267
269
|
const wideStart = round2(st.click.t);
|
|
268
270
|
const wideEnd = round2(Math.min(duration, st.t_end));
|
|
269
271
|
if (wideEnd - wideStart >= 0.6) {
|
|
270
|
-
camera.push({ t_start: wideStart, t_end: wideEnd, x: 0, y: 0, w:
|
|
272
|
+
camera.push({ t_start: wideStart, t_end: wideEnd, x: 0, y: 0, w: FW, h: FH, n: st.n, label: `${st.label || "click"}, new page` });
|
|
271
273
|
camera.sort((a, b) => a.t_start - b.t_start);
|
|
272
274
|
}
|
|
273
275
|
console.log(`consequence: step ${st.n} navigation — reset to wide at ${wideStart}s`);
|
|
@@ -296,7 +298,7 @@ const mouseEvents = rawEvents
|
|
|
296
298
|
.filter((e) => e.time >= 0 && e.time <= duration);
|
|
297
299
|
const interactions = mouseEvents.length
|
|
298
300
|
? {
|
|
299
|
-
viewport: man.interactions?.viewport ?? { width:
|
|
301
|
+
viewport: man.interactions?.viewport ?? { width: FW, height: FH },
|
|
300
302
|
mouseEvents,
|
|
301
303
|
}
|
|
302
304
|
: null;
|
|
@@ -322,11 +324,17 @@ if (cuts.length > 0) {
|
|
|
322
324
|
camera.push(...clipped.sort((a, b) => a.t_start - b.t_start));
|
|
323
325
|
}
|
|
324
326
|
|
|
327
|
+
// WORKSPACE RULES (1.4): the stage manifest names the rules version the take was filmed under.
|
|
328
|
+
let rulesVersion = null;
|
|
329
|
+
try { const sb = JSON.parse(fs.readFileSync(path.join(dir, "storyboard.json"), "utf8")); if (Number.isInteger(sb.rulesVersion)) rulesVersion = sb.rulesVersion; } catch {}
|
|
325
330
|
const wire = {
|
|
326
331
|
version: 2,
|
|
332
|
+
...(rulesVersion !== null ? { rulesVersion } : {}),
|
|
327
333
|
app: man.app ?? "App",
|
|
328
334
|
title: titleArg ?? man.title ?? `${man.app ?? "App"} demo`,
|
|
329
|
-
frame: { width:
|
|
335
|
+
frame: { width: FW, height: FH },
|
|
336
|
+
// BROWSER HEADER (2026-09-16): true by default; DemoBites adds the dark macOS bar on top at ingest. A workspace rule "No browser header" sets it false.
|
|
337
|
+
...(typeof man.browserHeader === "boolean" ? { browserHeader: man.browserHeader } : {}),
|
|
330
338
|
duration,
|
|
331
339
|
steps,
|
|
332
340
|
camera,
|
package/skill/scripts/retake.mjs
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
// Usage: node retake.mjs <biteId> [--note "what changed"] [--take <dir>]
|
|
9
9
|
//
|
|
10
10
|
// Flow: recipe -> storyboard.json -> record -> trim -> calibrate (gate) ->
|
|
11
|
-
// manifest -> upload --retake-of <biteId> (stages
|
|
12
|
-
//
|
|
11
|
+
// manifest -> upload --retake-of <biteId> (stages and delivers into the bite;
|
|
12
|
+
// the person watches it on the Demos grid).
|
|
13
13
|
//
|
|
14
14
|
// A step that no longer resolves makes record.mjs FAIL LOUDLY at that step.
|
|
15
15
|
// That is the moment for the agent to look at the live page, fix the
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// WORKSPACE RULES (1.4, founder law 2026-09-15): standing rules the workspace
|
|
2
|
+
// admin wrote in plain words, one per line, in the "Agentic Recorder Rules"
|
|
3
|
+
// settings tab. Every run reads them FIRST, before any storyboard:
|
|
4
|
+
//
|
|
5
|
+
// node rules.mjs [<takeDir>] fetch GET <base>/api/recorder/rules with the recorder key,
|
|
6
|
+
// write .recorder/rules.json, print the rules numbered
|
|
7
|
+
//
|
|
8
|
+
// Fallback order, and the run log says which: fetched fresh → the snapshot in
|
|
9
|
+
// <takeDir>/brief.json (workspaceRules from the claim) → no rules.
|
|
10
|
+
// The rules never lift the filming laws: irreversible actions stay pointed at,
|
|
11
|
+
// never pressed; Cancel is never a beat. Never print the api_key.
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
const takeDir = process.argv.slice(2).find((a) => !a.startsWith("--")) ?? null;
|
|
16
|
+
const cfgPath = path.resolve(".recorder", "config.json");
|
|
17
|
+
let cfg = {};
|
|
18
|
+
try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
19
|
+
if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
|
|
20
|
+
const base = cfg.base.replace(/\/+$/, "");
|
|
21
|
+
|
|
22
|
+
let snapshot = null;
|
|
23
|
+
let rulesUrl = `${base}/api/recorder/rules`;
|
|
24
|
+
if (takeDir) {
|
|
25
|
+
try {
|
|
26
|
+
const b = JSON.parse(fs.readFileSync(path.join(takeDir, "brief.json"), "utf8"));
|
|
27
|
+
if (b.workspaceRules && typeof b.workspaceRules.text === "string") snapshot = { version: Number(b.workspaceRules.version) || 0, text: b.workspaceRules.text };
|
|
28
|
+
if (typeof b.api?.rules === "string" && /^https?:\/\//.test(b.api.rules)) rulesUrl = b.api.rules;
|
|
29
|
+
} catch { /* no brief.json: a free-prompt run */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let result = null; // { version, text, source, updatedAt }
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(rulesUrl, { headers: { Authorization: `Bearer ${cfg.api_key}`, "Cache-Control": "no-cache" } });
|
|
35
|
+
if (res.status === 401) { console.error("The recorder key was refused. Run: node scripts/login.mjs"); process.exit(1); }
|
|
36
|
+
const json = await res.json().catch(() => null);
|
|
37
|
+
if (res.ok && json && "version" in json) {
|
|
38
|
+
result = { version: Number(json.version) || 0, text: typeof json.rules === "string" ? json.rules : "", source: "fetched", updatedAt: json.updatedAt ?? null, workspaceId: json.workspaceId ?? null };
|
|
39
|
+
} else if (res.status === 404 && !json?.error) {
|
|
40
|
+
console.error("This DemoBites has no workspace rules route yet (older server).");
|
|
41
|
+
} else {
|
|
42
|
+
console.error(`Rules fetch answered ${res.status}${json?.error ? ` ${json.error}` : ""}.`);
|
|
43
|
+
}
|
|
44
|
+
} catch (e) { console.error(`Rules fetch failed: ${e.message}`); }
|
|
45
|
+
if (!result && snapshot) result = { ...snapshot, source: "snapshot", updatedAt: null, workspaceId: null };
|
|
46
|
+
if (!result) result = { version: 0, text: "", source: "none", updatedAt: null, workspaceId: null };
|
|
47
|
+
|
|
48
|
+
// One rule per line; a leading bullet or dash the admin typed is not part of the rule.
|
|
49
|
+
const lines = result.text.split(/\r?\n/).map((s) => s.trim().replace(/^[•·\-*]+\s*/, "").trim()).filter(Boolean);
|
|
50
|
+
const out = { ...result, lines, fetchedAt: new Date().toISOString() };
|
|
51
|
+
try { fs.mkdirSync(path.dirname(cfgPath), { recursive: true }); fs.writeFileSync(path.resolve(".recorder", "rules.json"), JSON.stringify(out, null, 2) + "\n"); } catch (e) { console.error(`rules.json not written: ${e.message}`); }
|
|
52
|
+
|
|
53
|
+
const where = result.source === "fetched" ? "fetched from DemoBites" : result.source === "snapshot" ? "from the batch snapshot in brief.json (the fetch failed)" : "none (the fetch failed and no snapshot)";
|
|
54
|
+
console.log(`Workspace rules: version ${result.version}, ${where}.`);
|
|
55
|
+
if (lines.length === 0) console.log("No standing rules. The filming laws alone apply.");
|
|
56
|
+
else {
|
|
57
|
+
console.log("Standing rules of the workspace, below the filming laws (they never lift them):");
|
|
58
|
+
lines.forEach((l, i) => console.log(` ${i + 1}. ${l}`));
|
|
59
|
+
console.log(`Record "rulesVersion": ${result.version} in the storyboard and list the rules applied on each beat.`);
|
|
60
|
+
}
|
|
@@ -10,14 +10,43 @@
|
|
|
10
10
|
// Returns { exitCode, status, biteId, studioUrl } and never throws.
|
|
11
11
|
//
|
|
12
12
|
// DELIVERY (1.3.0, founder ruling 2026-09-14): a take filmed from a brief
|
|
13
|
-
// becomes a bite by itself once its ZIP is uploaded; no Approve click.
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// becomes a bite by itself once its ZIP is uploaded; no Approve click.
|
|
14
|
+
// DIRECT DELIVERY (1.5.0, founder ruling 2026-09-25): EVERY take is delivered
|
|
15
|
+
// that way, plain prompt takes too. The person watches it come in on the
|
|
16
|
+
// Demos grid (<base>/demos), never on the preview page. A take the account
|
|
17
|
+
// has no recording minutes for is KEPT by the server (403 { kept: true,
|
|
18
|
+
// waiting: "minutes", resetsAt, dashboardUrl, stagingId }) and waits on the
|
|
19
|
+
// same grid; the stage status reads "waiting" until minutes are back.
|
|
20
|
+
|
|
21
|
+
/** Where the person watches their takes: the Demos grid. `dashboardUrl` from
|
|
22
|
+
* the server wins (relative or absolute), else <base>/demos. */
|
|
23
|
+
export function gridUrl(base, dashboardUrl) {
|
|
24
|
+
try { return new URL(dashboardUrl || "/demos", base + "/").toString(); } catch { return `${base}/demos`; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** "October 1, 2026" from an ISO date, or null when there is none. */
|
|
28
|
+
export function formatResetDate(resetsAt) {
|
|
29
|
+
if (!resetsAt) return null;
|
|
30
|
+
const d = new Date(resetsAt);
|
|
31
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
32
|
+
return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The one line a person reads when a take is kept for recording minutes. */
|
|
36
|
+
export function waitingLine(grid, resetsAt) {
|
|
37
|
+
const when = formatResetDate(resetsAt);
|
|
38
|
+
return `Kept. The take is waiting for recording minutes${when ? ` (back on ${when})` : ""}. Watch it here: ${grid}`;
|
|
39
|
+
}
|
|
16
40
|
|
|
17
41
|
/** PUT the delivery route for a staged take. `template` is the claim's
|
|
18
42
|
* api.uploaded ("{origin}/api/recorder/stage/{id}/uploaded", literal {id});
|
|
19
43
|
* the fallback is the same path under the configured base. Idempotent on the
|
|
20
|
-
* server: a repeat returns the same bite. Never throws.
|
|
44
|
+
* server: a repeat returns the same bite. Never throws.
|
|
45
|
+
* Returns one of:
|
|
46
|
+
* { delivered: true, biteId, videoId, studioUrl, dashboardUrl, queued }
|
|
47
|
+
* { delivered: false, waiting: "minutes", kept: true, resetsAt, dashboardUrl }
|
|
48
|
+
* { delivered: false, pending: true } (older server)
|
|
49
|
+
* { delivered: false, error } */
|
|
21
50
|
export async function deliverStaged({ base, apiKey, stagingId, template }) {
|
|
22
51
|
const url = template && template.includes("{id}")
|
|
23
52
|
? template.replace("{id}", encodeURIComponent(stagingId))
|
|
@@ -28,7 +57,17 @@ export async function deliverStaged({ base, apiKey, stagingId, template }) {
|
|
|
28
57
|
} catch (e) { return { delivered: false, httpStatus: 0, error: `DemoBites unreachable (${e.message})` }; }
|
|
29
58
|
const json = await res.json().catch(() => null);
|
|
30
59
|
if ((res.status === 200 || res.status === 202) && json?.biteId) {
|
|
31
|
-
return {
|
|
60
|
+
return {
|
|
61
|
+
delivered: true, biteId: json.biteId, videoId: json.videoId ?? null, studioUrl: json.studioUrl ?? null,
|
|
62
|
+
dashboardUrl: json.dashboardUrl ?? null, queued: res.status === 202 || json.queued === true, httpStatus: res.status,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// No recording minutes left: the server KEEPS the take; it waits on the grid.
|
|
66
|
+
if (res.status === 403 && json?.kept === true) {
|
|
67
|
+
return {
|
|
68
|
+
delivered: false, kept: true, waiting: json.waiting ?? "minutes", resetsAt: json.resetsAt ?? null,
|
|
69
|
+
dashboardUrl: json.dashboardUrl ?? null, httpStatus: res.status,
|
|
70
|
+
};
|
|
32
71
|
}
|
|
33
72
|
// An older server: { pending: true }, or no such route at all (a 404 without an error body).
|
|
34
73
|
if ((res.ok && json?.pending) || (res.status === 404 && !json?.error)) return { delivered: false, pending: true, httpStatus: res.status };
|
|
@@ -42,7 +81,7 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
|
|
|
42
81
|
let completed = false;
|
|
43
82
|
let approvedBiteId = null;
|
|
44
83
|
let finalStudioUrl = null;
|
|
45
|
-
process.stdout.write(delivered ? "Waiting for the bite to finish" : "
|
|
84
|
+
process.stdout.write(delivered ? "Waiting for the bite to finish" : "Checking the take in DemoBites");
|
|
46
85
|
while (Date.now() < deadline) {
|
|
47
86
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
48
87
|
let res;
|
|
@@ -52,6 +91,12 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
|
|
|
52
91
|
if (!res.ok) { process.stdout.write("."); continue; }
|
|
53
92
|
const st = await res.json().catch(() => null);
|
|
54
93
|
if (!st) { process.stdout.write("."); continue; }
|
|
94
|
+
if (st.status === "waiting") {
|
|
95
|
+
// Kept for recording minutes: nothing will finish until they are back.
|
|
96
|
+
process.stdout.write("\n");
|
|
97
|
+
console.log(waitingLine(pageUrl, st.resetsAt));
|
|
98
|
+
return { exitCode: 0, status: "waiting", biteId: null, studioUrl: null };
|
|
99
|
+
}
|
|
55
100
|
if (st.status === "rejected") {
|
|
56
101
|
process.stdout.write("\n");
|
|
57
102
|
console.error("Discarded in the app. Adjust the storyboard and film again.");
|
|
@@ -77,11 +122,11 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
|
|
|
77
122
|
}
|
|
78
123
|
if (!announced) {
|
|
79
124
|
process.stdout.write("\n");
|
|
80
|
-
console.error(delivered ? `The server does not show the delivered bite yet.
|
|
125
|
+
console.error(delivered ? `The server does not show the delivered bite yet. Watch it here: ${pageUrl}` : `No decision yet. The take stays on your Demos grid:\n ${pageUrl}`);
|
|
81
126
|
return { exitCode: 1, status: "pending", biteId: null, studioUrl: null };
|
|
82
127
|
}
|
|
83
128
|
if (!completed) {
|
|
84
|
-
console.error(`${delivered ? "Delivered" : "Approved"}, but the bite did not finish within the wait window. Do not share the link yet —
|
|
129
|
+
console.error(`${delivered ? "Delivered" : "Approved"}, but the bite did not finish within the wait window. Do not share the link yet — watch it on your Demos grid: ${pageUrl}`);
|
|
85
130
|
return { exitCode: 1, status: delivered ? "delivered" : "approved", biteId: approvedBiteId, studioUrl: null };
|
|
86
131
|
}
|
|
87
132
|
|
package/skill/scripts/status.mjs
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
// Where a staged take stands, and the wait for its word.
|
|
2
2
|
//
|
|
3
|
-
// node status.mjs <takeDir> wait for the
|
|
3
|
+
// node status.mjs <takeDir> wait for the delivered bite to finish; reads <takeDir>/staged.json
|
|
4
4
|
// a take whose delivery failed is delivered again here first (idempotent on the server)
|
|
5
5
|
// node status.mjs <stagingId> same, by id
|
|
6
|
-
// node status.mjs <takeDir> --no-wait one look, no waiting:
|
|
6
|
+
// node status.mjs <takeDir> --no-wait one look, no waiting: delivered (+bite status) | waiting for recording minutes | pending | rejected
|
|
7
7
|
// node status.mjs --all one look at every take-*/staged.json under the current directory
|
|
8
8
|
//
|
|
9
|
-
// A batch
|
|
10
|
-
// agent (or the human, later) comes back here per take. The
|
|
11
|
-
//
|
|
9
|
+
// A batch delivers every take with `upload.mjs --stage-only --no-open`, then
|
|
10
|
+
// the agent (or the human, later) comes back here per take. The page the
|
|
11
|
+
// person opens is the Demos grid (<base>/demos), never the preview page. The
|
|
12
|
+
// same law as upload.mjs: no studio link before the bite is completed.
|
|
12
13
|
import fs from "node:fs";
|
|
13
14
|
import path from "node:path";
|
|
14
|
-
import { waitForDecision, peekStaged, deliverStaged } from "./stage-wait.mjs";
|
|
15
|
+
import { waitForDecision, peekStaged, deliverStaged, gridUrl, formatResetDate, waitingLine } from "./stage-wait.mjs";
|
|
15
16
|
|
|
16
17
|
const args = process.argv.slice(2);
|
|
17
18
|
const noWait = args.includes("--no-wait");
|
|
@@ -27,6 +28,7 @@ let cfg = {};
|
|
|
27
28
|
try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
28
29
|
if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
|
|
29
30
|
const base = cfg.base.replace(/\/+$/, "");
|
|
31
|
+
const defaultGrid = gridUrl(base, null);
|
|
30
32
|
|
|
31
33
|
function readStaged(dir) {
|
|
32
34
|
const p = path.join(dir, "staged.json");
|
|
@@ -39,6 +41,10 @@ function describe(st) {
|
|
|
39
41
|
if (st.status === "rejected") return "discarded in the app";
|
|
40
42
|
if (st.status === "delivered") return `delivered, bite ${st.biteId ?? "?"} ${st.biteStatus ?? "processing"}`;
|
|
41
43
|
if (st.status === "approved") return `approved, bite ${st.biteId ?? "?"} ${st.biteStatus ?? "processing"}`;
|
|
44
|
+
if (st.status === "waiting") {
|
|
45
|
+
const when = formatResetDate(st.resetsAt);
|
|
46
|
+
return `waiting for recording minutes${when ? ` (back on ${when})` : ""}`;
|
|
47
|
+
}
|
|
42
48
|
return "waiting for the word in the app";
|
|
43
49
|
}
|
|
44
50
|
|
|
@@ -48,8 +54,9 @@ if (all) {
|
|
|
48
54
|
for (const d of dirs) {
|
|
49
55
|
const staged = readStaged(d);
|
|
50
56
|
const st = staged?.stagingId ? await peekStaged({ base, apiKey: cfg.api_key, stagingId: staged.stagingId }) : { ok: false, httpStatus: 0 };
|
|
51
|
-
console.log(`${d.padEnd(40)} ${describe(st)}
|
|
57
|
+
console.log(`${d.padEnd(40)} ${describe(st)}`);
|
|
52
58
|
}
|
|
59
|
+
console.log(`Watch them here: ${defaultGrid}`);
|
|
53
60
|
process.exit(0);
|
|
54
61
|
}
|
|
55
62
|
|
|
@@ -60,27 +67,36 @@ if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
|
|
|
60
67
|
const staged = readStaged(target);
|
|
61
68
|
if (!staged?.stagingId) { console.error(`${target} has no staged.json. Stage it first: node scripts/upload.mjs ${target} --stage-only`); process.exit(1); }
|
|
62
69
|
stagingId = staged.stagingId;
|
|
63
|
-
pageUrl = staged.
|
|
70
|
+
pageUrl = staged.dashboardUrl ?? null;
|
|
64
71
|
delivered = staged.delivered === true;
|
|
65
|
-
// A
|
|
66
|
-
//
|
|
67
|
-
|
|
72
|
+
// A take whose delivery failed (network, 409) is delivered again here; the
|
|
73
|
+
// server is idempotent. An older server (pending) and a kept take (waiting
|
|
74
|
+
// for minutes, the server holds it) are left alone.
|
|
75
|
+
if (staged.delivered === false && staged.pending !== true && !staged.waiting) {
|
|
68
76
|
const d = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: staged.api?.uploaded ?? null });
|
|
77
|
+
const grid = gridUrl(base, d.dashboardUrl ?? staged.dashboardUrl);
|
|
78
|
+
pageUrl = grid;
|
|
79
|
+
const write = (patch) => { try { fs.writeFileSync(path.join(target, "staged.json"), JSON.stringify({ ...staged, dashboardUrl: grid, ...patch }, null, 2) + "\n"); } catch {} };
|
|
69
80
|
if (d.delivered) {
|
|
70
81
|
delivered = true;
|
|
71
|
-
console.log(`
|
|
72
|
-
|
|
82
|
+
console.log(`Delivered. Watch it come in: ${grid}`);
|
|
83
|
+
write({ delivered: true, waiting: null, biteId: d.biteId, videoId: d.videoId ?? null, studioUrl: d.studioUrl ?? null, queued: d.queued ?? null, deliveryError: null });
|
|
84
|
+
} else if (d.waiting) {
|
|
85
|
+
console.log(waitingLine(grid, d.resetsAt));
|
|
86
|
+
write({ waiting: d.waiting, resetsAt: d.resetsAt ?? null, deliveryError: null });
|
|
87
|
+
process.exit(0);
|
|
73
88
|
} else if (d.pending) {
|
|
74
|
-
|
|
75
|
-
console.log(
|
|
76
|
-
} else console.error(`Not delivered: ${d.error}.
|
|
89
|
+
write({ pending: true });
|
|
90
|
+
console.log(`This DemoBites does not deliver by itself yet; the take waits for the word in the app. Watch it here: ${grid}`);
|
|
91
|
+
} else console.error(`Not delivered: ${d.error}. Try again later with: node scripts/status.mjs ${target}\nYour demos: ${grid}`);
|
|
77
92
|
}
|
|
78
93
|
}
|
|
79
|
-
if (!pageUrl) pageUrl =
|
|
94
|
+
if (!pageUrl) pageUrl = defaultGrid;
|
|
80
95
|
|
|
81
96
|
if (noWait) {
|
|
82
97
|
const st = await peekStaged({ base, apiKey: cfg.api_key, stagingId });
|
|
83
98
|
console.log(`${stagingId}: ${describe(st)}`);
|
|
99
|
+
console.log(`Watch it here: ${pageUrl}`);
|
|
84
100
|
if (st.ok && (st.status === "approved" || st.status === "delivered") && st.biteStatus === "completed" && st.studioUrl) console.log(`Studio: ${new URL(st.studioUrl, base).toString()}`);
|
|
85
101
|
process.exit(st.ok ? 0 : 1);
|
|
86
102
|
}
|
package/skill/scripts/upload.mjs
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// DemoBites ending — STAGE the take and
|
|
2
|
+
// DemoBites ending — STAGE the take and DELIVER it.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
4
|
+
// DIRECT DELIVERY (1.5.0, founder ruling 2026-09-25): this script zips
|
|
5
|
+
// clean.mp4, stages it together with a playable MP4, uploads both, then calls
|
|
6
|
+
// the uploaded door. The take becomes a bite by itself; the person watches it
|
|
7
|
+
// come in on their Demos grid (<base>/demos), never on the preview page. When
|
|
8
|
+
// the account has no recording minutes left the server KEEPS the take and it
|
|
9
|
+
// waits on the same grid, next to the upgrade door.
|
|
10
10
|
//
|
|
11
11
|
// Contracts (fixed, coded verbatim):
|
|
12
12
|
// PUT <base>/api/recorder/stage (Bearer)
|
|
13
13
|
// { filename, sizeBytes, previewSizeBytes, manifest }
|
|
14
14
|
// -> { stagingId, uploadUrl, previewUploadUrl, videoKey, previewUrl }
|
|
15
|
+
// PUT <base>/api/recorder/stage/<stagingId>/uploaded (Bearer) after both uploads, EVERY take
|
|
16
|
+
// -> 202 { delivered: true, biteId, studioUrl, dashboardUrl }
|
|
17
|
+
// | 403 { kept: true, waiting: "minutes", resetsAt, dashboardUrl, stagingId }
|
|
15
18
|
// GET <base>/api/recorder/stage?id=<stagingId> (Bearer)
|
|
16
19
|
// -> { status, biteId, biteUKey, biteStatus, studioUrl }
|
|
17
20
|
//
|
|
@@ -31,7 +34,7 @@ if (!dir) {
|
|
|
31
34
|
}
|
|
32
35
|
// RE-TAKE (2026-09-02): `--retake-of <biteId>` stages this take as a NEW RECORDING
|
|
33
36
|
// of an existing bite instead of a new bite. The server keeps the bite's current
|
|
34
|
-
// narration text, voice, intro/outro and look; the
|
|
37
|
+
// narration text, voice, intro/outro and look; the take is delivered into it.
|
|
35
38
|
const retakeIdx = process.argv.indexOf("--retake-of");
|
|
36
39
|
const retakeOfBiteId = retakeIdx >= 0 ? Number(process.argv[retakeIdx + 1]) : null;
|
|
37
40
|
if (retakeIdx >= 0 && !(Number.isInteger(retakeOfBiteId) && retakeOfBiteId > 0)) { console.error("--retake-of needs a bite id"); process.exit(2); }
|
|
@@ -47,11 +50,9 @@ const retakeNote = noteIdx >= 0 ? String(process.argv[noteIdx + 1] ?? "").trim()
|
|
|
47
50
|
// opts out. `--stage-only` returns right after the two uploads (the batch
|
|
48
51
|
// waits with status.mjs); `--supersede` replaces a stage already pinned to
|
|
49
52
|
// this attempt (the server refuses a second one otherwise).
|
|
50
|
-
// DELIVERY (1.3.0,
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// and prints "delivered: bite <id>". No Approve click, no review queue for
|
|
54
|
-
// brief batches. Free-prompt takes (no attempt) still stage for review.
|
|
53
|
+
// DELIVERY (1.3.0 for brief takes, 1.5.0 for every take): after both uploads
|
|
54
|
+
// this script PUTs the delivery route (the claim's api.uploaded when there is
|
|
55
|
+
// one, fallback <base>/api/recorder/stage/<id>/uploaded). No Approve click.
|
|
55
56
|
const stageOnly = process.argv.includes("--stage-only");
|
|
56
57
|
const supersede = process.argv.includes("--supersede");
|
|
57
58
|
const attemptIdx = process.argv.indexOf("--attempt");
|
|
@@ -77,13 +78,18 @@ if (!cfg.api_key || !cfg.base) {
|
|
|
77
78
|
process.exit(1);
|
|
78
79
|
}
|
|
79
80
|
const base = cfg.base.replace(/\/+$/, "");
|
|
80
|
-
// A delivered take is a bite already
|
|
81
|
+
// A delivered take is a bite already, and a kept take waits on the server;
|
|
82
|
+
// staging either again would make a second one.
|
|
81
83
|
try {
|
|
82
84
|
const prev = JSON.parse(fs.readFileSync(path.join(dir, "staged.json"), "utf8"));
|
|
83
85
|
if (prev?.delivered === true && prev.biteId && !supersede) {
|
|
84
86
|
console.error(`${dir} was already delivered as bite ${prev.biteId}. See where it stands: node scripts/status.mjs ${dir}. For a new take of it, stage again with --supersede.`);
|
|
85
87
|
process.exit(1);
|
|
86
88
|
}
|
|
89
|
+
if (prev?.waiting && !supersede) {
|
|
90
|
+
console.error(`${dir} is already kept in DemoBites, waiting for recording minutes. See where it stands: node scripts/status.mjs ${dir}. For a new take of it, stage again with --supersede.`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
87
93
|
} catch { /* not staged yet */ }
|
|
88
94
|
|
|
89
95
|
const cleanPath = path.join(dir, "clean.mp4");
|
|
@@ -193,7 +199,7 @@ if (!zipped) {
|
|
|
193
199
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
194
200
|
const sizeBytes = fs.statSync(zipPath).size;
|
|
195
201
|
console.log(`take.zip ready (${(sizeBytes / 1024 / 1024).toFixed(1)} MB, ${zipped ? "system zip" : "store method"})`);
|
|
196
|
-
if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId} — the new recording replaces the current one inside that bite
|
|
202
|
+
if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId} — the new recording replaces the current one inside that bite by itself once delivered.`);
|
|
197
203
|
|
|
198
204
|
// ── stage ──────────────────────────────────────────────────────────────────
|
|
199
205
|
const authHeaders = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
|
|
@@ -228,7 +234,7 @@ if (!stageRes.ok) {
|
|
|
228
234
|
if (errBody?.error === "quota_exceeded") {
|
|
229
235
|
// Should not happen anymore — staging is quota-free by design. Neutral
|
|
230
236
|
// fallback if an older server answers this way.
|
|
231
|
-
console.error(`DemoBites declined the stage. Check ${base}/
|
|
237
|
+
console.error(`DemoBites declined the stage. Check ${base}/demos and try again.`);
|
|
232
238
|
process.exit(1);
|
|
233
239
|
}
|
|
234
240
|
if (errBody?.error === "invalid_attempt") {
|
|
@@ -236,14 +242,14 @@ if (!stageRes.ok) {
|
|
|
236
242
|
process.exit(1);
|
|
237
243
|
}
|
|
238
244
|
if (errBody?.error === "attempt_already_staged") {
|
|
239
|
-
console.error(`This attempt already has a staged take${errBody.stagingId ? ` (${errBody.stagingId})` : ""}.
|
|
245
|
+
console.error(`This attempt already has a staged take${errBody.stagingId ? ` (${errBody.stagingId})` : ""}. Keep that one, or stage again with --supersede to replace it.`);
|
|
240
246
|
process.exit(1);
|
|
241
247
|
}
|
|
242
248
|
console.error(`Stage failed: ${stageRes.status} ${errBody ? JSON.stringify(errBody) : ""}`);
|
|
243
249
|
process.exit(1);
|
|
244
250
|
}
|
|
245
251
|
const { stagingId, uploadUrl, previewUploadUrl, previewUrl, queueUrl, pendingCount } = await stageRes.json();
|
|
246
|
-
if (!stagingId || !uploadUrl || !previewUploadUrl
|
|
252
|
+
if (!stagingId || !uploadUrl || !previewUploadUrl) {
|
|
247
253
|
console.error("Stage response missing fields.");
|
|
248
254
|
process.exit(1);
|
|
249
255
|
}
|
|
@@ -263,68 +269,59 @@ async function putS3(url, contentType, filePath, label) {
|
|
|
263
269
|
await putS3(uploadUrl, "application/zip", zipPath, "ZIP");
|
|
264
270
|
await putS3(previewUploadUrl, "video/mp4", cleanPath, "Preview");
|
|
265
271
|
|
|
266
|
-
// ── delivery
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (attempt) {
|
|
271
|
-
const { deliverStaged } = await import("./stage-wait.mjs");
|
|
272
|
-
delivery = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: uploadedTemplate });
|
|
273
|
-
}
|
|
272
|
+
// ── delivery: EVERY take becomes a bite by itself ─────────────────────────
|
|
273
|
+
const { deliverStaged, waitForDecision, gridUrl, waitingLine } = await import("./stage-wait.mjs");
|
|
274
|
+
const delivery = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: uploadedTemplate });
|
|
275
|
+
const grid = gridUrl(base, delivery.dashboardUrl);
|
|
274
276
|
// The staging id used to be printed only; a batch resumes from disk, so it is
|
|
275
277
|
// persisted next to the take (status.mjs reads it, and retries a delivery
|
|
276
|
-
// that failed).
|
|
277
|
-
|
|
278
|
+
// that failed). previewUrl stays for older readers; the person's page is
|
|
279
|
+
// dashboardUrl, the Demos grid.
|
|
280
|
+
const previewPage = previewUrl ? new URL(previewUrl, base).toString() : null;
|
|
278
281
|
try {
|
|
279
282
|
fs.writeFileSync(path.join(dir, "staged.json"), JSON.stringify({
|
|
280
|
-
stagingId, previewUrl:
|
|
283
|
+
stagingId, previewUrl: previewPage, dashboardUrl: grid, queueUrl: queueUrl ? new URL(queueUrl, base).toString() : null,
|
|
281
284
|
pendingCount: pendingCount ?? null, attemptRef: attempt?.attemptRef ?? null, briefId: attempt?.briefId ?? null,
|
|
282
|
-
delivered: delivery
|
|
283
|
-
|
|
285
|
+
delivered: delivery.delivered === true, waiting: delivery.waiting ?? null, resetsAt: delivery.resetsAt ?? null,
|
|
286
|
+
biteId: delivery.biteId ?? null, videoId: delivery.videoId ?? null, studioUrl: delivery.studioUrl ? new URL(delivery.studioUrl, base).toString() : null,
|
|
287
|
+
queued: delivery.queued ?? null, pending: delivery.pending ?? null, deliveryError: delivery.error ?? null,
|
|
284
288
|
api: uploadedTemplate ? { uploaded: uploadedTemplate } : null, at: new Date().toISOString(),
|
|
285
289
|
}, null, 2) + "\n");
|
|
286
290
|
} catch (e) { console.error(`staged.json not written: ${e.message}`); }
|
|
287
291
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const { waitForDecision } = await import("./stage-wait.mjs");
|
|
292
|
-
const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl, delivered: true });
|
|
293
|
-
process.exit(outcome.exitCode);
|
|
294
|
-
}
|
|
295
|
-
if (delivery && !delivery.pending) {
|
|
296
|
-
console.error(`Staged, but not delivered: ${delivery.error}. The take waits in the review queue:\n ${pageUrl}\nTry the delivery again later with: node scripts/status.mjs ${dir}`);
|
|
297
|
-
process.exit(1);
|
|
298
|
-
}
|
|
299
|
-
if (delivery?.pending) console.log("This DemoBites does not deliver by itself yet; the take waits for the word in the app.");
|
|
300
|
-
|
|
301
|
-
// ── open the in-app preview — the review happens THERE ─────────────────────
|
|
302
|
-
// Batch etiquette (founder, 2026-08-11): when takes are stacked for a later
|
|
303
|
-
// review sprint, auto-opening a tab per take is spam. `--no-open` (or
|
|
304
|
-
// config.open_preview === false) stages silently — the queue pill and the
|
|
305
|
-
// printed URL carry the message. Default stays open: for a single take the
|
|
306
|
-
// opened page IS the consent moment.
|
|
307
|
-
console.log(`Staged. Review and approve in the browser:\n ${pageUrl}`);
|
|
308
|
-
if (typeof pendingCount === "number" && pendingCount > 1 && queueUrl) {
|
|
309
|
-
console.log(`${pendingCount} takes are now waiting for review: ${new URL(queueUrl, base).toString()}`);
|
|
310
|
-
}
|
|
292
|
+
// Single take: the grid opens in the browser, the person watches the card
|
|
293
|
+
// come in there. A batch passes --no-open (or config.open_preview === false)
|
|
294
|
+
// and the printed link carries the message.
|
|
311
295
|
const noOpen = process.argv.includes("--no-open") || cfg.open_preview === false;
|
|
312
|
-
|
|
296
|
+
function openGrid() {
|
|
297
|
+
if (noOpen) return;
|
|
313
298
|
try {
|
|
314
299
|
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
315
|
-
spawnSync(opener, [
|
|
316
|
-
} catch { /*
|
|
300
|
+
spawnSync(opener, [grid], { stdio: "ignore" });
|
|
301
|
+
} catch { /* the printed link is the fallback */ }
|
|
317
302
|
}
|
|
318
303
|
|
|
319
|
-
if (
|
|
320
|
-
console.log(`
|
|
304
|
+
if (delivery.delivered) {
|
|
305
|
+
console.log(`Delivered. Watch it come in: ${grid}`);
|
|
306
|
+
openGrid();
|
|
307
|
+
if (stageOnly) { console.log(`Wait for the bite to finish later with: node scripts/status.mjs ${dir}`); process.exit(0); }
|
|
308
|
+
// LAW (founder 2026-08-08): no studio link before the bite is completed.
|
|
309
|
+
const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl: grid, delivered: true });
|
|
310
|
+
process.exit(outcome.exitCode);
|
|
311
|
+
}
|
|
312
|
+
if (delivery.waiting) {
|
|
313
|
+
console.log(waitingLine(grid, delivery.resetsAt));
|
|
314
|
+
openGrid();
|
|
321
315
|
process.exit(0);
|
|
322
316
|
}
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl });
|
|
330
|
-
process.exit(outcome.exitCode);
|
|
317
|
+
if (delivery.pending) {
|
|
318
|
+
// An older DemoBites that does not deliver by itself: the take waits for
|
|
319
|
+
// the word in the app. Still no preview link as the destination.
|
|
320
|
+
console.log(`Staged. This DemoBites does not deliver by itself yet; the take waits for the word in the app. Watch it here: ${grid}`);
|
|
321
|
+
openGrid();
|
|
322
|
+
if (stageOnly) { console.log(`Wait for it later with: node scripts/status.mjs ${dir}`); process.exit(0); }
|
|
323
|
+
const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl: grid });
|
|
324
|
+
process.exit(outcome.exitCode);
|
|
325
|
+
}
|
|
326
|
+
console.error(`Staged, but not delivered: ${delivery.error}. Try the delivery again with: node scripts/status.mjs ${dir}\nYour demos: ${grid}`);
|
|
327
|
+
process.exit(1);
|