demobite 0.0.1 → 1.0.1
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 +16 -6
- package/launcher/index.mjs +75 -8
- package/package.json +6 -6
- package/scripts/record.mjs +133 -12
- package/scripts/trim.mjs +16 -0
- package/skill/SKILL.md +9 -3
- package/skill/scripts/login.mjs +10 -3
- package/skill/scripts/upload.mjs +12 -4
package/README.md
CHANGED
|
@@ -7,16 +7,26 @@ a fully editable demo: AI narration, zooms, cursor rendering, intro and outro,
|
|
|
7
7
|
localization, all tweakable in the studio.
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx demobite
|
|
10
|
+
npx demobite
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
That one command checks your setup, installs the recorder skill for
|
|
14
|
-
[Claude Code](https://claude.com/claude-code),
|
|
15
|
-
|
|
14
|
+
your coding agent — [Claude Code](https://claude.com/claude-code), Cursor,
|
|
15
|
+
or Codex — and wires the DemoBites
|
|
16
|
+
management MCP so your agent can also run your Update Center and Demo Center.
|
|
17
|
+
Sign-in happens through your own browser — no passwords in the terminal, ever.
|
|
16
18
|
Then you just ask your agent:
|
|
17
19
|
|
|
18
20
|
> "Record a demo of how search works on our app, and upload it to DemoBites."
|
|
19
21
|
|
|
22
|
+
> "Create a release with my latest bites and add Spanish."
|
|
23
|
+
|
|
24
|
+
Commands: `npx demobite` (full setup) · `npx demobite login` · `npx demobite mcp`
|
|
25
|
+
(register the management MCP with your agent) · `npx demobite logout`.
|
|
26
|
+
Anything public — publishing a release, taking a language offline — always
|
|
27
|
+
shows you a preview to approve first. Managing by agent requires the Grow plan;
|
|
28
|
+
recording works on every plan.
|
|
29
|
+
|
|
20
30
|
The agent storyboards the flow, films it in a real Chrome, and stages the take
|
|
21
31
|
for your approval inside DemoBites. You approve in the product; the platform
|
|
22
32
|
does the rest.
|
|
@@ -26,7 +36,7 @@ does the rest.
|
|
|
26
36
|
| Directory | What it is |
|
|
27
37
|
|---|---|
|
|
28
38
|
| `launcher/` | The `npx demobite` entry — environment checks, skill install, login |
|
|
29
|
-
| `skill/` | The DemoBites recorder skill for
|
|
39
|
+
| `skill/` | The DemoBites recorder skill for coding agents (staging, preview, approval flow) |
|
|
30
40
|
| `recorder/` | The open recorder — same filming engine, no account, ends at a polished `demo.mp4` |
|
|
31
41
|
| `scripts/` | The shared engine: filming, clock calibration, cutting |
|
|
32
42
|
|
|
@@ -34,7 +44,7 @@ does the rest.
|
|
|
34
44
|
|
|
35
45
|
The `recorder/` directory is a standalone skill: the same real-browser filming,
|
|
36
46
|
hover-anchor clock calibration and camera work, delivering a finished, styled
|
|
37
|
-
`demo.mp4` on your disk — no account, no upload. Point
|
|
47
|
+
`demo.mp4` on your disk — no account, no upload. Point your coding agent at it and
|
|
38
48
|
film. When you want narration, zooms, an editable timeline and hosting, the
|
|
39
49
|
sibling skill in `skill/` is one login away.
|
|
40
50
|
|
|
@@ -48,7 +58,7 @@ is cryptographically tied to a public commit in this repository.
|
|
|
48
58
|
## Requirements
|
|
49
59
|
|
|
50
60
|
- Node 18+
|
|
51
|
-
- [Claude Code](https://claude.com/claude-code)
|
|
61
|
+
- A coding agent — [Claude Code](https://claude.com/claude-code), Cursor, or Codex; the recorder is agent-driven
|
|
52
62
|
- Google Chrome (recommended; films with the real browser) — otherwise
|
|
53
63
|
Chromium is downloaded on first take
|
|
54
64
|
- ffmpeg (`brew install ffmpeg` on macOS)
|
package/launcher/index.mjs
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// demobite —
|
|
2
|
+
// demobite — connect DemoBites to your agent, one command away.
|
|
3
3
|
//
|
|
4
|
-
// npx demobite@latest
|
|
4
|
+
// npx demobite@latest full setup: checks, recorder skill, MCP
|
|
5
5
|
// npx demobite@latest login connect this machine to DemoBites
|
|
6
|
+
// npx demobite@latest mcp register the DemoBites management MCP
|
|
6
7
|
// npx demobite@latest logout disconnect (revokes the key server-side)
|
|
7
8
|
//
|
|
9
|
+
// One front door (founder 2026-08-31): the user never chooses between the
|
|
10
|
+
// recorder skill and the management MCP — bare `npx demobite` sets up both.
|
|
11
|
+
// The MCP is universal (every agent gets it); the skill is the bonus layer
|
|
12
|
+
// for code agents, and stays the advocated recording lane because the agent
|
|
13
|
+
// knows the customer's code. `agentic-recorder` remains a docs alias of bare.
|
|
14
|
+
//
|
|
8
15
|
// This launcher is deliberately boring: it verifies the environment, installs
|
|
9
|
-
// the recorder skill into your agent's skills directory,
|
|
10
|
-
// recorder itself is driven by your coding agent (Claude
|
|
11
|
-
// you just ask it — "record a demo of
|
|
16
|
+
// the recorder skill into your agent's skills directory, wires the MCP, and
|
|
17
|
+
// hands off. The recorder itself is driven by your coding agent (Claude
|
|
18
|
+
// Code): once set up, you just ask it — "record a demo of our search flow".
|
|
12
19
|
import fs from "node:fs";
|
|
13
20
|
import os from "node:os";
|
|
14
21
|
import path from "node:path";
|
|
@@ -42,8 +49,8 @@ if (chromePaths.some((p) => fs.existsSync(p)) || hasBin("google-chrome")) ok("Go
|
|
|
42
49
|
else warn("Google Chrome not found — the recorder will download Chromium on first take");
|
|
43
50
|
if (hasBin("ffmpeg")) ok("ffmpeg");
|
|
44
51
|
else warn("ffmpeg not found — install it (macOS: brew install ffmpeg) before recording");
|
|
45
|
-
if (hasBin("claude")) ok("Claude Code (the
|
|
46
|
-
else warn("Claude Code not found —
|
|
52
|
+
if (hasBin("claude")) ok("Claude Code (drives the recorder; MCP registers automatically)");
|
|
53
|
+
else warn("Claude Code not found — using Cursor or Codex? They drive the recorder too; MCP setup prints below");
|
|
47
54
|
|
|
48
55
|
// ── 2. Install / update the skill ──────────────────────────────────────────
|
|
49
56
|
const skillsDir = path.join(os.homedir(), ".claude", "skills");
|
|
@@ -73,12 +80,72 @@ if (arg === "login" || arg === "logout") {
|
|
|
73
80
|
process.exit(r.status ?? 0);
|
|
74
81
|
}
|
|
75
82
|
|
|
83
|
+
// ── 3b. Management MCP ─────────────────────────────────────────────────────
|
|
84
|
+
// The key lives in <cwd>/.recorder/config.json (login.mjs writes it there),
|
|
85
|
+
// so MCP registration is per-project too — `claude mcp add` default (local)
|
|
86
|
+
// scope matches that exactly and keeps the key out of committable files.
|
|
87
|
+
const readCfg = () => {
|
|
88
|
+
try { return JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "config.json"), "utf8")); }
|
|
89
|
+
catch { return null; }
|
|
90
|
+
};
|
|
91
|
+
const registerMcp = (cfg, { quiet = false } = {}) => {
|
|
92
|
+
const url = `${cfg.base ?? "https://app.demobites.com"}/api/mcp`;
|
|
93
|
+
const header = `Authorization: Bearer ${cfg.api_key}`;
|
|
94
|
+
if (hasBin("claude")) {
|
|
95
|
+
const r = spawnSync("claude", ["mcp", "add", "--transport", "http", "demobites", url, "--header", header], {
|
|
96
|
+
stdio: quiet ? "ignore" : "inherit",
|
|
97
|
+
cwd: process.cwd(),
|
|
98
|
+
});
|
|
99
|
+
if (r.status === 0) { ok(`DemoBites MCP registered with Claude Code (${url})`); return true; }
|
|
100
|
+
}
|
|
101
|
+
if (!quiet) {
|
|
102
|
+
console.log(`
|
|
103
|
+
Add the DemoBites MCP to your agent manually — Streamable HTTP:
|
|
104
|
+
|
|
105
|
+
URL: ${url}
|
|
106
|
+
Header: ${header}
|
|
107
|
+
|
|
108
|
+
Claude Code: claude mcp add --transport http demobites ${url} --header "${header}"
|
|
109
|
+
Cursor: add the URL + header under Settings → MCP
|
|
110
|
+
Other MCP clients: any Streamable HTTP client works with the same URL + header.
|
|
111
|
+
`);
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (arg === "mcp") {
|
|
117
|
+
let cfg = readCfg();
|
|
118
|
+
if (!cfg?.api_key) {
|
|
119
|
+
console.log("\n Not connected yet — linking this machine to DemoBites first…\n");
|
|
120
|
+
const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs")], { stdio: "inherit", cwd: process.cwd() });
|
|
121
|
+
if (r.status !== 0) process.exit(r.status ?? 1);
|
|
122
|
+
cfg = readCfg();
|
|
123
|
+
}
|
|
124
|
+
if (!cfg?.api_key) { warn("Login did not complete — run: npx demobite login"); process.exit(1); }
|
|
125
|
+
registerMcp(cfg);
|
|
126
|
+
console.log(`
|
|
127
|
+
Your agent can now manage DemoBites — try asking it:
|
|
128
|
+
|
|
129
|
+
"Create a release with my latest bites and add Spanish"
|
|
130
|
+
|
|
131
|
+
Publishing always shows you a preview to approve first.
|
|
132
|
+
`);
|
|
133
|
+
process.exit(0);
|
|
134
|
+
}
|
|
135
|
+
|
|
76
136
|
// ── 4. Handoff ─────────────────────────────────────────────────────────────
|
|
137
|
+
// Bare invocation (and the `agentic-recorder` docs alias): if this project is
|
|
138
|
+
// already linked, quietly wire the MCP too — one command, both magics.
|
|
139
|
+
const cfg = readCfg();
|
|
140
|
+
if (cfg?.api_key) registerMcp(cfg, { quiet: true });
|
|
77
141
|
console.log(`
|
|
78
|
-
Ready.
|
|
142
|
+
Ready. Everything is agent-driven — open your coding agent (Claude Code,
|
|
143
|
+
Cursor, Codex) in your project and ask:
|
|
79
144
|
|
|
80
145
|
"Record a demo of <your flow> and upload it to DemoBites"
|
|
146
|
+
"Create a release with my latest bites and add Spanish"
|
|
81
147
|
|
|
82
148
|
It signs in via your browser on first use (or run: npx demobite login).
|
|
149
|
+
Manage-by-agent needs the MCP: npx demobite mcp (once, after login).
|
|
83
150
|
Only the recorder, no DemoBites? See the open recorder in this package's repo.
|
|
84
151
|
`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "demobite",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "The DemoBites agentic recorder
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "The DemoBites agentic recorder \u2014 you prompt, it films a real browser, and DemoBites turns the take into an editable demo bite.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"demobite": "launcher/index.mjs"
|
|
7
7
|
},
|
|
@@ -27,12 +27,12 @@
|
|
|
27
27
|
],
|
|
28
28
|
"repository": {
|
|
29
29
|
"type": "git",
|
|
30
|
-
"url": "git+https://github.com/demobites/
|
|
30
|
+
"url": "git+https://github.com/demobites/agentic-recorder.git"
|
|
31
31
|
},
|
|
32
|
-
"homepage": "https://github.com/demobites/
|
|
32
|
+
"homepage": "https://github.com/demobites/agentic-recorder#readme",
|
|
33
33
|
"bugs": {
|
|
34
|
-
"url": "https://github.com/demobites/
|
|
34
|
+
"url": "https://github.com/demobites/agentic-recorder/issues"
|
|
35
35
|
},
|
|
36
36
|
"license": "MIT",
|
|
37
37
|
"type": "module"
|
|
38
|
-
}
|
|
38
|
+
}
|
package/scripts/record.mjs
CHANGED
|
@@ -20,7 +20,7 @@ if (!outArg || !storyArg) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const STORYBOARD = JSON.parse(fs.readFileSync(storyArg, "utf8"));
|
|
23
|
-
const ACTIONS = new Set(["goto", "settle", "scroll", "click", "hover"]);
|
|
23
|
+
const ACTIONS = new Set(["goto", "settle", "scroll", "click", "hover", "type", "expect"]);
|
|
24
24
|
if (!Array.isArray(STORYBOARD.steps) || STORYBOARD.steps.length === 0) {
|
|
25
25
|
console.error("Storyboard has no steps.");
|
|
26
26
|
process.exit(2);
|
|
@@ -31,6 +31,8 @@ for (const [i, s] of STORYBOARD.steps.entries()) {
|
|
|
31
31
|
if (s.action === "goto" && !s.url) { console.error(`${at}: goto needs url`); process.exit(2); }
|
|
32
32
|
if (s.action === "scroll" && typeof s.dy !== "number") { console.error(`${at}: scroll needs numeric dy`); process.exit(2); }
|
|
33
33
|
if ((s.action === "click" || s.action === "hover") && !s.selector) { console.error(`${at}: ${s.action} needs selector`); process.exit(2); }
|
|
34
|
+
if (s.action === "type" && (!s.selector || typeof s.text !== "string")) { console.error(`${at}: type needs selector and text`); process.exit(2); }
|
|
35
|
+
if (s.action === "expect" && !s.selector) { console.error(`${at}: expect needs selector`); process.exit(2); }
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
const DIR = path.resolve(outArg);
|
|
@@ -486,17 +488,30 @@ async function glide(x, y) {
|
|
|
486
488
|
pushMouse("move", x, y);
|
|
487
489
|
}
|
|
488
490
|
|
|
489
|
-
async function smoothScroll(dy, ms = 1400) {
|
|
490
|
-
|
|
491
|
-
|
|
491
|
+
async function smoothScroll(dy, ms = 1400, within = null) {
|
|
492
|
+
// `within` (2026-08-28, UC-studio lesson): full-viewport apps scroll INNER
|
|
493
|
+
// panels, not the window — window.scrollTo is a no-op there. When set, it
|
|
494
|
+
// is a CSS selector; the first matching element that actually scrolls
|
|
495
|
+
// (scrollHeight meaningfully over clientHeight) is eased instead of the
|
|
496
|
+
// window. Same easing, same on-camera motion.
|
|
497
|
+
await page.evaluate(([d, m, sel]) => new Promise((res) => {
|
|
498
|
+
let target = null;
|
|
499
|
+
if (sel) {
|
|
500
|
+
target = [...document.querySelectorAll(sel)].find(
|
|
501
|
+
(el) => el.scrollHeight > el.clientHeight + 40,
|
|
502
|
+
) ?? null;
|
|
503
|
+
}
|
|
504
|
+
const y0 = target ? target.scrollTop : window.scrollY;
|
|
505
|
+
const t0 = performance.now();
|
|
492
506
|
const step = (now) => {
|
|
493
507
|
const e = Math.min(1, (now - t0) / m);
|
|
494
508
|
const ease = e < 0.5 ? 2 * e * e : 1 - Math.pow(-2 * e + 2, 2) / 2;
|
|
495
|
-
|
|
509
|
+
if (target) target.scrollTo(0, y0 + d * ease);
|
|
510
|
+
else window.scrollTo(0, y0 + d * ease);
|
|
496
511
|
e < 1 ? requestAnimationFrame(step) : res();
|
|
497
512
|
};
|
|
498
513
|
requestAnimationFrame(step);
|
|
499
|
-
}), [dy, ms]);
|
|
514
|
+
}), [dy, ms, within]);
|
|
500
515
|
}
|
|
501
516
|
|
|
502
517
|
let failure = null;
|
|
@@ -518,11 +533,46 @@ try {
|
|
|
518
533
|
await ensureCursor();
|
|
519
534
|
await applyHideCss();
|
|
520
535
|
if (manifest.record_from === undefined) {
|
|
521
|
-
// LAW (first-frame, founder 2026-08-08
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
536
|
+
// LAW (first-frame, founder 2026-08-08, HARDENED 2026-08-13 after a
|
|
537
|
+
// take opened on a white page): the published cut opens on a FULLY
|
|
538
|
+
// loaded page. Network quiet is NOT paint — client-rendered apps go
|
|
539
|
+
// networkidle while the screen is still blank. Three gates in order:
|
|
540
|
+
// 1. network quiet (best effort — long-pollers never go idle)
|
|
541
|
+
// 2. PAINT GATE: poll until the page shows real content (visible
|
|
542
|
+
// text mass or media elements), up to 15s
|
|
543
|
+
// 3. if the NEXT storyboard step is an `expect`, resolve it HERE,
|
|
544
|
+
// before the stamp — the cut then provably opens on the state
|
|
545
|
+
// the story assumes (the expect step later re-checks instantly)
|
|
546
|
+
// Only then stamp record_from: where the final video begins.
|
|
525
547
|
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {});
|
|
548
|
+
const paintDeadline = Date.now() + 15000;
|
|
549
|
+
let painted = false;
|
|
550
|
+
while (Date.now() < paintDeadline) {
|
|
551
|
+
painted = await page.evaluate(() => {
|
|
552
|
+
const textMass = (document.body?.innerText ?? "").trim().length;
|
|
553
|
+
const media = document.querySelectorAll("img, video, canvas, svg").length;
|
|
554
|
+
let visible = 0;
|
|
555
|
+
for (const el of document.querySelectorAll("div, section, main, button")) {
|
|
556
|
+
const r = el.getBoundingClientRect();
|
|
557
|
+
if (r.width > 40 && r.height > 20) { visible++; if (visible > 8) break; }
|
|
558
|
+
}
|
|
559
|
+
return (textMass > 80 || media >= 3) && visible > 8;
|
|
560
|
+
}).catch(() => false);
|
|
561
|
+
if (painted) break;
|
|
562
|
+
await page.waitForTimeout(250);
|
|
563
|
+
}
|
|
564
|
+
if (!painted) {
|
|
565
|
+
console.log("PAINT GATE: page never showed real content within 15s — the take would open on a blank frame. Judge the footage carefully.");
|
|
566
|
+
}
|
|
567
|
+
const nextStep = STORYBOARD.steps[STORYBOARD.steps.indexOf(step) + 1];
|
|
568
|
+
if (nextStep?.action === "expect" && nextStep.selector) {
|
|
569
|
+
const sel = nextStep.text
|
|
570
|
+
? `${nextStep.selector}:has-text(${JSON.stringify(nextStep.text)})`
|
|
571
|
+
: nextStep.selector;
|
|
572
|
+
await page.locator(sel).first().waitFor({ state: "visible", timeout: nextStep.timeout ?? 15000 }).catch(() => {
|
|
573
|
+
console.log(`PAINT GATE: pre-stamp expect "${sel}" not visible — stamping anyway; the expect step will abort the take.`);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
526
576
|
await page.waitForTimeout(600);
|
|
527
577
|
manifest.record_from = t();
|
|
528
578
|
}
|
|
@@ -539,7 +589,7 @@ try {
|
|
|
539
589
|
} else if (step.action === "scroll") {
|
|
540
590
|
// scrollTo distances are ALSO zoomed-space under CSS zoom (measured:
|
|
541
591
|
// scrollTo(0,600) moves 300 design px) — storyboards speak design px.
|
|
542
|
-
await smoothScroll(step.dy * SUPERSAMPLE, step.ms ?? 1400);
|
|
592
|
+
await smoothScroll(step.dy * SUPERSAMPLE, step.ms ?? 1400, step.within ?? null);
|
|
543
593
|
} else if (step.action === "click" || step.action === "hover") {
|
|
544
594
|
const { box, el } = await visibleTarget(step.selector, step.minY ?? 0);
|
|
545
595
|
if (!box) throw new Error("no visible target for " + step.selector);
|
|
@@ -551,7 +601,12 @@ try {
|
|
|
551
601
|
rec.slow_content_ms = visibleTarget.lastWaitMs;
|
|
552
602
|
console.log(`SLOW CONTENT: step ${rec.n} waited ${(visibleTarget.lastWaitMs / 1000).toFixed(1)}s for its target — content was loading on camera. Judge the take; prefer a retake.`);
|
|
553
603
|
}
|
|
554
|
-
|
|
604
|
+
// Optional fractional click point within the target box (step.at =
|
|
605
|
+
// [fx, fy], each 0..1, default center). Lets a click SET a value on
|
|
606
|
+
// track-style controls (sliders) without needing drag support.
|
|
607
|
+
const atFx = Array.isArray(step.at) ? Math.min(1, Math.max(0, step.at[0] ?? 0.5)) : 0.5;
|
|
608
|
+
const atFy = Array.isArray(step.at) ? Math.min(1, Math.max(0, step.at[1] ?? 0.5)) : 0.5;
|
|
609
|
+
const x = box.x + box.width * atFx, y = box.y + box.height * atFy;
|
|
555
610
|
// Resolved BEFORE the glide so the answer costs no video time.
|
|
556
611
|
const targetCursor = await cursorForElement(el);
|
|
557
612
|
const shotStart = t();
|
|
@@ -673,6 +728,72 @@ try {
|
|
|
673
728
|
}
|
|
674
729
|
}
|
|
675
730
|
}
|
|
731
|
+
} else if (step.action === "expect") {
|
|
732
|
+
// PRECONDITION GATE (brand-kit incident, 2026-08-11): assert the page
|
|
733
|
+
// is what the story assumes BEFORE any mutating click. Costs no camera
|
|
734
|
+
// time — no cursor movement, no shot. The take FAILS LOUDLY here if
|
|
735
|
+
// the state is wrong (wrong workspace, wrong account, wrong screen),
|
|
736
|
+
// instead of filming a confident mistake.
|
|
737
|
+
const sel = step.text
|
|
738
|
+
? `${step.selector}:has-text(${JSON.stringify(step.text)})`
|
|
739
|
+
: step.selector;
|
|
740
|
+
try {
|
|
741
|
+
await page.locator(sel).first().waitFor({ state: "visible", timeout: step.timeout ?? 8000 });
|
|
742
|
+
} catch {
|
|
743
|
+
throw new Error(
|
|
744
|
+
`EXPECT FAILED at step ${rec.n}: "${sel}" not visible — the page is not what the storyboard assumes. Take aborted before any further action.`
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
} else if (step.action === "type") {
|
|
748
|
+
// Click the field, then type with a human cadence. Optional step.clear
|
|
749
|
+
// selects-all + deletes first; optional step.enter presses Enter after.
|
|
750
|
+
const { box, el } = await visibleTarget(step.selector, step.minY ?? 0);
|
|
751
|
+
if (!box) throw new Error("no visible target for " + step.selector);
|
|
752
|
+
const x = box.x + box.width / 2, y = box.y + box.height / 2;
|
|
753
|
+
const targetCursor = await cursorForElement(el);
|
|
754
|
+
const shotStart = t();
|
|
755
|
+
currentCursor = "arrow";
|
|
756
|
+
await glide(x, y);
|
|
757
|
+
const arrivalT = t();
|
|
758
|
+
currentCursor = targetCursor;
|
|
759
|
+
rec.cursor = currentCursor;
|
|
760
|
+
pushMouse("move", x, y);
|
|
761
|
+
rec.target = {
|
|
762
|
+
selector: step.selector,
|
|
763
|
+
x: Math.round(x / SUPERSAMPLE), y: Math.round(y / SUPERSAMPLE),
|
|
764
|
+
bbox: {
|
|
765
|
+
x: Math.round(box.x / SUPERSAMPLE), y: Math.round(box.y / SUPERSAMPLE),
|
|
766
|
+
w: Math.round(box.width / SUPERSAMPLE), h: Math.round(box.height / SUPERSAMPLE),
|
|
767
|
+
},
|
|
768
|
+
};
|
|
769
|
+
await page.waitForTimeout(step.dwell ?? DEFAULT_CLICK_DWELL);
|
|
770
|
+
await page.evaluate(([a, b]) => window.__recPulse?.(a, b), [x, y]);
|
|
771
|
+
await page.waitForTimeout(220);
|
|
772
|
+
rec.click_at = t();
|
|
773
|
+
pushMouse("click", x, y, "left");
|
|
774
|
+
await page.mouse.click(x, y);
|
|
775
|
+
await page.waitForTimeout(380);
|
|
776
|
+
if (step.clear) {
|
|
777
|
+
await page.keyboard.press(process.platform === "darwin" ? "Meta+a" : "Control+a");
|
|
778
|
+
await page.waitForTimeout(180);
|
|
779
|
+
await page.keyboard.press("Backspace");
|
|
780
|
+
await page.waitForTimeout(280);
|
|
781
|
+
}
|
|
782
|
+
const text = String(step.text);
|
|
783
|
+
rec.typed = text;
|
|
784
|
+
for (const ch of text) {
|
|
785
|
+
await page.keyboard.type(ch);
|
|
786
|
+
await page.waitForTimeout(34 + Math.random() * 70);
|
|
787
|
+
}
|
|
788
|
+
if (step.enter) {
|
|
789
|
+
await page.waitForTimeout(300);
|
|
790
|
+
await page.keyboard.press("Enter");
|
|
791
|
+
}
|
|
792
|
+
// One shot: the field, from arrival through the typing.
|
|
793
|
+
pushShot(box, Math.max(shotStart, arrivalT - 0.3), t() + 0.3, step.label ?? "type", { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
|
|
794
|
+
currentCursor = await cursorUnderPoint();
|
|
795
|
+
pushMouse("move", cx, cy);
|
|
796
|
+
await page.waitForTimeout(step.after ?? DEFAULT_CLICK_AFTER);
|
|
676
797
|
}
|
|
677
798
|
rec.t_end = t();
|
|
678
799
|
manifest.steps.push(rec);
|
package/scripts/trim.mjs
CHANGED
|
@@ -69,4 +69,20 @@ execFileSync("ffmpeg", [
|
|
|
69
69
|
const dur = execFileSync("ffprobe", [
|
|
70
70
|
"-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", clean,
|
|
71
71
|
]).toString().trim();
|
|
72
|
+
const duration = parseFloat(dur);
|
|
73
|
+
// LAW (founder): a Bite is capped at 90 seconds by product concept. A take
|
|
74
|
+
// that cuts longer than that must not proceed to staging — the story needs a
|
|
75
|
+
// SPLIT, not a trim. Fail loudly here, before any upload machinery runs.
|
|
76
|
+
if (duration > 90) {
|
|
77
|
+
console.error(
|
|
78
|
+
`HARD CAP: this take cuts to ${duration.toFixed(1)}s — a Bite is limited to 90 seconds. ` +
|
|
79
|
+
"Do not upload. Split the scenario into multiple Bites and refilm."
|
|
80
|
+
);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
if (duration > 60) {
|
|
84
|
+
console.error(
|
|
85
|
+
`Note: ${duration.toFixed(1)}s is long for a Bite — the sweet spot is 30 to 45 seconds. Consider tightening.`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
72
88
|
console.log(`clean.mp4 ready in ${dir} (trimmed ${r0.toFixed ? r0.toFixed(2) : r0}s from the head), duration ${parseFloat(dur).toFixed(2)}s`);
|
package/skill/SKILL.md
CHANGED
|
@@ -53,7 +53,7 @@ Look for `.recorder/config.json` next to the project. If it exists, use it and a
|
|
|
53
53
|
- **app**: the product's name as it should appear in titles.
|
|
54
54
|
- **url**: the starting URL of the flow.
|
|
55
55
|
- **frame**: fixed at 1920x1080 for now, do not ask, just record it.
|
|
56
|
-
- **base**: defaults to `https://
|
|
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:
|
|
59
59
|
|
|
@@ -62,7 +62,7 @@ Write the answers to `.recorder/config.json` and never ask again:
|
|
|
62
62
|
"app": "Acme",
|
|
63
63
|
"url": "https://app.acme.com",
|
|
64
64
|
"frame": { "width": 1920, "height": 1080 },
|
|
65
|
-
"base": "https://
|
|
65
|
+
"base": "https://app.demobites.com"
|
|
66
66
|
}
|
|
67
67
|
```
|
|
68
68
|
|
|
@@ -91,6 +91,12 @@ Holding shots to cover estimated lines is what produced a 60 second take with th
|
|
|
91
91
|
**Budgets, hold yourself to them:**
|
|
92
92
|
|
|
93
93
|
- **30 to 45 seconds total.** Over 45 is a rewrite, not a trim.
|
|
94
|
+
- **90 seconds is a HARD CAP, by product concept, and it is a limit, not a
|
|
95
|
+
target.** A Bite over 90 seconds does not exist. Budget the beats BEFORE
|
|
96
|
+
filming: if the human's scenario cannot honestly fit inside 90 seconds, do
|
|
97
|
+
not film-and-trim and do not compress it into uselessness — tell them up
|
|
98
|
+
front and ask them to SPLIT it into multiple Bites, one story per Bite.
|
|
99
|
+
State the estimated duration in every storyboard presentation.
|
|
94
100
|
- **6 to 10 beats.** More than that and nothing gets seen.
|
|
95
101
|
- **8 to 14 words per narration line.** Short beats one long one, every time.
|
|
96
102
|
- Trust the defaults in `record.mjs` (`DEFAULT_HOVER_DWELL` 3.2s, `DEFAULT_CLICK_AFTER` 2.0s). Only override when the app itself is slow.
|
|
@@ -279,7 +285,7 @@ GET <base>/api/recorder/status?biteId=<id> (Authorization: Bearer <api_key>)
|
|
|
279
285
|
-> { status, title, durationSec, zooms, narrationTotal, narrationReady, transcription }
|
|
280
286
|
```
|
|
281
287
|
|
|
282
|
-
The upload zip contains exactly one file: `clean.mp4` stored as `recording.mp4`. Nothing else goes in the zip. Default base is `https://
|
|
288
|
+
The upload zip contains exactly one file: `clean.mp4` stored as `recording.mp4`. Nothing else goes in the zip. Default base is `https://app.demobites.com`, overridable via `config.base`.
|
|
283
289
|
|
|
284
290
|
## Standing rules
|
|
285
291
|
|
package/skill/scripts/login.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// node login.mjs --logout revoke the key server-side AND strip it
|
|
16
16
|
// from local config — a leaked config file
|
|
17
17
|
// dies with the logout.
|
|
18
|
-
// Base resolution: CLI arg, config.base, https://
|
|
18
|
+
// Base resolution: CLI arg, config.base, https://app.demobites.com.
|
|
19
19
|
import fs from "node:fs";
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import { spawnSync } from "node:child_process";
|
|
@@ -33,8 +33,8 @@ const wantLogout = process.argv.includes("--logout");
|
|
|
33
33
|
// CLI base argument must not point the revoke at a different host (review
|
|
34
34
|
// finding — the real key would stay live while we claim success).
|
|
35
35
|
const base = (wantLogout
|
|
36
|
-
? (cfg.base || argv[0] || "https://
|
|
37
|
-
: (argv[0] || cfg.base || "https://
|
|
36
|
+
? (cfg.base || argv[0] || "https://app.demobites.com")
|
|
37
|
+
: (argv[0] || cfg.base || "https://app.demobites.com")
|
|
38
38
|
).replace(/\/+$/, "");
|
|
39
39
|
|
|
40
40
|
if (wantLogout) {
|
|
@@ -126,6 +126,9 @@ while (Date.now() < deadline) {
|
|
|
126
126
|
cfg.base = base;
|
|
127
127
|
cfg.api_key = data.api_key;
|
|
128
128
|
cfg.workspace = data.workspace;
|
|
129
|
+
// Capability scopes (MCP engine 2026-08-31): "manage" rides only on
|
|
130
|
+
// Grow-approved keys and unlocks `npx demobite mcp`.
|
|
131
|
+
cfg.scopes = Array.isArray(data.scopes) && data.scopes.length ? data.scopes : ["record"];
|
|
129
132
|
fs.mkdirSync(cfgDir, { recursive: true });
|
|
130
133
|
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
131
134
|
fs.chmodSync(cfgPath, 0o600);
|
|
@@ -133,7 +136,11 @@ while (Date.now() < deadline) {
|
|
|
133
136
|
data.workspace && typeof data.workspace === "object"
|
|
134
137
|
? data.workspace.name ?? data.workspace.slug ?? JSON.stringify(data.workspace)
|
|
135
138
|
: data.workspace;
|
|
139
|
+
const canManage = cfg.scopes.includes("manage");
|
|
136
140
|
console.log(`Approved. Recorder key saved to .recorder/config.json (workspace: ${wsName}).`);
|
|
141
|
+
console.log(canManage
|
|
142
|
+
? "This key can also MANAGE your centers — wire your agent with: npx demobite mcp"
|
|
143
|
+
: "This key records only. Managing by agent (MCP) comes with the Grow plan.");
|
|
137
144
|
process.exit(0);
|
|
138
145
|
}
|
|
139
146
|
// denied / expired / consumed — terminal states. The wording matters:
|
package/skill/scripts/upload.mjs
CHANGED
|
@@ -167,15 +167,23 @@ await putS3(uploadUrl, "application/zip", zipPath, "ZIP");
|
|
|
167
167
|
await putS3(previewUploadUrl, "video/mp4", cleanPath, "Preview");
|
|
168
168
|
|
|
169
169
|
// ── open the in-app preview — the review happens THERE ─────────────────────
|
|
170
|
+
// Batch etiquette (founder, 2026-08-11): when takes are stacked for a later
|
|
171
|
+
// review sprint, auto-opening a tab per take is spam. `--no-open` (or
|
|
172
|
+
// config.open_preview === false) stages silently — the queue pill and the
|
|
173
|
+
// printed URL carry the message. Default stays open: for a single take the
|
|
174
|
+
// opened page IS the consent moment.
|
|
170
175
|
const pageUrl = new URL(previewUrl, base).toString();
|
|
171
176
|
console.log(`Staged. Review and approve in the browser:\n ${pageUrl}`);
|
|
172
177
|
if (typeof pendingCount === "number" && pendingCount > 1 && queueUrl) {
|
|
173
178
|
console.log(`${pendingCount} takes are now waiting for review: ${new URL(queueUrl, base).toString()}`);
|
|
174
179
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
180
|
+
const noOpen = process.argv.includes("--no-open") || cfg.open_preview === false;
|
|
181
|
+
if (!noOpen) {
|
|
182
|
+
try {
|
|
183
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
184
|
+
spawnSync(opener, [pageUrl], { stdio: "ignore" });
|
|
185
|
+
} catch { /* printing the URL above is the fallback */ }
|
|
186
|
+
}
|
|
179
187
|
|
|
180
188
|
// ── poll while the human decides, then until the bite is READY ─────────────
|
|
181
189
|
// LAW (founder 2026-08-08): never hand a human a studio link before the bite
|