@hypersocial/decks-cli 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -35,7 +35,51 @@ word, and wait for them:
35
35
  - voice: cloning needs them to type their consent in a terminal (`hyperdecks
36
36
  voice clone <file> --name <name>`), or to agree to the statement before you
37
37
  call `clone_voice`. Never type or confirm consent for them. `--voice dash`
38
- lets D.A.S.H., a character voice, present instead.
38
+ lets D.A.S.H., a character voice, present instead;
39
+ - narrator: who narrates their decks, a face plus a voice. Without one, a deck
40
+ in the user's own voice plays captions only. See *Narrators* below; only the
41
+ person picks one.
42
+
43
+ ### Narrators: who narrates a deck (a face plus a voice)
44
+
45
+ A narrator pairs a face (one of the user's avatars) with a voice (one of their
46
+ ready voices, or D.A.S.H.'s). D.A.S.H. is built in. A deck uses its own
47
+ narrator, else the default: `narrate` speaks in the narrator's voice (an
48
+ explicit `--voice` or the script's `voice:` still wins) and `release` puts its
49
+ face on the deck. **Never add a narrator for the person**: `narrator new`
50
+ returns a link; hand it to them, word for word, and wait.
51
+
52
+ | Step | CLI | MCP |
53
+ | --- | --- | --- |
54
+ | List them, the default and the voices | `hyperdecks narrator --json` | `list_narrators` |
55
+ | Add one (the person picks face and voice) | `hyperdecks narrator new --json` → `url` | `new_narrator_link` |
56
+ | Make one the default (the person named it) | `hyperdecks narrator default <name>` | `set_default_narrator` |
57
+ | Remove one (confirm first) | `hyperdecks narrator remove <name>` | `remove_narrator` |
58
+ | Change its voice (the person asked) | `hyperdecks narrator voice <name> <voice>` | `set_narrator_voice` |
59
+ | Rename it (the person asked) | `hyperdecks narrator rename <name> <new name>` | `rename_narrator` |
60
+ | Change its face to one of their avatars (the person named it) | `hyperdecks narrator face <name> <avatar>` | `set_narrator_face` |
61
+ | Hear a voice or a narrator | `hyperdecks voice hear <voice>` (`--narrator <name>`) → `url`, `caption` | `hear_voice` |
62
+ | One deck's own narrator | `hyperdecks deck narrator <slug> <name\|none\|default>` | `set_deck_narrator` |
63
+
64
+ ### Avatars: who presents a deck in the user's voice
65
+
66
+ The faces narrators wear; these commands still work as before.
67
+
68
+ A deck narrated in the user's own voice is presented by their default avatar
69
+ (a cartoon Minimoji, never a photo) from its next release; with none it plays
70
+ captions only. D.A.S.H. presents only in its own voice. **Never pick or approve
71
+ a face for the person.** Every command that needs a choice returns a link (and,
72
+ for an import, a code): hand it to them, word for word, and wait.
73
+
74
+ | Step | CLI | MCP |
75
+ | --- | --- | --- |
76
+ | List them and the default | `hyperdecks avatar --json` | `list_avatars` |
77
+ | Pick a species and an avatar | `hyperdecks avatar new [--species cat] --json` → `pickUrl` | `new_avatar_link` |
78
+ | A cartoon from a photo | `hyperdecks avatar from-photo [me.jpg] --json` → `reviewUrl` (or `selfieUrl`) | `avatar_from_photo` |
79
+ | Import from Minimoji | `hyperdecks avatar connect --json` → `url` + `code` | `connect_avatar` |
80
+ | Make one the default (the person named it) | `hyperdecks avatar default <name>` | `set_default_avatar` |
81
+ | Remove one (confirm first) | `hyperdecks avatar remove <name>` | `remove_avatar` |
82
+ | One deck's own presenter | `hyperdecks deck avatar <slug> <name\|none\|default>` | `set_deck_avatar` |
39
83
 
40
84
  ## Target Selection
41
85
 
@@ -92,11 +136,21 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
92
136
  | Ship | `hyperdecks release decks/<slug> --to "<name>" --email <e>` | `release_deck` |
93
137
  | Share-link poster | `hyperdecks poster <slug>` | `generate_deck_poster` |
94
138
  | Import only | `hyperdecks import <file> --slug <slug>` | `import_deck_from_url` |
95
- | One more link | `hyperdecks share new <slug> "<name>"` | `create_share` |
139
+ | One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
140
+ | Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
141
+ | Forwarding, whole deck | `hyperdecks deck forward on\|off <slug>` | `set_deck_forwarding` |
96
142
  | Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
97
143
  | All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
98
144
  | What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
99
145
 
146
+ Forwarding is on by default: a viewer can forward the deck to someone, which
147
+ makes a child link tracked on its own ("Dana via Peter"; `share list` shows it
148
+ indented under Peter's, `list_shares` gives `parentToken` and `forwardedBy`).
149
+ One hop only: a forwarded link can't be forwarded again, nor can a QA link.
150
+ Disabling the parent link (`share off`) turns off its forwards too, and
151
+ `share forward default` (or `allowForward: null`) drops a link's own setting
152
+ so it follows the deck's.
153
+
100
154
  ### 1. Ground it in the user's own material
101
155
 
102
156
  Build the deck from what you can already read in this session: files in the
@@ -7,6 +7,18 @@ reaches a recipient until `release_deck`. Topics: call `deck_guide` with
7
7
  `format`, `script`, `design`, `layouts`, `presenter`, `transitions` or `check`
8
8
  before the step that needs it.
9
9
 
10
+ ## First time here?
11
+
12
+ `get_started` with no answers says where the person stands (ready voices,
13
+ narrators, credits) and the steps. No voice of their own: `clone_voice_link`
14
+ (they record in the browser), or `clone_voice` with 1-3 recordings they attach
15
+ in the chat, only after they agree to the consent statement, word for word.
16
+ `hear_voice` plays a voice or a narrator in the chat. A face from a photo they
17
+ attach: `avatar_from_photo { file }`, then `check_avatar_draft` until it hands
18
+ back the link they approve it on. `rename_narrator` and `set_narrator_face`
19
+ change a narrator when they ask. Then `get_started { name, about, ask, voice }`
20
+ makes the hello deck; continue from step 5 below.
21
+
10
22
  ## The loop
11
23
 
12
24
  1. `deck_guide` (this page), then `deck_guide { topic: "layouts" }` to pick slides.
@@ -31,8 +43,17 @@ before the step that needs it.
31
43
  8. `release_deck { slug, qa: true }` gives the user a QA link to watch first.
32
44
  `release_deck { slug, recipients: [{ recipient, email }] }` makes one
33
45
  tracked link each. Release creates live links: confirm the recipients with
34
- the user before you call it.
35
- 9. Later: `list_shares`, `deck_stats`, `activity_overview`.
46
+ the user before you call it. In Claude and ChatGPT the result shows as a
47
+ deck card: the deck playing from its QA link (the draft before release),
48
+ with each share link and its opens. Never paste a recipient's link to "test"
49
+ it: opening it counts as their open.
50
+ 9. Later: `list_shares` and `deck_stats` (both show the card),
51
+ `activity_overview`; `email_embed { token }` for the HTML that pastes into an
52
+ email as the deck's moving preview (it needs a recipient's link, not a QA
53
+ one); `set_share_notify` to mute a link's "opened" alerts;
54
+ `set_recipient_archived` to tidy the activity feed; `credits` for the
55
+ balance. `remove_deck` deletes a deck and breaks every link to it: confirm
56
+ with the user first.
36
57
 
37
58
  ## Rules that matter
38
59
 
package/src/avatar.mjs ADDED
@@ -0,0 +1,91 @@
1
+ // hyperdecks avatar [list] | new [--species x] | from-photo [file] [--save-to-minimoji] | connect | default <id> | remove <id>
2
+ //
3
+ // The face that presents decks narrated in your own voice (convex/avatars.ts).
4
+ // Only a person picks or approves a face: in a terminal you pick by number;
5
+ // with --json, or without a terminal (an agent), each command returns the link
6
+ // (and for connect, the code) to hand the person, and never chooses.
7
+ import { readFileSync } from "node:fs";
8
+ import { extname } from "node:path";
9
+ import * as ops from "./ops.mjs";
10
+ import { SPECIES as TABLE } from "./species.mjs";
11
+
12
+ export const SPECIES = TABLE.map((s) => s.id);
13
+ const PHOTO_TYPES = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png" };
14
+
15
+ const interactiveOf = (opts) => !opts.json && !!process.stdin.isTTY && !!process.stdout.isTTY;
16
+
17
+ export async function avatarList(opts, { log = console.log } = {}) {
18
+ const out = await ops.listAvatars(opts);
19
+ if (opts.json) return out;
20
+ for (const a of out.avatars) log(`${a.isDefault ? "*" : " "} ${a.name}\t${a.source}${a.species ? ` (${a.species})` : ""}\t${a.id}`);
21
+ log("\n* the default; decks narrated in your voice use it unless they set their own (deck avatar <slug> <id>)");
22
+ return out;
23
+ }
24
+
25
+ // Pick a species and one of its avatars in the terminal; s shuffles, u undoes.
26
+ export async function avatarNew(opts, { ask, log = console.log, interactive = interactiveOf(opts) } = {}) {
27
+ const species = opts.species ? String(opts.species).toLowerCase() : null;
28
+ if (species && !SPECIES.includes(species)) throw new Error(`--species is one of ${SPECIES.join(", ")}`);
29
+ if (!interactive) {
30
+ const link = await ops.newAvatarLink(opts, species ? { species } : {});
31
+ if (!opts.json) log(`Pick an avatar here (only you can choose it):\n ${link.pickUrl}`);
32
+ return link;
33
+ }
34
+ log(`Species: ${SPECIES.join(" ")}`);
35
+ let chosen = species;
36
+ while (!chosen) {
37
+ const a = (await ask("Species [human]: ")).trim().toLowerCase() || "human";
38
+ if (SPECIES.includes(a)) chosen = a;
39
+ else log(` not a species; one of ${SPECIES.join(", ")}`);
40
+ }
41
+ const link = await ops.newAvatarLink(opts, { species: chosen });
42
+ log(`Preview them: ${link.pickUrl}`);
43
+ const seeds = [0];
44
+ for (;;) {
45
+ const page = await ops.avatarOptions(opts, chosen, seeds[seeds.length - 1]);
46
+ log(page.options.map((o, i) => ` ${String(i + 1).padStart(2)} ${o.name}`).join("\n"));
47
+ const keys = [page.canShuffle && "s to shuffle", seeds.length > 1 && "u to undo"].filter(Boolean).join(", ");
48
+ const a = (await ask(`Pick 1-${page.options.length}${keys ? `, or ${keys}` : ""}: `)).trim().toLowerCase();
49
+ if (a === "s" && page.canShuffle) { seeds.push(seeds[seeds.length - 1] + 1); continue; }
50
+ if (a === "u" && seeds.length > 1) { seeds.pop(); continue; }
51
+ const n = Number(a);
52
+ const option = Number.isInteger(n) ? page.options[n - 1] : null;
53
+ if (!option) continue;
54
+ await ops.addPickedAvatar(opts, option.avatarKey);
55
+ log(`✓ Added ${option.name} (${chosen}). Default for decks in your voice.`);
56
+ return { status: "added", name: option.name, species: chosen };
57
+ }
58
+ }
59
+
60
+ // A face from a photo file, or (no file) a selfie in the browser. Either way the
61
+ // person approves it on the link before it is used.
62
+ export async function avatarFromPhoto(file, opts, { log = console.log, interactive = interactiveOf(opts) } = {}) {
63
+ let photo = {};
64
+ if (file) {
65
+ const contentType = PHOTO_TYPES[extname(file).toLowerCase()];
66
+ if (!contentType) throw new Error("the photo must be a .jpg or .png");
67
+ photo = { bytes: readFileSync(file), contentType };
68
+ }
69
+ const out = await ops.avatarFromPhoto(opts, photo, { onProgress: (m) => !opts.json && log(m) });
70
+ const url = out.reviewUrl ?? out.selfieUrl;
71
+ if (opts.json) return out;
72
+ log(file ? `✓ Drew a cartoon of you. Check it before it's used:\n ${url}` : `No photo given. Take a selfie in your browser:\n ${url}`);
73
+ if (opts["save-to-minimoji"]) log(" (tick \"Also save it to my Minimoji account\" there)");
74
+ if (!interactive) return out;
75
+ log("Waiting for you…");
76
+ const avatar = await ops.waitForLink(opts, out.token);
77
+ log(`✓ ${avatar.name} presents your decks now. Your photo was deleted.`);
78
+ return { status: "added", name: avatar.name };
79
+ }
80
+
81
+ export async function avatarConnect(opts, { log = console.log, interactive = interactiveOf(opts) } = {}) {
82
+ const out = await ops.connectAvatar(opts);
83
+ if (opts.json) return out;
84
+ log(`On any device, go to ${out.url}\nand enter ${out.code} (expires in ${Math.round(out.expiresIn / 60)} min)`);
85
+ if (!interactive) return out;
86
+ log("Waiting for you to pick a face…");
87
+ const done = await ops.waitForConnect(opts, out.connectId);
88
+ if (done.status !== "added") throw new Error(done.status === "denied" ? "not shared on minimoji.com" : done.status === "expired" ? "the code expired; run avatar connect again" : done.error || `the import ended (${done.status})`);
89
+ log("✓ Presenter: your Minimoji. Your decks use it from the next release.");
90
+ return { status: "added", avatarId: done.avatarId };
91
+ }
package/src/commands.mjs CHANGED
@@ -87,7 +87,8 @@ function shareBase(opts) {
87
87
 
88
88
  async function shareNew(pos, opts) {
89
89
  const [slug, recipient] = pos;
90
- if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify]");
90
+ if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify] [--allow-forward|--no-forward]");
91
+ if (opts["allow-forward"] && opts["no-forward"]) throw new Error("pick one of --allow-forward and --no-forward");
91
92
  const { token } = await ops.createShare(opts, {
92
93
  slug,
93
94
  recipient,
@@ -96,6 +97,7 @@ async function shareNew(pos, opts) {
96
97
  requireEmail: opts["require-email"],
97
98
  qa: opts.qa || undefined,
98
99
  notify: opts["no-notify"] ? false : undefined,
100
+ allowForward: opts["allow-forward"] ? true : opts["no-forward"] ? false : undefined,
99
101
  expires: opts.expires,
100
102
  limit: opts.limit,
101
103
  });
@@ -112,7 +114,11 @@ async function shareList(pos, opts) {
112
114
  console.log(JSON.stringify(rows, null, 2));
113
115
  return;
114
116
  }
115
- for (const r of rows) console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${r.recipient}\topens:${r.opens}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.archived ? "\t(archived)" : ""}`);
117
+ // Rows arrive with each link's forwards right after it; indent those.
118
+ for (const r of rows) {
119
+ const who = r.parentToken ? ` ↳ ${r.recipient} via ${r.forwardedBy ?? "?"}` : r.recipient;
120
+ console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${who}\topens:${r.opens}\tfwd:${r.allowForward ? "on" : "off"}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.probablyForwarded ? "\tprobably forwarded" : ""}${r.archived ? "\t(archived)" : ""}`);
121
+ }
116
122
  if (!rows.length) console.log("(no shares)");
