demobite 1.2.0 → 1.3.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 +3 -2
- package/package.json +4 -4
- package/scripts/check-release.mjs +8 -0
- package/scripts/release.mjs +48 -0
- package/skill/SKILL.md +17 -10
- package/skill/scripts/briefs.mjs +4 -2
- package/skill/scripts/stage-wait.mjs +34 -8
- package/skill/scripts/status.mjs +22 -5
- package/skill/scripts/upload.mjs +46 -5
- package/scripts/check-aliases.mjs +0 -42
package/README.md
CHANGED
|
@@ -130,6 +130,8 @@ 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
|
+
**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
|
+
|
|
133
135
|
The package also includes a DemoBites management MCP for releases and centers:
|
|
134
136
|
|
|
135
137
|
```bash
|
|
@@ -148,9 +150,8 @@ The default installer also attempts to register that MCP with Claude Code when t
|
|
|
148
150
|
| `skill/` | Studio-connected recorder instructions, login, upload, and Retake. |
|
|
149
151
|
| `scripts/` | Shared browser recording, trimming, and timing calibration. |
|
|
150
152
|
| `recorder/` | Standalone skill and local video finishing tools. |
|
|
151
|
-
| `aliases/` | `agentic-recorder` and `demobites` aliases for the same launcher. |
|
|
152
153
|
|
|
153
|
-
|
|
154
|
+
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).
|
|
154
155
|
|
|
155
156
|
Found something confusing or have an example to share? [Open an issue](https://github.com/demobites/agentic-recorder/issues). Include your operating system, agent, and recorder version. Remove keys, cookies, and private app data from logs or recordings before attaching them.
|
|
156
157
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "demobite",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "The DemoBites agentic recorder
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"description": "The DemoBites agentic recorder — 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
|
},
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"license": "MIT",
|
|
37
37
|
"type": "module",
|
|
38
38
|
"scripts": {
|
|
39
|
-
"
|
|
40
|
-
"prepublishOnly": "node scripts/check-
|
|
39
|
+
"release": "node scripts/release.mjs",
|
|
40
|
+
"prepublishOnly": "node scripts/check-release.mjs"
|
|
41
41
|
}
|
|
42
42
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// prepublishOnly guard: a bare `npm publish` ships ONE of the three names and
|
|
3
|
+
// leaves the other two behind, which is exactly what the founder refused on
|
|
4
|
+
// 2026-09-15. Publish through `npm run release`.
|
|
5
|
+
if (process.env.DEMOBITE_RELEASE !== "1") {
|
|
6
|
+
console.error("Refusing a bare npm publish: demobite, agentic-recorder and demobites ship together. Run: npm run release");
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// RELEASE (founder law, 2026-09-15): one package, three names, one version.
|
|
3
|
+
// `demobite`, `agentic-recorder` and `demobites` on npm are the SAME full
|
|
4
|
+
// package (launcher, skill, recorder, scripts), published together at the
|
|
5
|
+
// same version. No aliases, no caret ranges, nothing to strand.
|
|
6
|
+
//
|
|
7
|
+
// npm run release publish package.json's version under all three names
|
|
8
|
+
// npm run release -- --dry-run
|
|
9
|
+
//
|
|
10
|
+
// For each name the script writes package.json with that name and a bin of
|
|
11
|
+
// the same name, runs `npm publish`, and restores the original package.json
|
|
12
|
+
// (also on failure). A bare `npm publish` is refused by prepublishOnly unless
|
|
13
|
+
// this script set DEMOBITE_RELEASE, so nobody ships one name alone.
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
export const NAMES = ["demobite", "agentic-recorder", "demobites"];
|
|
20
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
21
|
+
const pkgPath = path.join(root, "package.json");
|
|
22
|
+
const original = fs.readFileSync(pkgPath, "utf8");
|
|
23
|
+
const base = JSON.parse(original);
|
|
24
|
+
const dryRun = process.argv.includes("--dry-run");
|
|
25
|
+
if (base.name !== "demobite") { console.error(`package.json name is ${base.name}; expected demobite (a previous release did not restore it?)`); process.exit(1); }
|
|
26
|
+
|
|
27
|
+
const published = [];
|
|
28
|
+
try {
|
|
29
|
+
for (const name of NAMES) {
|
|
30
|
+
const pkg = { ...base, name, bin: { [name]: "launcher/index.mjs" } };
|
|
31
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
32
|
+
console.log(`\n── ${name}@${base.version}${dryRun ? " (dry run)" : ""}`);
|
|
33
|
+
const r = spawnSync("npm", ["publish", "--access", "public", ...(dryRun ? ["--dry-run"] : [])], { cwd: root, stdio: "inherit", env: { ...process.env, DEMOBITE_RELEASE: "1" } });
|
|
34
|
+
if (r.status !== 0) { console.error(`\n${name}@${base.version} did not publish (exit ${r.status}). Published so far: ${published.join(", ") || "none"}. Fix and rerun; npm refuses to republish a version that already landed, so bump the patch if some names went out.`); process.exit(r.status ?? 1); }
|
|
35
|
+
published.push(name);
|
|
36
|
+
}
|
|
37
|
+
} finally {
|
|
38
|
+
fs.writeFileSync(pkgPath, original);
|
|
39
|
+
}
|
|
40
|
+
if (dryRun) { console.log(`\nDry run ok for ${NAMES.join(", ")} at ${base.version}.`); process.exit(0); }
|
|
41
|
+
console.log(`\nPublished ${NAMES.join(", ")} at ${base.version}. Verifying the registry…`);
|
|
42
|
+
let bad = false;
|
|
43
|
+
for (const name of NAMES) {
|
|
44
|
+
const v = spawnSync("npm", ["view", `${name}@${base.version}`, "version"], { encoding: "utf8" }).stdout.trim();
|
|
45
|
+
console.log(` ${v.endsWith(base.version) ? "✓" : "✗"} ${name} → ${v || "(not visible yet)"}`);
|
|
46
|
+
if (!v.endsWith(base.version)) bad = true;
|
|
47
|
+
}
|
|
48
|
+
if (bad) console.error("Some names are not visible yet; npm can take a minute. Check again with: npm view <name> version");
|
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. Never ingest before the human's word — for DemoBites, Approve on the in-app preview page IS the word.
|
|
12
|
+
Follow the phases in order. Never skip the storyboard approval. Never ingest before the human's word — for DemoBites, Approve on the in-app preview page IS the word. For a batch of briefs the word was given twice already, on the batch and on each storyboard: a delivered take becomes a bite by itself (see Batch of briefs).
|
|
13
13
|
|
|
14
14
|
## Phase 0: Auth gate, ALWAYS FIRST — with the human's word
|
|
15
15
|
|
|
@@ -313,7 +313,9 @@ Laws for a re-take:
|
|
|
313
313
|
with the bite's current text per step. Only remove lines whose beats you dropped.
|
|
314
314
|
- **Same pacing laws apply** (intro, narrate the path, linger, cut and fade on page transitions).
|
|
315
315
|
- **The human approves in-app.** The preview page says "Re-take of <bite>". Approve replaces the recording in
|
|
316
|
-
that bite; the previous recording is kept for rollback, never overwritten.
|
|
316
|
+
that bite; the previous recording is kept for rollback, never overwritten. A re-take filmed from a brief
|
|
317
|
+
(a take with an attempt) is delivered instead: the new recording replaces the current one by itself, and
|
|
318
|
+
the promoted export stays as it is until a version is published.
|
|
317
319
|
|
|
318
320
|
## Batch of briefs (GitHub PR → demos)
|
|
319
321
|
|
|
@@ -324,9 +326,9 @@ node scripts/briefs.mjs list <batchId> [--paste bundle.txt] # the approve
|
|
|
324
326
|
node scripts/briefs.mjs claim <batchId> <briefId> # mints an attempt, creates take-<briefId>-r<revision>/brief.json
|
|
325
327
|
node scripts/briefs.mjs event <takeDir> planning|awaiting_storyboard_approval|recording|uploading|failed|cancelled [--note "..."]
|
|
326
328
|
node scripts/briefs.mjs release <takeDir> # give the brief back (cancelled)
|
|
327
|
-
node scripts/upload.mjs <takeDir> --stage-only --no-open #
|
|
328
|
-
node scripts/status.mjs <takeDir> # later: wait for the
|
|
329
|
-
node scripts/status.mjs --all # one look at every
|
|
329
|
+
node scripts/upload.mjs <takeDir> --stage-only --no-open # deliver: the take becomes a bite by itself, do not wait
|
|
330
|
+
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
|
|
330
332
|
```
|
|
331
333
|
|
|
332
334
|
The procedure, in order:
|
|
@@ -335,11 +337,11 @@ The procedure, in order:
|
|
|
335
337
|
2. Claim the briefs you are about to film, one `claim` each. A claim answers "active attempt" when another agent or an earlier run holds the brief: show the human the attempt reference and its start time, and only with their word claim again with `--force`.
|
|
336
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.
|
|
337
339
|
4. **Show the storyboards together, get a word on each one.** One message can carry all of them, but every brief gets its own yes or no. Never take one yes as a yes for the batch. A brief the human declines gets `release`.
|
|
338
|
-
5. Film sequentially, never in parallel: one Chrome on the profile. Per take: `event recording` → Phase 4 dry run → `cleanup.mjs --prep` when declared → Phase 5 take → `cleanup.mjs` (revert, checks.after) → trim, calibrate, manifest → `upload.mjs <takeDir> --stage-only --no-open`. Report, per take, what was created and what was reverted, with the before/after checks. `upload.mjs` reads `brief.json`, moves the attempt to uploading
|
|
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 `delivered: bite <id>`. It writes `staged.json` with the staging id and the bite id.
|
|
339
341
|
6. **A failed brief never stops the others.** On a failure send `event <takeDir> failed --note "<what happened>"`, keep the take directory for diagnosis, and continue with the next brief. Report every failure plainly at the end.
|
|
340
|
-
7. When all takes are
|
|
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) waits in the review queue; `status.mjs <takeDir>` tries the delivery again, and on an older DemoBites waits for the word in the app as before. Deliver each take; never publish, never share, never send invitations.
|
|
341
343
|
|
|
342
|
-
Resume after an interruption from what is on disk and on the server: a `take-*` directory with `brief.json` is claimed; with `raw.webm` it was filmed; with `clean.mp4` and `manifest.demobites.json` it is ready to stage; with `staged.json` it is staged (check it with `status.mjs --no-wait`). `list` shows the server's view of every attempt. Never re-claim a brief that already has your own live attempt; never re-stage one that `staged.json` says is staged unless the human asked for a new take (`upload.mjs --supersede`).
|
|
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`).
|
|
343
345
|
|
|
344
346
|
## The wire manifest (fixed contract, version 2)
|
|
345
347
|
|
|
@@ -394,7 +396,12 @@ PUT <base>/api/recorder/stage (Authorization: Bearer <api_key>)
|
|
|
394
396
|
-> { stagingId, uploadUrl, previewUploadUrl, videoKey, previewUrl }
|
|
395
397
|
|
|
396
398
|
GET <base>/api/recorder/stage?id=<stagingId> (Authorization: Bearer <api_key>)
|
|
397
|
-
-> { status: 'pending'|'approving'|'approved'|'rejected', biteId, biteUKey, biteStatus, studioUrl }
|
|
399
|
+
-> { status: 'pending'|'approving'|'approved'|'delivered'|'rejected', biteId, biteUKey, biteStatus, studioUrl }
|
|
400
|
+
|
|
401
|
+
PUT <base>/api/recorder/stage/<stagingId>/uploaded (Authorization: Bearer <api_key>) // DELIVERY: brief takes only, after both uploads
|
|
402
|
+
{} // the url comes from the claim's api.uploaded ("{origin}/api/recorder/stage/{id}/uploaded"); this path is the fallback
|
|
403
|
+
-> 200 { biteId, videoId } | 202 { biteId, queued:true } | 200 { pending:true } (older server: wait for the word) | 404/409 { error }
|
|
404
|
+
// idempotent: a repeat returns the same bite
|
|
398
405
|
|
|
399
406
|
GET <base>/api/recorder/status?biteId=<id> (Authorization: Bearer <api_key>)
|
|
400
407
|
-> { status, title, durationSec, narrationReady, narrationTotal, zooms } (STARTS the pipeline, not done)
|
|
@@ -409,5 +416,5 @@ The upload zip contains exactly one file: `clean.mp4` stored as `recording.mp4`.
|
|
|
409
416
|
|
|
410
417
|
- Anything the human sees (storyboard presentation, review page, questions) uses commas and periods only, no dashes, and real action words. Never orphan a single word on its own line in a heading.
|
|
411
418
|
- Never touch credentials. Never print the api_key. Config and key files are chmod 600.
|
|
412
|
-
- Never INGEST without the human's explicit word. For the DemoBites ending, staging for the in-app preview is HOW the word is asked — the take becomes a bite only when the human clicks Approve on that page.
|
|
419
|
+
- Never INGEST without the human's explicit word. For the DemoBites ending, staging for the in-app preview is HOW the word is asked — the take becomes a bite only when the human clicks Approve on that page. For a batch of briefs the word was given on the batch and on each storyboard, and delivery ingests by itself. Never publish, never share, never send invitations.
|
|
413
420
|
- 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
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
// GET /api/recorder/briefs?batch=<batchId>
|
|
12
12
|
// PUT /api/recorder/briefs/claim { briefId, revision, contentHash, idempotencyKey, force? }
|
|
13
13
|
// PUT /api/recorder/briefs/attempts/<attemptRef> { event, note? }
|
|
14
|
-
// The stage call (upload.mjs) sends the attempt from <takeDir>/brief.json
|
|
14
|
+
// The stage call (upload.mjs) sends the attempt from <takeDir>/brief.json, and
|
|
15
|
+
// after the uploads calls the delivery route from the claim's `api.uploaded`
|
|
16
|
+
// (1.3.0): a take filmed from a brief becomes a bite by itself.
|
|
15
17
|
//
|
|
16
18
|
// Laws: one storyboard approval per brief, never one word for the batch.
|
|
17
19
|
// Sequential takes, one Chrome on the profile. A failed brief never stops the
|
|
@@ -140,7 +142,7 @@ if (cmd === "claim") {
|
|
|
140
142
|
const record = {
|
|
141
143
|
batchId, briefId: brief.briefId, revision: brief.revision, contentHash: brief.contentHash, attemptRef: r.json.attemptRef,
|
|
142
144
|
brief: r.json.brief ?? brief, target: r.json.target ?? data.batch?.target ?? null, rules: r.json.rules ?? { maxSeconds: 90 },
|
|
143
|
-
source: data.batch?.source ?? null, claimedAt: new Date().toISOString(),
|
|
145
|
+
source: data.batch?.source ?? null, api: r.json.api ?? data.api ?? null, claimedAt: new Date().toISOString(),
|
|
144
146
|
};
|
|
145
147
|
fs.writeFileSync(path.join(dir, "brief.json"), JSON.stringify(record, null, 2) + "\n");
|
|
146
148
|
console.log(`Claimed ${brief.briefId} r${brief.revision} → ${dir}/brief.json (attempt ${r.json.attemptRef}, at most ${record.rules.maxSeconds}s)`);
|
|
@@ -8,15 +8,41 @@
|
|
|
8
8
|
// confirmed "completed".
|
|
9
9
|
//
|
|
10
10
|
// Returns { exitCode, status, biteId, studioUrl } and never throws.
|
|
11
|
+
//
|
|
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. The
|
|
14
|
+
// server answers status "delivered" from then on, and the wait here is only
|
|
15
|
+
// for the bite to finish. Free-prompt takes still wait for the word.
|
|
16
|
+
|
|
17
|
+
/** PUT the delivery route for a staged take. `template` is the claim's
|
|
18
|
+
* api.uploaded ("{origin}/api/recorder/stage/{id}/uploaded", literal {id});
|
|
19
|
+
* the fallback is the same path under the configured base. Idempotent on the
|
|
20
|
+
* server: a repeat returns the same bite. Never throws. */
|
|
21
|
+
export async function deliverStaged({ base, apiKey, stagingId, template }) {
|
|
22
|
+
const url = template && template.includes("{id}")
|
|
23
|
+
? template.replace("{id}", encodeURIComponent(stagingId))
|
|
24
|
+
: `${base}/api/recorder/stage/${encodeURIComponent(stagingId)}/uploaded`;
|
|
25
|
+
let res;
|
|
26
|
+
try {
|
|
27
|
+
res = await fetch(url, { method: "PUT", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: "{}" });
|
|
28
|
+
} catch (e) { return { delivered: false, httpStatus: 0, error: `DemoBites unreachable (${e.message})` }; }
|
|
29
|
+
const json = await res.json().catch(() => null);
|
|
30
|
+
if ((res.status === 200 || res.status === 202) && json?.biteId) {
|
|
31
|
+
return { delivered: true, biteId: json.biteId, videoId: json.videoId ?? null, queued: res.status === 202 || json.queued === true, httpStatus: res.status };
|
|
32
|
+
}
|
|
33
|
+
// An older server: { pending: true }, or no such route at all (a 404 without an error body).
|
|
34
|
+
if ((res.ok && json?.pending) || (res.status === 404 && !json?.error)) return { delivered: false, pending: true, httpStatus: res.status };
|
|
35
|
+
return { delivered: false, httpStatus: res.status, error: json?.error ? `${json.error}${json.message ? `: ${json.message}` : ""}` : `HTTP ${res.status}` };
|
|
36
|
+
}
|
|
11
37
|
|
|
12
|
-
export async function waitForDecision({ base, apiKey, stagingId, pageUrl, decisionTimeoutMs = 30 * 60 * 1000, pollMs = 4000 }) {
|
|
38
|
+
export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delivered = false, decisionTimeoutMs = 30 * 60 * 1000, pollMs = 4000 }) {
|
|
13
39
|
const headers = { Authorization: `Bearer ${apiKey}` };
|
|
14
40
|
const deadline = Date.now() + decisionTimeoutMs;
|
|
15
41
|
let announced = false;
|
|
16
42
|
let completed = false;
|
|
17
43
|
let approvedBiteId = null;
|
|
18
44
|
let finalStudioUrl = null;
|
|
19
|
-
process.stdout.write("Waiting for your word in the browser");
|
|
45
|
+
process.stdout.write(delivered ? "Waiting for the bite to finish" : "Waiting for your word in the browser");
|
|
20
46
|
while (Date.now() < deadline) {
|
|
21
47
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
22
48
|
let res;
|
|
@@ -31,14 +57,14 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, decisi
|
|
|
31
57
|
console.error("Discarded in the app. Adjust the storyboard and film again.");
|
|
32
58
|
return { exitCode: 1, status: "rejected", biteId: null, studioUrl: null };
|
|
33
59
|
}
|
|
34
|
-
if (st.status === "approved") {
|
|
60
|
+
if (st.status === "approved" || st.status === "delivered") {
|
|
35
61
|
if (!announced) {
|
|
36
62
|
process.stdout.write("\n");
|
|
37
|
-
console.log(`Approved — bite ${st.biteId} is being created`);
|
|
63
|
+
console.log(st.status === "delivered" ? `delivered: bite ${st.biteId} is being created` : `Approved — bite ${st.biteId} is being created`);
|
|
38
64
|
announced = true;
|
|
39
65
|
approvedBiteId = st.biteId;
|
|
40
66
|
finalStudioUrl = st.studioUrl ? new URL(st.studioUrl, base).toString() : null;
|
|
41
|
-
process.stdout.write("Waiting for the bite to finish");
|
|
67
|
+
if (st.biteStatus !== "completed") process.stdout.write("Waiting for the bite to finish");
|
|
42
68
|
}
|
|
43
69
|
if (st.biteStatus === "completed") { completed = true; process.stdout.write("\n"); break; }
|
|
44
70
|
if (st.biteStatus === "failed") {
|
|
@@ -51,12 +77,12 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, decisi
|
|
|
51
77
|
}
|
|
52
78
|
if (!announced) {
|
|
53
79
|
process.stdout.write("\n");
|
|
54
|
-
console.error(`No decision yet. The preview stays available at:\n ${pageUrl}`);
|
|
80
|
+
console.error(delivered ? `The server does not show the delivered bite yet. Look again later: ${pageUrl}` : `No decision yet. The preview stays available at:\n ${pageUrl}`);
|
|
55
81
|
return { exitCode: 1, status: "pending", biteId: null, studioUrl: null };
|
|
56
82
|
}
|
|
57
83
|
if (!completed) {
|
|
58
|
-
console.error("Approved, but the bite did not finish within the wait window. Do not share the link yet — poll /api/recorder/status or reload the preview page
|
|
59
|
-
return { exitCode: 1, status: "approved", biteId: approvedBiteId, studioUrl: null };
|
|
84
|
+
console.error(`${delivered ? "Delivered" : "Approved"}, but the bite did not finish within the wait window. Do not share the link yet — poll /api/recorder/status or reload the preview page.`);
|
|
85
|
+
return { exitCode: 1, status: delivered ? "delivered" : "approved", biteId: approvedBiteId, studioUrl: null };
|
|
60
86
|
}
|
|
61
87
|
|
|
62
88
|
// Final receipt via the status endpoint (same gate as before).
|
package/skill/scripts/status.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Where a staged take stands, and the wait for its word.
|
|
2
2
|
//
|
|
3
|
-
// node status.mjs <takeDir> wait for the decision,
|
|
3
|
+
// node status.mjs <takeDir> wait for the decision (or, delivered, for the bite to finish); reads <takeDir>/staged.json
|
|
4
|
+
// a take whose delivery failed is delivered again here first (idempotent on the server)
|
|
4
5
|
// node status.mjs <stagingId> same, by id
|
|
5
|
-
// node status.mjs <takeDir> --no-wait one look, no waiting: pending | approved (+bite status) | rejected
|
|
6
|
+
// node status.mjs <takeDir> --no-wait one look, no waiting: pending | delivered (+bite status) | approved (+bite status) | rejected
|
|
6
7
|
// node status.mjs --all one look at every take-*/staged.json under the current directory
|
|
7
8
|
//
|
|
8
9
|
// A batch stages every take with `upload.mjs --stage-only --no-open`, then the
|
|
@@ -10,7 +11,7 @@
|
|
|
10
11
|
// upload.mjs: no studio link before the bite is completed.
|
|
11
12
|
import fs from "node:fs";
|
|
12
13
|
import path from "node:path";
|
|
13
|
-
import { waitForDecision, peekStaged } from "./stage-wait.mjs";
|
|
14
|
+
import { waitForDecision, peekStaged, deliverStaged } from "./stage-wait.mjs";
|
|
14
15
|
|
|
15
16
|
const args = process.argv.slice(2);
|
|
16
17
|
const noWait = args.includes("--no-wait");
|
|
@@ -36,6 +37,7 @@ function readStaged(dir) {
|
|
|
36
37
|
function describe(st) {
|
|
37
38
|
if (!st.ok) return `unreachable (${st.httpStatus})`;
|
|
38
39
|
if (st.status === "rejected") return "discarded in the app";
|
|
40
|
+
if (st.status === "delivered") return `delivered, bite ${st.biteId ?? "?"} ${st.biteStatus ?? "processing"}`;
|
|
39
41
|
if (st.status === "approved") return `approved, bite ${st.biteId ?? "?"} ${st.biteStatus ?? "processing"}`;
|
|
40
42
|
return "waiting for the word in the app";
|
|
41
43
|
}
|
|
@@ -53,20 +55,35 @@ if (all) {
|
|
|
53
55
|
|
|
54
56
|
let stagingId = target;
|
|
55
57
|
let pageUrl = null;
|
|
58
|
+
let delivered = false;
|
|
56
59
|
if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
|
|
57
60
|
const staged = readStaged(target);
|
|
58
61
|
if (!staged?.stagingId) { console.error(`${target} has no staged.json. Stage it first: node scripts/upload.mjs ${target} --stage-only`); process.exit(1); }
|
|
59
62
|
stagingId = staged.stagingId;
|
|
60
63
|
pageUrl = staged.previewUrl ?? null;
|
|
64
|
+
delivered = staged.delivered === true;
|
|
65
|
+
// A brief take whose delivery failed (network, 409) is delivered again here;
|
|
66
|
+
// the server is idempotent. An older server (pending) is left alone.
|
|
67
|
+
if (staged.attemptRef && staged.delivered === false && staged.pending !== true) {
|
|
68
|
+
const d = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: staged.api?.uploaded ?? null });
|
|
69
|
+
if (d.delivered) {
|
|
70
|
+
delivered = true;
|
|
71
|
+
console.log(`delivered: bite ${d.biteId}${d.queued ? " (ingest queued)" : ""}. It becomes a bite in DemoBites by itself.`);
|
|
72
|
+
try { fs.writeFileSync(path.join(target, "staged.json"), JSON.stringify({ ...staged, delivered: true, biteId: d.biteId, videoId: d.videoId ?? null, queued: d.queued ?? null, deliveryError: null }, null, 2) + "\n"); } catch {}
|
|
73
|
+
} else if (d.pending) {
|
|
74
|
+
try { fs.writeFileSync(path.join(target, "staged.json"), JSON.stringify({ ...staged, pending: true }, null, 2) + "\n"); } catch {}
|
|
75
|
+
console.log("This DemoBites does not deliver by itself yet; the take waits for the word in the app.");
|
|
76
|
+
} else console.error(`Not delivered: ${d.error}. The take waits in the review queue.`);
|
|
77
|
+
}
|
|
61
78
|
}
|
|
62
79
|
if (!pageUrl) pageUrl = `${base}/recording-preview/agentic/${encodeURIComponent(stagingId)}`;
|
|
63
80
|
|
|
64
81
|
if (noWait) {
|
|
65
82
|
const st = await peekStaged({ base, apiKey: cfg.api_key, stagingId });
|
|
66
83
|
console.log(`${stagingId}: ${describe(st)}`);
|
|
67
|
-
if (st.ok && st.status === "approved" && st.biteStatus === "completed" && st.studioUrl) console.log(`Studio: ${new URL(st.studioUrl, base).toString()}`);
|
|
84
|
+
if (st.ok && (st.status === "approved" || st.status === "delivered") && st.biteStatus === "completed" && st.studioUrl) console.log(`Studio: ${new URL(st.studioUrl, base).toString()}`);
|
|
68
85
|
process.exit(st.ok ? 0 : 1);
|
|
69
86
|
}
|
|
70
87
|
|
|
71
|
-
const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl });
|
|
88
|
+
const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl, delivered });
|
|
72
89
|
process.exit(outcome.exitCode);
|
package/skill/scripts/upload.mjs
CHANGED
|
@@ -47,6 +47,11 @@ const retakeNote = noteIdx >= 0 ? String(process.argv[noteIdx + 1] ?? "").trim()
|
|
|
47
47
|
// opts out. `--stage-only` returns right after the two uploads (the batch
|
|
48
48
|
// waits with status.mjs); `--supersede` replaces a stage already pinned to
|
|
49
49
|
// this attempt (the server refuses a second one otherwise).
|
|
50
|
+
// DELIVERY (1.3.0, founder ruling 2026-09-14): a take with an attempt becomes
|
|
51
|
+
// a bite by itself. After both uploads this script PUTs the delivery route
|
|
52
|
+
// (the claim's api.uploaded, fallback <base>/api/recorder/stage/<id>/uploaded)
|
|
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.
|
|
50
55
|
const stageOnly = process.argv.includes("--stage-only");
|
|
51
56
|
const supersede = process.argv.includes("--supersede");
|
|
52
57
|
const attemptIdx = process.argv.indexOf("--attempt");
|
|
@@ -54,11 +59,13 @@ const attemptPath = process.argv.includes("--no-attempt")
|
|
|
54
59
|
? null
|
|
55
60
|
: attemptIdx >= 0 ? String(process.argv[attemptIdx + 1] ?? "") : path.join(dir, "brief.json");
|
|
56
61
|
let attempt = null;
|
|
62
|
+
let uploadedTemplate = null;
|
|
57
63
|
if (attemptPath && fs.existsSync(attemptPath)) {
|
|
58
64
|
try {
|
|
59
65
|
const b = JSON.parse(fs.readFileSync(attemptPath, "utf8"));
|
|
60
66
|
if (b.briefId && b.revision !== undefined && b.contentHash && b.attemptRef) {
|
|
61
67
|
attempt = { briefId: String(b.briefId), revision: b.revision, contentHash: String(b.contentHash), attemptRef: String(b.attemptRef) };
|
|
68
|
+
uploadedTemplate = typeof b.api?.uploaded === "string" ? b.api.uploaded : null;
|
|
62
69
|
} else console.error(`${attemptPath} is missing briefId/revision/contentHash/attemptRef; staging without an attempt`);
|
|
63
70
|
} catch (e) { console.error(`${attemptPath} unreadable (${e.message}); staging without an attempt`); }
|
|
64
71
|
} else if (attemptIdx >= 0) { console.error(`--attempt ${attemptPath} not found`); process.exit(2); }
|
|
@@ -70,6 +77,14 @@ if (!cfg.api_key || !cfg.base) {
|
|
|
70
77
|
process.exit(1);
|
|
71
78
|
}
|
|
72
79
|
const base = cfg.base.replace(/\/+$/, "");
|
|
80
|
+
// A delivered take is a bite already; staging it again would make a second one.
|
|
81
|
+
try {
|
|
82
|
+
const prev = JSON.parse(fs.readFileSync(path.join(dir, "staged.json"), "utf8"));
|
|
83
|
+
if (prev?.delivered === true && prev.biteId && !supersede) {
|
|
84
|
+
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
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
} catch { /* not staged yet */ }
|
|
73
88
|
|
|
74
89
|
const cleanPath = path.join(dir, "clean.mp4");
|
|
75
90
|
const wirePath = path.join(dir, "manifest.demobites.json");
|
|
@@ -178,7 +193,7 @@ if (!zipped) {
|
|
|
178
193
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
179
194
|
const sizeBytes = fs.statSync(zipPath).size;
|
|
180
195
|
console.log(`take.zip ready (${(sizeBytes / 1024 / 1024).toFixed(1)} MB, ${zipped ? "system zip" : "store method"})`);
|
|
181
|
-
if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId} — the new recording replaces the current one inside that bite once approved.`);
|
|
196
|
+
if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId} — the new recording replaces the current one inside that bite ${attempt ? "by itself once delivered" : "once approved"}.`);
|
|
182
197
|
|
|
183
198
|
// ── stage ──────────────────────────────────────────────────────────────────
|
|
184
199
|
const authHeaders = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
|
|
@@ -247,22 +262,48 @@ async function putS3(url, contentType, filePath, label) {
|
|
|
247
262
|
}
|
|
248
263
|
await putS3(uploadUrl, "application/zip", zipPath, "ZIP");
|
|
249
264
|
await putS3(previewUploadUrl, "video/mp4", cleanPath, "Preview");
|
|
265
|
+
|
|
266
|
+
// ── delivery (brief batches): the take becomes a bite by itself ────────────
|
|
267
|
+
// Only a take carrying an attempt is delivered. Free-prompt takes never call
|
|
268
|
+
// this route, so their flow is unchanged: staged, then the word in the app.
|
|
269
|
+
let delivery = null;
|
|
270
|
+
if (attempt) {
|
|
271
|
+
const { deliverStaged } = await import("./stage-wait.mjs");
|
|
272
|
+
delivery = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: uploadedTemplate });
|
|
273
|
+
}
|
|
250
274
|
// The staging id used to be printed only; a batch resumes from disk, so it is
|
|
251
|
-
// persisted next to the take (status.mjs reads it
|
|
275
|
+
// persisted next to the take (status.mjs reads it, and retries a delivery
|
|
276
|
+
// that failed).
|
|
277
|
+
const pageUrl = new URL(previewUrl, base).toString();
|
|
252
278
|
try {
|
|
253
279
|
fs.writeFileSync(path.join(dir, "staged.json"), JSON.stringify({
|
|
254
|
-
stagingId, previewUrl:
|
|
255
|
-
pendingCount: pendingCount ?? null, attemptRef: attempt?.attemptRef ?? null, briefId: attempt?.briefId ?? null,
|
|
280
|
+
stagingId, previewUrl: pageUrl, queueUrl: queueUrl ? new URL(queueUrl, base).toString() : null,
|
|
281
|
+
pendingCount: pendingCount ?? null, attemptRef: attempt?.attemptRef ?? null, briefId: attempt?.briefId ?? null,
|
|
282
|
+
delivered: delivery ? delivery.delivered : null, biteId: delivery?.biteId ?? null, videoId: delivery?.videoId ?? null,
|
|
283
|
+
queued: delivery?.queued ?? null, pending: delivery?.pending ?? null, deliveryError: delivery?.error ?? null,
|
|
284
|
+
api: uploadedTemplate ? { uploaded: uploadedTemplate } : null, at: new Date().toISOString(),
|
|
256
285
|
}, null, 2) + "\n");
|
|
257
286
|
} catch (e) { console.error(`staged.json not written: ${e.message}`); }
|
|
258
287
|
|
|
288
|
+
if (delivery?.delivered) {
|
|
289
|
+
console.log(`delivered: bite ${delivery.biteId}${delivery.queued ? " (ingest queued)" : ""}. It becomes a bite in DemoBites by itself.\n ${pageUrl}`);
|
|
290
|
+
if (stageOnly) { console.log(`Delivered only. Wait for the bite to finish later with: node scripts/status.mjs ${dir}`); process.exit(0); }
|
|
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
|
+
|
|
259
301
|
// ── open the in-app preview — the review happens THERE ─────────────────────
|
|
260
302
|
// Batch etiquette (founder, 2026-08-11): when takes are stacked for a later
|
|
261
303
|
// review sprint, auto-opening a tab per take is spam. `--no-open` (or
|
|
262
304
|
// config.open_preview === false) stages silently — the queue pill and the
|
|
263
305
|
// printed URL carry the message. Default stays open: for a single take the
|
|
264
306
|
// opened page IS the consent moment.
|
|
265
|
-
const pageUrl = new URL(previewUrl, base).toString();
|
|
266
307
|
console.log(`Staged. Review and approve in the browser:\n ${pageUrl}`);
|
|
267
308
|
if (typeof pendingCount === "number" && pendingCount > 1 && queueUrl) {
|
|
268
309
|
console.log(`${pendingCount} takes are now waiting for review: ${new URL(queueUrl, base).toString()}`);
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// ALIAS GUARD (founder law, 2026-09-09): `agentic-recorder` and `demobites` on
|
|
3
|
-
// npm are aliases of this package. They depend on `demobite` with a caret
|
|
4
|
-
// range, so minor and patch releases flow through automatically, but a MAJOR
|
|
5
|
-
// bump silently strands them on the old major. This guard runs before every
|
|
6
|
-
// publish of demobite (prepublishOnly) and in CI, and refuses to continue if
|
|
7
|
-
// any alias's dependency range no longer covers the version about to ship.
|
|
8
|
-
//
|
|
9
|
-
// When it fails: bump the range in aliases/<name>/package.json (and the alias
|
|
10
|
-
// version), publish each alias from the founder's own terminal, then publish
|
|
11
|
-
// demobite. See RELEASING.md.
|
|
12
|
-
import fs from "node:fs";
|
|
13
|
-
import path from "node:path";
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
15
|
-
|
|
16
|
-
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
-
const main = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
18
|
-
const aliasesDir = path.join(root, "aliases");
|
|
19
|
-
const [major] = main.version.split(".").map(Number);
|
|
20
|
-
|
|
21
|
-
let failed = false;
|
|
22
|
-
for (const name of fs.readdirSync(aliasesDir)) {
|
|
23
|
-
const pkgPath = path.join(aliasesDir, name, "package.json");
|
|
24
|
-
if (!fs.existsSync(pkgPath)) continue;
|
|
25
|
-
const alias = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
26
|
-
const range = alias.dependencies?.demobite ?? "";
|
|
27
|
-
const m = /^\^(\d+)\./.exec(range);
|
|
28
|
-
const covered = m && Number(m[1]) === major;
|
|
29
|
-
const line = `${alias.name.padEnd(18)} depends on demobite ${range.padEnd(8)} → ${covered ? "covers" : "DOES NOT COVER"} ${main.version}`;
|
|
30
|
-
console.log((covered ? " ✓ " : " ✗ ") + line);
|
|
31
|
-
if (!covered) failed = true;
|
|
32
|
-
const bin = fs.readFileSync(path.join(aliasesDir, name, "index.mjs"), "utf8");
|
|
33
|
-
if (!bin.includes('import "demobite/launcher/index.mjs"')) {
|
|
34
|
-
console.log(` ✗ ${alias.name} does not import the demobite launcher`);
|
|
35
|
-
failed = true;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
if (failed) {
|
|
39
|
-
console.error("\nAlias guard failed: an npm alias would strand on the old major. Update aliases/*/package.json (range + version), publish the aliases, then publish demobite. See RELEASING.md.");
|
|
40
|
-
process.exit(1);
|
|
41
|
-
}
|
|
42
|
-
console.log("alias guard: ok");
|