demobite 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/launcher/index.mjs +2 -1
- package/package.json +1 -1
- package/scripts/record.mjs +38 -2
- package/skill/SKILL.md +35 -5
- package/skill/scripts/briefs.mjs +3 -1
- package/skill/scripts/manifest.mjs +13 -5
- package/skill/scripts/rules.mjs +60 -0
package/README.md
CHANGED
|
@@ -130,6 +130,10 @@ When your UI changes, **Retake** can refilm an existing Bite from its saved reci
|
|
|
130
130
|
npx demobite retake <biteId> --note "Export moved to the header"
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
+
**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.
|
|
134
|
+
|
|
135
|
+
**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.
|
|
136
|
+
|
|
133
137
|
**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. A delivered take becomes a Bite in DemoBites by itself; there is no second review click for these takes. Nothing is published or shared.
|
|
134
138
|
|
|
135
139
|
The package also includes a DemoBites management MCP for releases and centers:
|
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
|
@@ -52,7 +52,7 @@ Look for `.recorder/config.json` next to the project. If it exists, use it and a
|
|
|
52
52
|
|
|
53
53
|
- **app**: the product's name as it should appear in titles.
|
|
54
54
|
- **url**: the starting URL of the flow.
|
|
55
|
-
- **frame**:
|
|
55
|
+
- **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
56
|
- **base**: defaults to `https://app.demobites.com`, only ask if the human mentions a different environment.
|
|
57
57
|
|
|
58
58
|
Write the answers to `.recorder/config.json` and never ask again:
|
|
@@ -61,13 +61,29 @@ Write the answers to `.recorder/config.json` and never ask again:
|
|
|
61
61
|
{
|
|
62
62
|
"app": "Acme",
|
|
63
63
|
"url": "https://app.acme.com",
|
|
64
|
-
"frame": { "width": 1920, "height": 1080 },
|
|
65
64
|
"base": "https://app.demobites.com"
|
|
66
65
|
}
|
|
67
66
|
```
|
|
68
67
|
|
|
69
68
|
`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
69
|
|
|
70
|
+
## Phase 1b: Workspace rules, EVERY run
|
|
71
|
+
|
|
72
|
+
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:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
node scripts/rules.mjs [<takeDir>] # fetches the rules fresh, writes .recorder/rules.json, prints them numbered
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
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:
|
|
79
|
+
|
|
80
|
+
- 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.
|
|
81
|
+
- 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.
|
|
82
|
+
- The prep, take and cleanup legs obey the rules too (a "never open" page is never opened, not even off camera).
|
|
83
|
+
- 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.
|
|
84
|
+
- 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.
|
|
85
|
+
- `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.
|
|
86
|
+
|
|
71
87
|
## Phase 2: Target-app sign-in, only when a login wall appears
|
|
72
88
|
|
|
73
89
|
The camera browser uses a persistent profile at `.recorder/profile`. Signed in sessions survive between takes.
|
|
@@ -92,6 +108,17 @@ node scripts/vocab.mjs <takeDir> <url of every screen the take visits>
|
|
|
92
108
|
|
|
93
109
|
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
110
|
|
|
111
|
+
### Phase 3b: When you sit in the app's repository, read it FIRST
|
|
112
|
+
|
|
113
|
+
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:
|
|
114
|
+
|
|
115
|
+
- **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.
|
|
116
|
+
- **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.
|
|
117
|
+
- **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.
|
|
118
|
+
- **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.
|
|
119
|
+
|
|
120
|
+
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.
|
|
121
|
+
|
|
95
122
|
### LAW: the camera shows an action to its end
|
|
96
123
|
|
|
97
124
|
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 +161,7 @@ Holding shots to cover estimated lines is what produced a 60 second take with th
|
|
|
134
161
|
|
|
135
162
|
**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
163
|
|
|
137
|
-
Storyboard schema:
|
|
164
|
+
Storyboard schema (`rulesVersion` and per-step `rules` come from Phase 1b):
|
|
138
165
|
|
|
139
166
|
```json
|
|
140
167
|
{
|
|
@@ -153,7 +180,7 @@ Storyboard schema:
|
|
|
153
180
|
}
|
|
154
181
|
```
|
|
155
182
|
|
|
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`.
|
|
183
|
+
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
184
|
|
|
158
185
|
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
186
|
|
|
@@ -335,7 +362,7 @@ The procedure, in order:
|
|
|
335
362
|
|
|
336
363
|
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
364
|
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.
|
|
365
|
+
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
366
|
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
367
|
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 `delivered: bite <id>`. It writes `staged.json` with the staging id and the bite id.
|
|
341
368
|
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.
|
|
@@ -387,6 +414,9 @@ PUT <base>/api/recorder/device { device_code } (poll every `interval`
|
|
|
387
414
|
DELETE <base>/api/recorder/key (Authorization: Bearer <api_key>)
|
|
388
415
|
-> { revoked: true } (logout)
|
|
389
416
|
|
|
417
|
+
GET <base>/api/recorder/rules (Authorization: Bearer <api_key>) // WORKSPACE RULES (1.4): never cached
|
|
418
|
+
-> { workspaceId, rules: string | null, version, updatedAt } (the claim's api.rules is the same url; workspaceRules on the claim is the snapshot)
|
|
419
|
+
|
|
390
420
|
GET <base>/api/recorder/recipe?biteId=<id> (Authorization: Bearer <api_key>) // RE-TAKE: the bite's recipe
|
|
391
421
|
-> { storyboard, config:{app,url,frame}, manifest, engine } (404 no recipe; 402/403 plan gate)
|
|
392
422
|
|
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,
|
|
@@ -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
|
+
}
|