117
123
  }
118
124
 
@@ -126,8 +132,24 @@ async function shareToggle(pos, opts, enabled) {
126
132
  async function shareRm(pos, opts) {
127
133
  const [token] = pos;
128
134
  if (!token) throw new Error("usage: share rm <token>");
129
- await ops.removeShare(opts, token);
130
- console.log(`OK removed ${token}`);
135
+ const { removedForwards } = await ops.removeShare(opts, token);
136
+ console.log(`OK removed ${token}${removedForwards ? ` and ${removedForwards} forwarded link${removedForwards === 1 ? "" : "s"}` : ""}`);
137
+ }
138
+
139
+ const FORWARD = { on: true, off: false, default: null };
140
+
141
+ async function shareForward(pos, opts) {
142
+ const [mode, token] = pos;
143
+ if (!Object.hasOwn(FORWARD, mode ?? "") || !token) throw new Error("usage: share forward on|off|default <token>");
144
+ await ops.setShareForwarding(opts, token, FORWARD[mode]);
145
+ console.log(`OK forwarding ${mode === "default" ? "follows the deck default" : mode} for ${token}`);
146
+ }
147
+
148
+ async function deckForward(pos, opts) {
149
+ const [mode, slug] = pos;
150
+ if ((mode !== "on" && mode !== "off") || !slug) throw new Error("usage: deck forward on|off <slug>");
151
+ await ops.setDeckForwarding(opts, slug, mode === "on");
152
+ console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
131
153
  }
132
154
 
133
155
  function shareUrl(pos, opts) {
@@ -281,7 +303,9 @@ commands:
281
303
  login [--token <pat>] [--broker <url>] [--dev]
282
304
  logout [--dev]
283
305
  whoami [--json] [--dev]
284
- deck list|stats|open|rm
306
+ deck list|stats|open|rm|forward
307
+ deck forward on|off <slug>
308
+ whether viewers can forward the deck's links (on by default; a link's own setting wins)
285
309
  deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
286
310
  scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md
287
311
  layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
@@ -298,8 +322,11 @@ commands:
298
322
  the skill, one topic at a time (what deck_guide serves)
299
323
  release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
300
324
  on a committed deck: check → narrate → import → share new per --to
301
- share new|list|on|off|mute|unmute|rm|url
302
- share new <slug> <recipient> [--qa] [--no-notify]
325
+ share new|list|on|off|forward|mute|unmute|rm|url
326
+ share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward]
327
+ share forward on|off|default <token>
328
+ let this link's viewer forward it as their own tracked link ("Dana via Peter",
329
+ one hop); default follows the deck. share off on a link also turns off its forwards.
303
330
  recipient archive|unarchive
304
331
  import <html-or-zip-or-url> --slug <slug> [--title title] [--status draft|live]
305
332
  import-design <project-id> --serve-token <token> --entry <file.html> --slug <slug>
@@ -341,6 +368,24 @@ commands:
341
368
  asks you to type "I consent" before anything is uploaded
342
369
  voice upload <file...> upload recordings and print their sample ids (for the MCP clone_voice tool)
343
370
  voice rm <name|id> delete a voice, also at the provider
371
+ voice hear <voice> | voice hear --narrator <name|id>
372
+ a short sample of the voice: its clip URL and the line it says
373
+ avatar [--json] your avatars (who presents decks in your voice) and the default
374
+ avatar new [--species <s>] pick a species, then an avatar (s shuffles); --json or an agent gets a pick link
375
+ avatar from-photo [file] [--save-to-minimoji]
376
+ draw a cartoon of you from a photo (no file: a selfie link); you approve it on a link first
377
+ avatar connect import your avatar from Minimoji with a code you enter on minimoji.com
378
+ avatar default <name|id|dash> make it the default
379
+ avatar remove <name|id> remove it; decks already shared keep working, captions only
380
+ deck avatar <slug> <name|id|none|default> the deck's own presenter (applies at its next release)
381
+ narrator [--json] who narrates your decks (a face plus a voice) and the default
382
+ narrator new a link where you pick a face and a voice; it becomes the default
383
+ narrator default <name|id|dash> make it the default
384
+ narrator remove <name|id> remove it; decks already shared keep working
385
+ narrator voice <name|id> <voice> change its voice (one of yours, or dash)
386
+ narrator rename <name|id> <new name> rename it
387
+ narrator face <name|id> <avatar> change its face (one of your avatars)
388
+ deck narrator <slug> <name|id|none|default> the deck's own narrator (none: captions only)
344
389
 
345
390
  global:
346
391
  --json
@@ -664,6 +709,87 @@ function commandTable(pos, rest, opts) {
664
709
  if (opts.json) return void console.log(JSON.stringify(out, null, 2));
665
710
  console.log(`OK removed voice "${out.name}"${out.removedAtProvider ? " (deleted at the provider; decks already narrated with it keep playing)" : ""}`);
666
711
  },
712
+ avatar: async () => {
713
+ const { avatarList } = await import("./avatar.mjs");
714
+ const out = await avatarList(opts);
715
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
716
+ },
717
+ "avatar new": async () => {
718
+ const { avatarNew } = await import("./avatar.mjs");
719
+ const { askLine } = await import("./init.mjs");
720
+ const out = await avatarNew(opts, { ask: askLine });
721
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
722
+ },
723
+ "avatar from-photo": async () => {
724
+ const { avatarFromPhoto } = await import("./avatar.mjs");
725
+ const out = await avatarFromPhoto(rest[0], opts);
726
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
727
+ },
728
+ "avatar connect": async () => {
729
+ const { avatarConnect } = await import("./avatar.mjs");
730
+ const out = await avatarConnect(opts);
731
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
732
+ },
733
+ "avatar default": async () => {
734
+ if (!rest[0]) throw new Error("usage: avatar default <name|id|dash>");
735
+ const out = await ops.setDefaultAvatar(opts, rest.join(" "));
736
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} is the default for decks in your voice`);
737
+ },
738
+ "avatar remove": async () => {
739
+ if (!rest[0]) throw new Error("usage: avatar remove <name|id>");
740
+ const out = await ops.removeAvatar(opts, rest.join(" "));
741
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK removed ${out.name}; decks already shared keep working`);
742
+ },
743
+ "deck avatar": async () => {
744
+ if (rest.length < 2) throw new Error("usage: deck avatar <slug> <name|id|none|default>");
745
+ const out = await ops.setDeckAvatar(opts, rest[0], rest.slice(1).join(" "));
746
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.avatar} (applies at its next release)`);
747
+ },
748
+ narrator: async () => {
749
+ const { narratorList } = await import("./narrator.mjs");
750
+ const out = await narratorList(opts);
751
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
752
+ },
753
+ "narrator new": async () => {
754
+ const { narratorNew } = await import("./narrator.mjs");
755
+ const out = await narratorNew(opts);
756
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
757
+ },
758
+ "narrator default": async () => {
759
+ if (!rest[0]) throw new Error("usage: narrator default <name|id|dash>");
760
+ const out = await ops.setDefaultNarrator(opts, rest.join(" "));
761
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} narrates your decks by default`);
762
+ },
763
+ "narrator remove": async () => {
764
+ if (!rest[0]) throw new Error("usage: narrator remove <name|id>");
765
+ const out = await ops.removeNarrator(opts, rest.join(" "));
766
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK removed ${out.name}; decks already shared keep working`);
767
+ },
768
+ "narrator voice": async () => {
769
+ if (rest.length < 2) throw new Error("usage: narrator voice <name|id> <voice>");
770
+ const out = await ops.setNarratorVoice(opts, rest[0], rest.slice(1).join(" "));
771
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} speaks in ${out.voice} now`);
772
+ },
773
+ "narrator rename": async () => {
774
+ if (rest.length < 2) throw new Error("usage: narrator rename <name|id> <new name>");
775
+ const out = await ops.renameNarrator(opts, rest[0], rest.slice(1).join(" "));
776
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.was} is ${out.name} now`);
777
+ },
778
+ "narrator face": async () => {
779
+ if (rest.length < 2) throw new Error("usage: narrator face <name|id> <avatar>");
780
+ const out = await ops.setNarratorFace(opts, rest[0], rest.slice(1).join(" "));
781
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} wears ${out.face} now`);
782
+ },
783
+ "voice hear": async () => {
784
+ if (!rest[0] && !opts.narrator) throw new Error("usage: voice hear <voice> | voice hear --narrator <name|id>");
785
+ const out = await ops.hearVoice(opts, opts.narrator ? { narrator: opts.narrator } : { voice: rest.join(" ") });
786
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `"${out.caption}"\n ${out.url}`);
787
+ },
788
+ "deck narrator": async () => {
789
+ if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
790
+ const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
791
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.narrator} (applies at its next narrate and release)`);
792
+ },
667
793
  "preview setup": async () => {
668
794
  const { setupCapture } = await import("./preview-capture.mjs");
669
795
  await setupCapture(rest, opts);
@@ -675,10 +801,12 @@ function commandTable(pos, rest, opts) {
675
801
  "deck stats": () => deckStats(rest, opts),
676
802
  "deck open": () => deckOpen(rest, opts),
677
803
  "deck rm": () => deckRemove(rest, opts),
804
+ "deck forward": () => deckForward(rest, opts),
678
805
  "share new": () => shareNew(rest, opts),
679
806
  "share list": () => shareList(rest, opts),
680
807
  "share on": () => shareToggle(rest, opts, true),
681
808
  "share off": () => shareToggle(rest, opts, false),
809
+ "share forward": () => shareForward(rest, opts),
682
810
  "share mute": async () => { if (!rest[0]) throw new Error("usage: share mute <token>"); await ops.setShareNotify(opts, rest[0], false); console.log(`OK muted ${rest[0]}`); },
683
811
  "share unmute": async () => { if (!rest[0]) throw new Error("usage: share unmute <token>"); await ops.setShareNotify(opts, rest[0], true); console.log(`OK notification preference requested for ${rest[0]} (QA links remain muted)`); },
684
812
  "share rm": () => shareRm(rest, opts),
package/src/files.mjs CHANGED
@@ -19,6 +19,8 @@ const FILES = {
19
19
  "templates/layouts/quote.html": () => new URL("../templates/layouts/quote.html", import.meta.url),
20
20
  "templates/layouts/team.html": () => new URL("../templates/layouts/team.html", import.meta.url),
21
21
  "templates/layouts/timeline.html": () => new URL("../templates/layouts/timeline.html", import.meta.url),
22
+ "templates/first-deck/self.json": () => new URL("../templates/first-deck/self.json", import.meta.url),
23
+ "templates/first-deck/character.json": () => new URL("../templates/first-deck/character.json", import.meta.url),
22
24
  "skills/hyperdecks/SKILL.md": () => new URL("../skills/hyperdecks/SKILL.md", import.meta.url),
23
25
  "skills/hyperdecks/chat.md": () => new URL("../skills/hyperdecks/chat.md", import.meta.url),
24
26
  };
@@ -0,0 +1,105 @@
1
+ // The first deck `hyperdecks init` (and the hosted get_started tool) makes: a
2
+ // three-slide hello deck (hero, features, closer) filled from three answers.
3
+ // No file or process I/O beyond the packaged templates, so the hosted MCP route
4
+ // can bundle it.
5
+ import { packageText } from "./files.mjs";
6
+ import { slugify } from "./importer.mjs";
7
+
8
+ export const FIRST_DECK_LAYOUTS = "hero,features,closer";
9
+ export const DASH_AVATAR = "https://decks.hypersocial.ai/player/v1/dash-avatar.json";
10
+ export const ANSWER_MAX = { name: 40, about: 80, ask: 80 };
11
+
12
+ // One answer as it goes on a slide and into the script: one line, no stage-tag
13
+ // brackets, ending in a full stop so it is its own caption.
14
+ export function cleanAnswer(key, value) {
15
+ const text = String(value ?? "").replace(/[[\]]/g, "").replace(/\s+/g, " ").trim();
16
+ if (text.length < 2) throw new Error(`--${key} needs an answer`);
17
+ if (text.length > ANSWER_MAX[key]) throw new Error(`--${key} is ${text.length} characters; keep it to ${ANSWER_MAX[key]} so it fits a caption`);
18
+ return key === "name" ? text.replace(/[.!?]+$/, "") : /[.!?…]$/.test(text) ? text : `${text}.`;
19
+ }
20
+
21
+ export const firstDeckSlug = (email) => `hello-${slugify(String(email ?? "").split("@")[0]) || "there"}`.slice(0, 58).replace(/-+$/, "");
22
+
23
+ const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
24
+
25
+ export function firstDeckCopy(presenter) {
26
+ return JSON.parse(packageText(`templates/first-deck/${presenter === "dash" ? "character" : "self"}.json`));
27
+ }
28
+
29
+ function slots(text, answers, html) {
30
+ const map = { __NAME__: answers.name, __WHAT__: answers.about, __ASK__: answers.ask };
31
+ return text.replace(/__NAME__|__WHAT__|__ASK__/g, (k) => (html ? escapeHtml(map[k]) : map[k]));
32
+ }
33
+
34
+ // Replace exactly one match, or fail loudly: a layout change must not ship a deck
35
+ // that still says "One sentence on what this deck is about."
36
+ function swap(html, pattern, to, what) {
37
+ let hit = 0;
38
+ const out = html.replace(pattern, (...m) => (hit++, typeof to === "function" ? to(...m) : to));
39
+ if (hit !== 1) throw new Error(`first deck: the ${what} in the layout template changed (${hit} matches); update init's first-deck fill`);
40
+ return out;
41
+ }
42
+
43
+ function section(html, id, edit) {
44
+ return swap(html, new RegExp(`<section\\b[^>]*\\sid="${id}"[\\s\\S]*?</section>`), (s) => edit(s), `#${id} slide`);
45
+ }
46
+
47
+ // index.html and script.md of the scaffolded hero,features,closer deck, filled
48
+ // with the answers: no template copy is left, and no generated imagery.
49
+ export function fillFirstDeck({ html, title, answers, presenter, voice }) {
50
+ const copy = firstDeckCopy(presenter);
51
+ const h = (t) => slots(t, answers, true);
52
+ const note = (s) => swap(s, /\sdata-note="[^"]*"/, ' data-note="From your init answers."', "data-note");
53
+ let out = section(html, "hero", (s) => {
54
+ s = note(s);
55
+ s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.hero.kicker)}</div>`, "hero kicker");
56
+ s = swap(s, /<h1 class="tt"[^>]*>[\s\S]*?<\/h1>/, `<h1 class="tt" data-title-mobile="${h(copy.hero.title)}">${h(copy.hero.title)}</h1>`, "hero title");
57
+ return swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.hero.lead)}</p>`, "hero lead");
58
+ });
59
+ out = section(out, "features", (s) => {
60
+ s = note(s);
61
+ s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.features.kicker)}</div>`, "features kicker");
62
+ s = swap(s, /<h2 class="tt"[^>]*>[\s\S]*?<\/h2>/, `<h2 class="tt" data-title-mobile="${h(copy.features.title)}">${h(copy.features.title)}</h2>`, "features title");
63
+ copy.features.cards.forEach((card, i) => {
64
+ s = swap(s, new RegExp(`(<li class="card" id="features-f${i + 1}">[\\s\\S]*?</svg>)<b>[^<]*</b><p>[^<]*</p>`), (_, icon) => `${icon}<b>${h(card.name)}</b><p>${h(card.line)}</p>`, `features card ${i + 1}`);
65
+ });
66
+ return s;
67
+ });
68
+ out = section(out, "closer", (s) => {
69
+ s = note(s);
70
+ s = swap(s, /<h2 class="tt"[^>]*>[\s\S]*?<\/h2>/, `<h2 class="tt" data-title-mobile="${h(copy.closer.title)}">${h(copy.closer.title)}</h2>`, "closer title");
71
+ s = swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.closer.lead)}</p>`, "closer lead");
72
+ s = swap(s, /<a class="cta"[^>]*>[\s\S]*?<\/a>/, `<p class="cta" id="closer-cta">${h(copy.closer.cta)}</p>`, "closer call to action");
73
+ return swap(s, /<div class="contact muted">[^<]*<\/div>/, `<div class="contact muted">${h(copy.closer.contact)}</div>`, "closer contact");
74
+ });
75
+ // A character's live head presents its own deck; a person's voice ships with
76
+ // captions only (data-presenter="none"), never on a character's face.
77
+ if (presenter === "dash") {
78
+ out = swap(out, /(<script type="module" src="https:\/\/decks\.hypersocial\.ai\/player\/v1\/player\.js"[^>]*?)\sdata-presenter="none"/, (_, tag) => `${tag} data-presenter="head" data-avatar="${DASH_AVATAR}"`, "player tag");
79
+ }
80
+ const s = (t) => slots(t, answers, false);
81
+ const script = `---
82
+ voice: ${voice}
83
+ maxTotalSeconds: 90
84
+ transition: drift
85
+ ---
86
+
87
+ # ${title} — narration script
88
+
89
+ Written by \`hyperdecks init\` from your three answers. Make it yours: rewrite
90
+ these lines and the slides in index.html, then
91
+
92
+ hyperdecks narrate .
93
+ hyperdecks release . --to "<name>"
94
+
95
+ ## hero
96
+ ${s(copy.hero.script)}
97
+
98
+ ## features
99
+ ${s(copy.features.script)}
100
+
101
+ ## closer
102
+ ${s(copy.closer.script)}
103
+ `;
104
+ return { html: out, script };
105
+ }
package/src/init.mjs CHANGED
@@ -6,7 +6,8 @@
6
6
  // QA link and print it. Every step checks real state before it acts, so running
7
7
  // init again resumes where it stopped and changes nothing once it is complete.
8
8
  // Each step returns { step, status: "done" | "skipped" | "ran" | "needs-human", detail }.
9
- // CLI-only, like login: there is no MCP tool for it.
9
+ // In chat (no folder, no git) the hosted MCP's get_started tool walks the same
10
+ // steps on a hosted draft (lib/getStarted.ts); the deck fill is shared (firstDeck.mjs).
10
11
  import { execFileSync } from "node:child_process";
11
12
  import { createHash, randomBytes } from "node:crypto";
12
13
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
@@ -15,7 +16,7 @@ import { dirname, join, relative, resolve } from "node:path";
15
16
  import { createInterface } from "node:readline/promises";
16
17
  import { fileURLToPath } from "node:url";
17
18
  import * as ops from "./ops.mjs";
18
- import { slugify } from "./importer.mjs";
19
+ import { ANSWER_MAX, DASH_AVATAR, FIRST_DECK_LAYOUTS, cleanAnswer, fillFirstDeck, firstDeckCopy, firstDeckSlug } from "./firstDeck.mjs";
19
20
  import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, login as runLogin, openBrowser, target } from "./runtime.mjs";
20
21
  import { READING_PASSAGE, voiceClone as runVoiceClone } from "./voice.mjs";
21
22
  import { assembleLayoutDeck, deckNew as runDeckNew } from "./scaffold.mjs";
@@ -23,17 +24,13 @@ import { narrate as runNarrate } from "./narrate.mjs";
23
24
  import { release as runRelease } from "./release.mjs";
24
25
 
25
26
  export const STATE_FILE = join(CRED_DIR, "init.json");
26
- export const FIRST_DECK_LAYOUTS = "hero,features,closer";
27
- export const DASH_AVATAR = "https://decks.hypersocial.ai/player/v1/dash-avatar.json";
28
27
  // Same numbers as convex/lib/tokenCost.ts (the published CLI cannot import it).
29
28
  export const VOICE_CLONE_COST = 10;
30
29
  export const STARTER_GRANT = 30;
31
- export const ANSWER_MAX = { name: 40, about: 80, ask: 80 };
32
30
  const SKILL_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "skills", "hyperdecks");
33
- // CLI-only, so read next to the package rather than through files.mjs (the hosted route's list).
34
- const FIRST_DECK_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "templates", "first-deck");
35
31
  const CHARACTERS = ["dash", "d.a.s.h."];
36
32
  const RESUME = "npx hyperdecks init";
33
+ export { ANSWER_MAX, DASH_AVATAR, FIRST_DECK_LAYOUTS, cleanAnswer, fillFirstDeck, firstDeckCopy, firstDeckSlug };
37
34
  export const AGENT_VOICE_HELP = "ask the user to run `npx hyperdecks voice clone <file> --name <name>` in a terminal, " +
38
35
  "or upload with `voice upload` and call `clone_voice` after they agree to the statement; " +
39
36
  "or run init again with `--voice dash` to let D.A.S.H. present";
@@ -66,100 +63,6 @@ export function writeState(path, all) {
66
63
 
67
64
  // ---- answers and the first deck -------------------------------------------
68
65
 
69
- // One answer as it goes on a slide and into the script: one line, no stage-tag
70
- // brackets, ending in a full stop so it is its own caption.
71
- export function cleanAnswer(key, value) {
72
- const text = String(value ?? "").replace(/[[\]]/g, "").replace(/\s+/g, " ").trim();
73
- if (text.length < 2) throw new Error(`--${key} needs an answer`);
74
- if (text.length > ANSWER_MAX[key]) throw new Error(`--${key} is ${text.length} characters; keep it to ${ANSWER_MAX[key]} so it fits a caption`);
75
- return key === "name" ? text.replace(/[.!?]+$/, "") : /[.!?…]$/.test(text) ? text : `${text}.`;
76
- }
77
-
78
- export const firstDeckSlug = (email) => `hello-${slugify(String(email ?? "").split("@")[0]) || "there"}`.slice(0, 58).replace(/-+$/, "");
79
-
80
- const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
81
-
82
- export function firstDeckCopy(presenter) {
83
- return JSON.parse(readFileSync(join(FIRST_DECK_DIR, `${presenter === "dash" ? "character" : "self"}.json`), "utf8"));
84
- }
85
-
86
- function slots(text, answers, html) {
87
- const map = { __NAME__: answers.name, __WHAT__: answers.about, __ASK__: answers.ask };
88
- return text.replace(/__NAME__|__WHAT__|__ASK__/g, (k) => (html ? escapeHtml(map[k]) : map[k]));
89
- }
90
-
91
- // Replace exactly one match, or fail loudly: a layout change must not ship a deck
92
- // that still says "One sentence on what this deck is about."
93
- function swap(html, pattern, to, what) {
94
- let hit = 0;
95
- const out = html.replace(pattern, (...m) => (hit++, typeof to === "function" ? to(...m) : to));
96
- if (hit !== 1) throw new Error(`first deck: the ${what} in the layout template changed (${hit} matches); update init's first-deck fill`);
97
- return out;
98
- }
99
-
100
- function section(html, id, edit) {
101
- return swap(html, new RegExp(`<section\\b[^>]*\\sid="${id}"[\\s\\S]*?</section>`), (s) => edit(s), `#${id} slide`);
102
- }
103
-
104
- // index.html and script.md of the scaffolded hero,features,closer deck, filled
105
- // with the answers: no template copy is left, and no generated imagery.
106
- export function fillFirstDeck({ html, title, answers, presenter, voice }) {
107
- const copy = firstDeckCopy(presenter);
108
- const h = (t) => slots(t, answers, true);
109
- const note = (s) => swap(s, /\sdata-note="[^"]*"/, ' data-note="From your init answers."', "data-note");
110
- let out = section(html, "hero", (s) => {
111
- s = note(s);
112
- s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.hero.kicker)}</div>`, "hero kicker");
113
- s = swap(s, /<h1 class="tt"[^>]*>[\s\S]*?<\/h1>/, `<h1 class="tt" data-title-mobile="${h(copy.hero.title)}">${h(copy.hero.title)}</h1>`, "hero title");
114
- return swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.hero.lead)}</p>`, "hero lead");
115
- });
116
- out = section(out, "features", (s) => {
117
- s = note(s);
118
- s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.features.kicker)}</div>`, "features kicker");
119
- s = swap(s, /<h2 class="tt"[^>]*>[\s\S]*?<\/h2>/, `<h2 class="tt" data-title-mobile="${h(copy.features.title)}">${h(copy.features.title)}</h2>`, "features title");
120
- copy.features.cards.forEach((card, i) => {
121
- s = swap(s, new RegExp(`(<li class="card" id="features-f${i + 1}">[\\s\\S]*?</svg>)<b>[^<]*</b><p>[^<]*</p>`), (_, icon) => `${icon}<b>${h(card.name)}</b><p>${h(card.line)}</p>`, `features card ${i + 1}`);
122
- });
123
- return s;
124
- });
125
- out = section(out, "closer", (s) => {
126
- s = note(s);
127
- s = swap(s, /<h2 class="tt"[^>]*>[\s\S]*?<\/h2>/, `<h2 class="tt" data-title-mobile="${h(copy.closer.title)}">${h(copy.closer.title)}</h2>`, "closer title");
128
- s = swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.closer.lead)}</p>`, "closer lead");
129
- s = swap(s, /<a class="cta"[^>]*>[\s\S]*?<\/a>/, `<p class="cta" id="closer-cta">${h(copy.closer.cta)}</p>`, "closer call to action");
130
- return swap(s, /<div class="contact muted">[^<]*<\/div>/, `<div class="contact muted">${h(copy.closer.contact)}</div>`, "closer contact");
131
- });
132
- // A character's live head presents its own deck; a person's voice ships with
133
- // captions only (data-presenter="none"), never on a character's face.
134
- if (presenter === "dash") {
135
- out = swap(out, /(<script type="module" src="https:\/\/decks\.hypersocial\.ai\/player\/v1\/player\.js"[^>]*?)\sdata-presenter="none"/, (_, tag) => `${tag} data-presenter="head" data-avatar="${DASH_AVATAR}"`, "player tag");
136
- }
137
- const s = (t) => slots(t, answers, false);
138
- const script = `---
139
- voice: ${voice}
140
- maxTotalSeconds: 90
141
- transition: drift
142
- ---
143
-
144
- # ${title} — narration script
145
-
146
- Written by \`hyperdecks init\` from your three answers. Make it yours: rewrite
147
- these lines and the slides in index.html, then
148
-
149
- hyperdecks narrate .
150
- hyperdecks release . --to "<name>"
151
-
152
- ## hero
153
- ${s(copy.hero.script)}
154
-
155
- ## features
156
- ${s(copy.features.script)}
157
-
158
- ## closer
159
- ${s(copy.closer.script)}
160
- `;
161
- return { html: out, script };
162
- }
163
66
 
164
67
  // ---- git ------------------------------------------------------------------
165
68
 
@@ -279,6 +182,7 @@ export async function init(pos = [], opts = {}, io = {}) {
279
182
  env = process.env,
280
183
  random = () => randomBytes(2).toString("hex"),
281
184
  fetch,
185
+ avatars = null, // the avatar.mjs commands (tests pass fakes)
282
186
  } = io;
283
187
  if (pos.length) throw new Error(`unexpected argument(s): ${pos.join(" ")}; init takes flags only (hyperdecks init --help)`);
284
188
  for (const k of ["voice", "name", "about", "ask", "dir"]) if (k in opts && typeof opts[k] !== "string") throw new Error(`--${k} requires a value`);
@@ -335,7 +239,17 @@ export async function init(pos = [], opts = {}, io = {}) {
335
239
  const voiceStep = await chooseVoice(base, opts, { interactive, ask, voiceClone, sub, log, state, who });
336
240
  record(voiceStep);
337
241
  if (voiceStep.status === "needs-human") return finish("incomplete");
338
- const { voice, presenter } = voiceStep;
242
+ let { voice, presenter } = voiceStep;
243
+
244
+ // 3b. Who presents a deck in your own voice: the deck stays captions-only in
245
+ // its source, and release puts your default avatar on it.
246
+ if (presenter === "self") {
247
+ const face = await presenterStep(base, opts, { interactive, ask, log, sub, avatars: avatars ?? (await import("./avatar.mjs")), state });
248
+ record(face);
249
+ if (face.presenter === "dash") ({ voice, presenter } = face);
250
+ // Asked once, unless the step could not even look (it asks again next run).
251
+ if (!face.warning) save({ presenterChosen: true });
252
+ }
339
253
 
340
254
  // 4. The first deck.
341
255
  const scaffold = await scaffoldStep(base, opts, { state, who, voice, presenter, interactive, ask, deckNew, cwd, home, random, sub, log, save });
@@ -494,6 +408,48 @@ async function chooseVoice(base, opts, { interactive, ask, voiceClone, sub, log,
494
408
  }
495
409
  }
496
410
 
411
+ // Who narrates your decks: your own voice needs a face, or the deck plays
412
+ // captions only. Only you pick it: without a terminal (an agent) this points
413
+ // at `hyperdecks narrator new`.
414
+ async function presenterStep(base, opts, { interactive, ask, log, sub, avatars, state }) {
415
+ let mine;
416
+ try {
417
+ mine = await ops.listAvatars(base);
418
+ } catch (e) {
419
+ const why = ops.errorText(e);
420
+ if (/not switched on/.test(why)) return step("presenter", "skipped", "presenter: captions only (avatars are not switched on for this account yet)");
421
+ return step("presenter", "skipped", `presenter: captions only for now (${why}); run init again, or \`hyperdecks avatar new\``, { warning: true });
422
+ }
423
+ const current = mine.avatars.find((a) => a.isDefault && a.source !== "dash");
424
+ if (current) return step("presenter", "done", `presenter: ${current.name}`);
425
+ if (!interactive || state.presenterChosen) {
426
+ return step("presenter", "skipped", "presenter: captions only; give your decks a narrator (a face plus your voice) with `hyperdecks narrator new`");
427
+ }
428
+ const io = { ask, log: sub, interactive: true };
429
+ for (;;) {
430
+ log("\nWho narrates your decks?");
431
+ log(" 1 D.A.S.H. our robot, in its own voice");
432
+ log(" 2 Pick a Minimoji choose a species, in your voice");
433
+ log(" 3 Make one from a selfie a cartoon of you, you approve it");
434
+ log(" 4 Import from Minimoji one you already have");
435
+ log(" 5 Nobody captions only");
436
+ const pick = (await ask("Choose 1-5 [2]: ")).trim() || "2";
437
+ try {
438
+ if (pick === "1") return step("presenter", "done", "D.A.S.H. presents, in the dash character voice", { voice: "dash", presenter: "dash" });
439
+ if (pick === "2") { const out = await avatars.avatarNew(base, io); return step("presenter", "ran", `presenter: ${out.name}`); }
440
+ if (pick === "3") {
441
+ const file = parseDroppedPaths(await ask("Drag a photo (.jpg or .png) here, or press Enter for a selfie link: "))[0];
442
+ const out = await avatars.avatarFromPhoto(file, base, io);
443
+ return step("presenter", "ran", `presenter: ${out.name}`);
444
+ }
445
+ if (pick === "4") { await avatars.avatarConnect(base, io); return step("presenter", "ran", "presenter: your Minimoji"); }
446
+ if (pick === "5") return step("presenter", "skipped", "presenter: captions only; add a narrator later with `hyperdecks narrator new`");
447
+ } catch (e) {
448
+ log(`That did not finish: ${ops.errorText(e)}`);
449
+ }
450
+ }
451
+ }
452
+
497
453
  async function deckOwnedBy(base, slug, who) {
498
454
  const deck = await (await client(base)).query(api.decks.get, { slug });
499
455
  return deck ? { exists: true, mine: deck.ownerId === who.subject } : { exists: false, mine: false };
@@ -0,0 +1,29 @@
1
+ // hyperdecks narrator [list] | new | default <id> | remove <id> | voice <id> <voice>
2
+ //
3
+ // Who narrates your decks: a face plus a voice (convex/narrators.ts). Only a
4
+ // person adds one: `narrator new` hands back a link where they pick the face
5
+ // and the voice; in a terminal it waits for them, with --json (or an agent) it
6
+ // returns the link and never chooses.
7
+ import * as ops from "./ops.mjs";
8
+
9
+ const interactiveOf = (opts) => !opts.json && !!process.stdin.isTTY && !!process.stdout.isTTY;
10
+
11
+ export async function narratorList(opts, { log = console.log } = {}) {
12
+ const out = await ops.listNarrators(opts);
13
+ if (opts.json) return out;
14
+ for (const n of out.narrators) log(`${n.isDefault ? "*" : " "} ${n.name}\t${n.face ?? "(no face)"} · ${n.voice ? `${n.voice}${n.voiceReady ? "" : " (not ready)"}` : "(voice removed)"}\t${n.id}`);
15
+ if (out.note) log(`\n${out.note}`);
16
+ log("\n* the default; decks use it unless they set their own (deck narrator <slug> <id>)");
17
+ return out;
18
+ }
19
+
20
+ export async function narratorNew(opts, { log = console.log, interactive = interactiveOf(opts) } = {}) {
21
+ const link = await ops.newNarratorLink(opts);
22
+ if (opts.json) return link;
23
+ log(`Pick your narrator's face and voice here (only you can choose them):\n ${link.url}`);
24
+ if (!interactive) return link;
25
+ log("Waiting for you…");
26
+ const narrator = await ops.waitForNarrator(opts, link.token);
27
+ log(`✓ Added ${narrator.name}. \`hyperdecks narrator\` shows which one is the default.`);
28
+ return { status: "added", ...narrator };
29
+ }
package/src/ops.mjs CHANGED
@@ -395,6 +395,14 @@ export async function listShares(opts, slug) {
395
395
  recipientEmail: r.recipientEmail ?? null,
396
396
  expiresAt: r.expiresAt ?? null,
397
397
  viewLimit: r.viewLimit ?? null,
398
+ // Forwarding: allowForward is the effective setting (link override, else
399
+ // the deck's, else on); a forwarded link carries its parent's token and the
400
+ // viewer who forwarded it. Rows come back with each link's forwards after it.
401
+ allowForward: !!r.allowForward,
402
+ allowForwardOverride: r.allowForwardOverride ?? null,
403
+ parentToken: r.parentToken ?? null,
404
+ forwardedBy: r.forwardedBy ?? null,
405
+ probablyForwarded: !!r.probablyForwarded,
398
406
  }));
399
407
  }
400
408
 
@@ -407,6 +415,7 @@ export async function createShare(opts, fields) {
407
415
  if (fields.email) args.recipientEmail = fields.email;
408
416
  if (fields.note) args.note = fields.note;
409
417
  if (fields.requireEmail) args.requireEmail = true;
418
+ if (fields.allowForward !== undefined) args.allowForward = !!fields.allowForward;
410
419
  if (fields.expires) {
411
420
  const t = Date.parse(fields.expires);
412
421
  if (Number.isNaN(t)) throw new Error(`expires must be an ISO date; got ${fields.expires}`);
@@ -433,8 +442,23 @@ export async function setShareNotify(opts, token, notify) {
433
442
 
434
443
  export async function removeShare(opts, token) {
435
444
  if (!token) throw new Error("token is required");
436
- await (await client(opts)).mutation(api.shares.remove, { token });
437
- return { token, removed: true };
445
+ const out = await (await client(opts)).mutation(api.shares.remove, { token });
446
+ return { token, removed: true, removedForwards: out?.removedForwards ?? 0 };
447
+ }
448
+
449
+ // Per-link forwarding override: true/false, or null to follow the deck default.
450
+ export async function setShareForwarding(opts, token, allowForward) {
451
+ if (!token) throw new Error("token is required");
452
+ const value = allowForward === null || allowForward === undefined ? null : !!allowForward;
453
+ await (await client(opts)).mutation(api.shares.setAllowForward, { token, allowForward: value });
454
+ return { token, allowForward: value };
455
+ }
456
+
457
+ // The deck default for every link without its own override (on unless set off).
458
+ export async function setDeckForwarding(opts, slug, allowForward) {
459
+ if (!slug) throw new Error("slug is required");
460
+ await (await client(opts)).mutation(api.decks.setAllowForward, { slug, allowForward: !!allowForward });
461
+ return { slug, allowForward: !!allowForward };
438
462
  }
439
463
 
440
464
  // Register a new deck (draft) you own. Refuses any slug that already exists:
@@ -830,3 +854,257 @@ export async function importDeckFromDesign(opts = {}, fields = {}, adapter = {})
830
854
  );
831
855
  return { ...result, fetched: files.size, missing };
832
856
  }
857
+
858
+ // ---- avatars (convex/avatars.ts) --------------------------------------------
859
+ // The face that presents decks narrated in your own voice. An agent never picks
860
+ // or approves a face: every way that needs a choice hands back a link (and for
861
+ // an import, a code) for the person; only an interactive terminal picks itself.
862
+
863
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
864
+
865
+ // Your avatars, D.A.S.H. first, after following any edits or revokes made on
866
+ // minimoji.com (best effort: a Minimoji blip still lists them).
867
+ export async function listAvatars(opts = {}) {
868
+ const c = await client(opts);
869
+ await c.action(api.avatarsNode.refresh, {}).catch(() => {});
870
+ const out = await c.query(api.avatars.listAvatars, {});
871
+ if (!out.enabled) throw new Error("avatars are not switched on for this account yet");
872
+ return { default: out.defaultId, avatars: out.avatars.map(({ id, name, source, species, isDefault, linked }) => ({ id, name, source, species: species ?? null, isDefault, linked: !!linked })) };
873
+ }
874
+
875
+ export async function findAvatar(opts, ref) {
876
+ const want = String(ref ?? "").trim().toLowerCase();
877
+ if (!want) throw new Error("name an avatar (its id or name; `dash` for D.A.S.H.)");
878
+ const { avatars } = await listAvatars(opts);
879
+ const hit = avatars.find((a) => a.id === ref || a.name.toLowerCase() === want || (want === "dash" && a.source === "dash"));
880
+ if (!hit) throw new Error(`no avatar "${ref}" (yours: ${avatars.map((a) => a.name).join(", ")})`);
881
+ return hit;
882
+ }
883
+
884
+ export async function setDefaultAvatar(opts, ref) {
885
+ const a = await findAvatar(opts, ref);
886
+ await (await client(opts)).mutation(api.avatars.setDefault, { avatarId: a.id });
887
+ return { default: a.id, name: a.name };
888
+ }
889
+
890
+ export async function removeAvatar(opts, ref) {
891
+ const a = await findAvatar(opts, ref);
892
+ if (a.source === "dash") throw new Error("D.A.S.H. is built in and cannot be removed");
893
+ return { ...(await (await client(opts)).mutation(api.avatars.removeAvatar, { avatarId: a.id })), id: a.id };
894
+ }
895
+
896
+ // A deck's own presenter: an avatar, `none` (captions only) or `default`.
897
+ export async function setDeckAvatar(opts, slug, ref) {
898
+ if (!slug) throw new Error("usage: deck avatar <slug> <avatar|none|default>");
899
+ const value = ref === "none" || ref === "default" ? ref : (await findAvatar(opts, ref)).id;
900
+ if (value === "dash") throw new Error("D.A.S.H. presents decks in its own voice; for an own-voice deck pick one of your avatars, none or default");
901
+ return (await client(opts)).mutation(api.avatars.setDeckAvatar, { slug, avatar: value });
902
+ }
903
+
904
+ // The link where the person picks one (optionally of one species).
905
+ export async function newAvatarLink(opts, { species } = {}) {
906
+ const link = await (await client(opts)).mutation(api.avatars.createLink, { kind: "pick", ...(species ? { species: String(species) } : {}) });
907
+ return { status: "waiting", pickUrl: link.url, token: link.token, expiresAt: link.expiresAt };
908
+ }
909
+
910
+ export async function avatarOptions(opts, species, seed = 0) {
911
+ return (await client(opts)).action(api.avatarsNode.options, { species: String(species), seed });
912
+ }
913
+
914
+ export async function addPickedAvatar(opts, avatarKey) {
915
+ return (await client(opts)).mutation(api.avatars.addPicked, { avatarKey });
916
+ }
917
+
918
+ // A face from a photo. With bytes: upload and draw it, then hand back the link
919
+ // where the person approves it (nothing is used before). Without: the link
920
+ // where the person takes a selfie in their browser.
921
+ export async function avatarFromPhoto(opts, { bytes, contentType } = {}, { onProgress } = {}) {
922
+ const c = await client(opts);
923
+ if (!bytes) {
924
+ const link = await c.mutation(api.avatars.createLink, { kind: "selfie" });
925
+ return { status: "waiting", selfieUrl: link.url, token: link.token, expiresAt: link.expiresAt };
926
+ }
927
+ if (!/^image\/(jpeg|png)$/.test(contentType ?? "")) throw new Error("the photo must be a .jpg or .png");
928
+ const storageId = await runtimeUploadBytes(c, bytes, contentType);
929
+ const { draftId } = await c.mutation(api.avatars.drawSelfie, { storageId });
930
+ onProgress?.("drawing…");
931
+ const draft = await pollUntil(() => c.query(api.avatars.getDraft, { draftId }), (d) => !d || d.status !== "drawing", { timeoutMs: 6 * 60_000 });
932
+ if (!draft) throw new Error("the drawing is gone; run the command again");
933
+ if (draft.status === "failed") throw new Error(draft.error || "the drawing failed; try another photo");
934
+ const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
935
+ return { status: "waiting", reviewUrl: link.url, token: link.token, draftId, expiresAt: link.expiresAt };
936
+ }
937
+
938
+ // Import from Minimoji with a device code: the person opens `url`, enters
939
+ // `code` and picks one there. The server watches for the answer.
940
+ export async function connectAvatar(opts = {}) {
941
+ const out = await (await client(opts)).action(api.avatarsNode.startConnect, { makeDefault: true });
942
+ return { status: "waiting", url: out.url, code: out.userCode, expiresIn: out.expiresIn, connectId: out.connectId };
943
+ }
944
+
945
+ export async function waitForConnect(opts, connectId, { timeoutMs = 11 * 60_000 } = {}) {
946
+ const c = await client(opts);
947
+ return pollUntil(() => c.query(api.avatars.getConnect, { connectId }), (x) => x.status !== "waiting", { timeoutMs });
948
+ }
949
+
950
+ // Wait for the person to finish on a /presenter link; returns the avatar added.
951
+ export async function waitForLink(opts, token, { timeoutMs = 60 * 60_000 } = {}) {
952
+ const c = await client(opts);
953
+ const link = await pollUntil(() => c.query(api.avatars.getLink, { token }), (l) => !!l.avatar || l.expired, { timeoutMs });
954
+ if (!link.avatar) throw new Error("the link expired before an avatar was added; run the command again");
955
+ return link.avatar;
956
+ }
957
+
958
+ export async function presenterFor(opts, slug, voice) {
959
+ return (await client(opts)).query(api.avatars.presenterFor, { slug, ...(voice ? { voice: String(voice) } : {}) });
960
+ }
961
+
962
+ // ---- narrators (convex/narrators.ts) ------------------------------------------
963
+ // Who narrates your decks: a face plus a voice. D.A.S.H. is built in. As with
964
+ // avatars, an agent never adds one: `narrator new` hands back a link.
965
+
966
+ export async function listNarrators(opts = {}) {
967
+ const out = await (await client(opts)).query(api.narrators.list, {});
968
+ return {
969
+ default: out.defaultId,
970
+ ...(out.unpickedVoice ? { note: `no default narrator yet: decks narrate in your voice "${out.unpickedVoice}"` } : {}),
971
+ narrators: out.narrators.map((n) => ({ id: n.id, name: n.name, isDefault: n.isDefault, builtin: n.builtin, face: n.face?.name ?? null, voice: n.voice?.name ?? null, voiceReady: !!n.voice?.ready })),
972
+ voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, narrators: x.narrators })),
973
+ };
974
+ }
975
+
976
+ export async function findNarrator(opts, ref) {
977
+ const want = String(ref ?? "").trim().toLowerCase();
978
+ if (!want) throw new Error("name a narrator (its id or name; `dash` for D.A.S.H.)");
979
+ const { narrators } = await listNarrators(opts);
980
+ const byId = narrators.find((n) => n.id === ref || (want === "dash" && n.builtin));
981
+ if (byId) return byId;
982
+ // Names repeat ("Me" is the default): an ambiguous name never picks one.
983
+ const named = narrators.filter((n) => n.name.toLowerCase() === want);
984
+ if (named.length > 1) throw new Error(`"${ref}" matches ${named.length} narrators (ids: ${named.map((n) => n.id).join(", ")}); use the id`);
985
+ if (!named.length) throw new Error(`no narrator "${ref}" (yours: ${narrators.map((n) => `${n.name} ${n.id}`).join(", ")})`);
986
+ return named[0];
987
+ }
988
+
989
+ // The link where the person picks a face and a voice; it becomes their default.
990
+ export async function newNarratorLink(opts = {}) {
991
+ const link = await (await client(opts)).mutation(api.narrators.newLink, {});
992
+ return { status: "waiting", url: link.url, token: link.token, expiresAt: link.expiresAt };
993
+ }
994
+
995
+ export async function waitForNarrator(opts, token, { timeoutMs = 60 * 60_000 } = {}) {
996
+ const c = await client(opts);
997
+ const link = await pollUntil(() => c.query(api.avatars.getLink, { token }), (l) => !!l.narrator || l.expired, { timeoutMs });
998
+ if (!link.narrator) throw new Error("the link expired before a narrator was added; run the command again");
999
+ return link.narrator;
1000
+ }
1001
+
1002
+ export async function setDefaultNarrator(opts, ref) {
1003
+ const n = await findNarrator(opts, ref);
1004
+ await (await client(opts)).mutation(api.narrators.setDefault, { narrator: n.id });
1005
+ return { default: n.id, name: n.name };
1006
+ }
1007
+
1008
+ export async function removeNarrator(opts, ref) {
1009
+ const n = await findNarrator(opts, ref);
1010
+ if (n.builtin) throw new Error("D.A.S.H. is built in and cannot be removed");
1011
+ return { ...(await (await client(opts)).mutation(api.narrators.remove, { narrator: n.id })), id: n.id };
1012
+ }
1013
+
1014
+ // voice: one of your ready voices (name or id) or dash.
1015
+ export async function setNarratorVoice(opts, ref, voice) {
1016
+ if (!voice) throw new Error("usage: narrator voice <narrator> <voice>");
1017
+ const n = await findNarrator(opts, ref);
1018
+ await (await client(opts)).mutation(api.narrators.setVoice, { narrator: n.id, voice: String(voice) });
1019
+ return { narrator: n.id, name: n.name, voice: String(voice) };
1020
+ }
1021
+
1022
+ export async function renameNarrator(opts, ref, name) {
1023
+ if (!String(name ?? "").trim()) throw new Error("usage: narrator rename <name|id> <new name>");
1024
+ const n = await findNarrator(opts, ref);
1025
+ if (n.builtin) throw new Error("D.A.S.H. is built in; its name stays");
1026
+ await (await client(opts)).mutation(api.narrators.rename, { narrator: n.id, name: String(name).trim() });
1027
+ return { narrator: n.id, was: n.name, name: String(name).trim() };
1028
+ }
1029
+
1030
+ // face: one of your avatars (name or id from `avatar`); D.A.S.H. keeps its own.
1031
+ export async function setNarratorFace(opts, ref, avatarRef) {
1032
+ if (!avatarRef) throw new Error("usage: narrator face <name|id> <avatar>");
1033
+ const n = await findNarrator(opts, ref);
1034
+ if (n.builtin) throw new Error("D.A.S.H. is built in and keeps its face");
1035
+ const a = await findAvatar(opts, avatarRef);
1036
+ if (a.source === "dash") throw new Error("D.A.S.H.'s face is D.A.S.H.'s; pick one of your avatars");
1037
+ await (await client(opts)).mutation(api.narrators.setFace, { narrator: n.id, avatarId: a.id });
1038
+ return { narrator: n.id, name: n.name, face: a.name };
1039
+ }
1040
+
1041
+ // A short sample of a voice, or of a narrator's voice: { url, caption } (what
1042
+ // the dashboard's play buttons play). A narrator also brings its face still.
1043
+ export async function hearVoice(opts, { voice, narrator } = {}) {
1044
+ const c = await client(opts);
1045
+ if (!voice && !narrator) throw new Error("name a voice or a narrator (list_voices / list_narrators)");
1046
+ let ref = voice ? String(voice) : null;
1047
+ let who = null;
1048
+ if (narrator) {
1049
+ const n = await findNarrator(opts, narrator);
1050
+ const raw = (await c.query(api.narrators.list, {})).narrators.find((x) => x.id === n.id);
1051
+ if (!raw?.voice) throw new Error(`${n.name} has no voice yet; give it one (narrator voice)`);
1052
+ if (!raw.voice.ready) throw new Error(`${n.name}'s voice ${raw.voice.name} is not ready yet`);
1053
+ ref = raw.voice.ref;
1054
+ who = { narrator: n.id, name: n.name, faceUrl: raw.face?.thumbUrl ?? null, faceName: raw.face?.name ?? null };
1055
+ }
1056
+ const out = await c.action(api.narratorsNode.hear, { voice: ref });
1057
+ return { ...out, voice: ref, ...(who ?? {}) };
1058
+ }
1059
+
1060
+ // A photo drawing started from chat (avatar_from_photo with a photo): still
1061
+ // drawing, failed, or drawn, when it hands back the link where the person
1062
+ // approves it. Nothing is used before they do.
1063
+ export async function startAvatarDrawing(opts, { bytes, contentType }) {
1064
+ if (!/^image\/(jpeg|png)$/.test(contentType ?? "")) throw new Error("the photo must be a .jpg or .png");
1065
+ const c = await client(opts);
1066
+ const storageId = await runtimeUploadBytes(c, bytes, contentType);
1067
+ const { draftId } = await c.mutation(api.avatars.drawSelfie, { storageId });
1068
+ return { status: "drawing", draftId };
1069
+ }
1070
+
1071
+ export async function avatarDraftStatus(opts, draftId) {
1072
+ if (!draftId) throw new Error("draftId is required");
1073
+ const c = await client(opts);
1074
+ const draft = await c.query(api.avatars.getDraft, { draftId: String(draftId) });
1075
+ if (!draft) throw new Error("the drawing is gone; start again with avatar_from_photo");
1076
+ if (draft.status === "drawing") return { status: "drawing", draftId };
1077
+ if (draft.status === "failed") throw new Error(draft.error || "the drawing failed; try another photo");
1078
+ if (draft.status === "added") return { status: "added", draftId, avatarId: draft.avatarId ?? null };
1079
+ if (draft.status !== "drawn") throw new Error(`the drawing is in an unexpected state (${draft.status}); start again with avatar_from_photo`);
1080
+ const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
1081
+ return { status: "waiting", reviewUrl: link.url, token: link.token, draftId, expiresAt: link.expiresAt };
1082
+ }
1083
+
1084
+ // A deck's own narrator: a narrator, `none` (captions only) or `default`.
1085
+ export async function setDeckNarrator(opts, slug, ref) {
1086
+ if (!slug || !ref) throw new Error("usage: deck narrator <slug> <narrator|none|default>");
1087
+ const value = ref === "none" || ref === "default" ? ref : (await findNarrator(opts, ref)).id;
1088
+ return (await client(opts)).mutation(api.narrators.setDeckNarrator, { slug, narrator: value });
1089
+ }
1090
+
1091
+ // Polls `read` until `done`; a few failed reads in a row (a network blip) are
1092
+ // retried, since the person may still be finishing in the browser.
1093
+ async function pollUntil(read, done, { intervalMs = 2000, timeoutMs }) {
1094
+ const start = Date.now();
1095
+ let misses = 0;
1096
+ for (;;) {
1097
+ let value;
1098
+ try {
1099
+ value = await read();
1100
+ misses = 0;
1101
+ } catch (e) {
1102
+ if (++misses >= 5) throw e;
1103
+ await sleep(intervalMs);
1104
+ continue;
1105
+ }
1106
+ if (done(value)) return value;
1107
+ if (Date.now() - start > timeoutMs) throw new Error("timed out waiting; run the command again");
1108
+ await sleep(intervalMs);
1109
+ }
1110
+ }
@@ -0,0 +1,28 @@
1
+ // The released deck's presenter (roadmap: avatars-pick-make-from-a-selfie-or-
2
+ // import-your-minimoji). A deck narrated in the owner's own voice is written
3
+ // captions-only (data-presenter="none"); at release, when the owner has an
4
+ // avatar, its player tag gets that face instead. Shared by the hosted release
5
+ // (convex/deckSources.ts) and the CLI's local release (release.mjs), so both
6
+ // rewrite the tag the same way. A tag that already names a presenter (D.A.S.H.
7
+ // on a character-voice deck, or one set by hand) is left alone.
8
+
9
+ const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/i;
10
+
11
+ const attr = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
12
+
13
+ // html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
14
+ // the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL),
15
+ // or html unchanged when there is no such tag or it already has a presenter.
16
+ export function withAvatar(html, { avatarUrl, imageUrl }) {
17
+ const tag = PLAYER_TAG.exec(html)?.[0];
18
+ if (!tag || /\sdata-avatar=/.test(tag)) return html;
19
+ // Captions only: data-presenter="none", or no presenter at all (the player's
20
+ // default when there is no data-presenter-image either).
21
+ const none = /\sdata-presenter="none"/.test(tag);
22
+ const unset = !/\sdata-presenter=/.test(tag) && !/\sdata-presenter-image=/.test(tag);
23
+ if (!none && !unset) return html;
24
+ const image = imageUrl && !/\sdata-presenter-image=/.test(tag) ? ` data-presenter-image="${attr(imageUrl)}"` : "";
25
+ const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
26
+ const next = none ? tag.replace(/\sdata-presenter="none"/, face) : tag.replace(/>$/, `${face}>`);
27
+ return html.replace(tag, () => next);
28
+ }
package/src/release.mjs CHANGED
@@ -15,6 +15,7 @@ import { readFileSync } from "node:fs";
15
15
  import { join, posix } from "node:path";
16
16
  import { zipSync } from "fflate";
17
17
  import * as ops from "./ops.mjs";
18
+ import { withAvatar } from "./presenter.mjs";
18
19
  import { target } from "./runtime.mjs";
19
20
  import { checkDeck, localRefs, resolveDeckDir } from "./check.mjs";
20
21
  import { narrate as runNarrate } from "./narrate.mjs";
@@ -47,8 +48,11 @@ export function deckCommit(deckDir) {
47
48
  }
48
49
 
49
50
  // What import uploads: the page alone, or a zip of the page and its local files.
50
- export function releaseBundle(deckDir) {
51
- const html = readFileSync(join(deckDir, "index.html"));
51
+ // `presenter` (from avatars.presenterFor) puts the owner's avatar on the
52
+ // uploaded page's player tag; the committed index.html is never changed.
53
+ export function releaseBundle(deckDir, presenter = null) {
54
+ const raw = readFileSync(join(deckDir, "index.html"));
55
+ const html = presenter ? Buffer.from(withAvatar(raw.toString("utf8"), presenter)) : raw;
52
56
  // check has already refused refs that leave the folder or do not decode.
53
57
  const refs = [...new Set(localRefs(html.toString("utf8")).map((r) => posix.normalize(decodeURIComponent(r)).replace(/^(\.?\/)+/, "")))];
54
58
  if (!refs.length) return { bytes: html, filename: "index.html", files: ["index.html"] };
@@ -95,13 +99,22 @@ export async function release(pos, opts = {}, io = {}) {
95
99
  throw new Error(`narration changed the deck (${after.split("\n").length} file(s)):\n${after}\n` +
96
100
  `run \`hyperdecks check ${pos[0]}\`, look at it, commit (index.html and narration/), then run release again`);
97
101
  }
102
+ // Narrated in your own voice: the released page gets your avatar's face.
103
+ let presenter = null;
104
+ try {
105
+ presenter = await ops.presenterFor(opts, slug, opts.voice);
106
+ } catch (e) {
107
+ log(`⚠ could not look up your avatar (${ops.errorText(e)}); releasing captions only`);
108
+ }
109
+ const released = presenter ? releaseBundle(deckDir, presenter) : bundle;
110
+ if (presenter) log("✓ presented by your avatar");
98
111
  const imported = await ops.importDeckFromBytes(opts, {
99
112
  slug,
100
113
  title: opts.title,
101
114
  status: opts.status || "live",
102
- bytes: bundle.bytes,
103
- filename: bundle.filename,
104
- htmlFile: bundle.htmlFile,
115
+ bytes: released.bytes,
116
+ filename: released.filename,
117
+ htmlFile: released.htmlFile,
105
118
  });
106
119
  const sha = commit(deckDir);
107
120
  log(`✓ imported "${imported.title}" as ${imported.slug} (${bundle.files.length} file(s), commit ${sha})\n deck: ${imported.deckUrl}`);
package/src/runtime.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { ConvexHttpClient } from "convex/browser";
9
9
  import { makeFunctionReference } from "convex/server";
10
10
 
11
- export const VERSION = "0.3.1";
11
+ export const VERSION = "0.5.0";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  export const PROD = {
@@ -20,9 +20,41 @@ export const PROD = {
20
20
 
21
21
  export const api = {
22
22
  previews: { get: makeFunctionReference("previews:get") },
23
+ avatars: {
24
+ listAvatars: makeFunctionReference("avatars:listAvatars"),
25
+ setDefault: makeFunctionReference("avatars:setDefault"),
26
+ removeAvatar: makeFunctionReference("avatars:removeAvatar"),
27
+ setDeckAvatar: makeFunctionReference("avatars:setDeckAvatar"),
28
+ addPicked: makeFunctionReference("avatars:addPicked"),
29
+ drawSelfie: makeFunctionReference("avatars:drawSelfie"),
30
+ getDraft: makeFunctionReference("avatars:getDraft"),
31
+ getConnect: makeFunctionReference("avatars:getConnect"),
32
+ createLink: makeFunctionReference("avatars:createLink"),
33
+ getLink: makeFunctionReference("avatars:getLink"),
34
+ presenterFor: makeFunctionReference("avatars:presenterFor"),
35
+ },
36
+ narrators: {
37
+ list: makeFunctionReference("narrators:list"),
38
+ setDefault: makeFunctionReference("narrators:setDefault"),
39
+ remove: makeFunctionReference("narrators:remove"),
40
+ setVoice: makeFunctionReference("narrators:setVoice"),
41
+ setDeckNarrator: makeFunctionReference("narrators:setDeckNarrator"),
42
+ newLink: makeFunctionReference("narrators:newLink"),
43
+ rename: makeFunctionReference("narrators:rename"),
44
+ setFace: makeFunctionReference("narrators:setFace"),
45
+ },
46
+ narratorsNode: {
47
+ hear: makeFunctionReference("narratorsNode:hear"),
48
+ },
49
+ avatarsNode: {
50
+ options: makeFunctionReference("avatarsNode:options"),
51
+ startConnect: makeFunctionReference("avatarsNode:startConnect"),
52
+ refresh: makeFunctionReference("avatarsNode:refresh"),
53
+ },
23
54
  assets: {
24
55
  generateUploadUrl: makeFunctionReference("assets:generateUploadUrl"),
25
56
  saveAsset: makeFunctionReference("assets:saveAsset"),
57
+ list: makeFunctionReference("assets:list"),
26
58
  },
27
59
  assetsNode: {
28
60
  importAssetFromUrl: makeFunctionReference("assetsNode:importAssetFromUrl"),
@@ -48,6 +80,7 @@ export const api = {
48
80
  get: makeFunctionReference("decks:get"),
49
81
  listForViewer: makeFunctionReference("decks:listForViewer"),
50
82
  remove: makeFunctionReference("decks:remove"),
83
+ setAllowForward: makeFunctionReference("decks:setAllowForward"),
51
84
  setHtmlStorage: makeFunctionReference("decks:setHtmlStorage"),
52
85
  upsert: makeFunctionReference("decks:upsert"),
53
86
  whoami: makeFunctionReference("decks:whoami"),
@@ -57,6 +90,7 @@ export const api = {
57
90
  deckAnalytics: makeFunctionReference("shares:deckAnalytics"),
58
91
  listForDeck: makeFunctionReference("shares:listForDeck"),
59
92
  remove: makeFunctionReference("shares:remove"),
93
+ setAllowForward: makeFunctionReference("shares:setAllowForward"),
60
94
  setEnabled: makeFunctionReference("shares:setEnabled"),
61
95
  setNotify: makeFunctionReference("shares:setNotify"),
62
96
  },
@@ -102,7 +136,7 @@ export const api = {
102
136
  },
103
137
  };
104
138
 
105
- const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "help", "version", "all-agents", "global", "project", "transparent", "upload", "dry-run", "narrated", "layout", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open"]);
139
+ const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "upload", "dry-run", "narrated", "layout", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji"]);
106
140
  // release's recipients, in order: each --to starts one, and an --email right
107
141
  // after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
108
142
  // opts.email stays the last --email for the other commands (share new).
@@ -0,0 +1,15 @@
1
+ // The avatar picker's species (roadmap: avatars-pick-make-from-a-selfie-or-
2
+ // import-your-minimoji), shared by the backend (convex/lib/avatars/species.ts),
3
+ // the CLI (avatar.mjs) and the web picker. `family` is Minimoji's character
4
+ // family in GET /capabilities; humans have none (we deal their documents).
5
+ export const SPECIES = [
6
+ { id: "human", label: "Human", family: null },
7
+ { id: "robot", label: "Robot", family: "dash" },
8
+ { id: "alien", label: "Alien", family: "alien" },
9
+ { id: "cat", label: "Cat", family: "cat" },
10
+ { id: "dog", label: "Dog", family: "dog" },
11
+ { id: "rabbit", label: "Rabbit", family: "rabbit" },
12
+ { id: "monkey", label: "Monkey", family: "monkey" },
13
+ { id: "penguin", label: "Penguin", family: "bird" },
14
+ { id: "ghost", label: "Ghost", family: "ghost" },
15
+ ];