@hypersocial/decks-cli 0.4.0 → 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.4.0",
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",
@@ -56,6 +56,9 @@ returns a link; hand it to them, word for word, and wait.
56
56
  | Make one the default (the person named it) | `hyperdecks narrator default <name>` | `set_default_narrator` |
57
57
  | Remove one (confirm first) | `hyperdecks narrator remove <name>` | `remove_narrator` |
58
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` |
59
62
  | One deck's own narrator | `hyperdecks deck narrator <slug> <name\|none\|default>` | `set_deck_narrator` |
60
63
 
61
64
  ### Avatars: who presents a deck in the user's voice
@@ -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/commands.mjs CHANGED
@@ -368,6 +368,8 @@ commands:
368
368
  asks you to type "I consent" before anything is uploaded
369
369
  voice upload <file...> upload recordings and print their sample ids (for the MCP clone_voice tool)
370
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
371
373
  avatar [--json] your avatars (who presents decks in your voice) and the default
372
374
  avatar new [--species <s>] pick a species, then an avatar (s shuffles); --json or an agent gets a pick link
373
375
  avatar from-photo [file] [--save-to-minimoji]
@@ -381,6 +383,8 @@ commands:
381
383
  narrator default <name|id|dash> make it the default
382
384
  narrator remove <name|id> remove it; decks already shared keep working
383
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)
384
388
  deck narrator <slug> <name|id|none|default> the deck's own narrator (none: captions only)
385
389
 
386
390
  global:
@@ -766,6 +770,21 @@ function commandTable(pos, rest, opts) {
766
770
  const out = await ops.setNarratorVoice(opts, rest[0], rest.slice(1).join(" "));
767
771
  console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} speaks in ${out.voice} now`);
768
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
+ },
769
788
  "deck narrator": async () => {
770
789
  if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
771
790
  const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
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
 
package/src/ops.mjs CHANGED
@@ -1019,6 +1019,68 @@ export async function setNarratorVoice(opts, ref, voice) {
1019
1019
  return { narrator: n.id, name: n.name, voice: String(voice) };
1020
1020
  }
1021
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
+
1022
1084
  // A deck's own narrator: a narrator, `none` (captions only) or `default`.
1023
1085
  export async function setDeckNarrator(opts, slug, ref) {
1024
1086
  if (!slug || !ref) throw new Error("usage: deck narrator <slug> <narrator|none|default>");
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.4.0";
11
+ export const VERSION = "0.5.0";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  export const PROD = {
@@ -40,6 +40,11 @@ export const api = {
40
40
  setVoice: makeFunctionReference("narrators:setVoice"),
41
41
  setDeckNarrator: makeFunctionReference("narrators:setDeckNarrator"),
42
42
  newLink: makeFunctionReference("narrators:newLink"),
43
+ rename: makeFunctionReference("narrators:rename"),
44
+ setFace: makeFunctionReference("narrators:setFace"),
45
+ },
46
+ narratorsNode: {
47
+ hear: makeFunctionReference("narratorsNode:hear"),
43
48
  },
44
49
  avatarsNode: {
45
50
  options: makeFunctionReference("avatarsNode:options"),
@@ -49,6 +54,7 @@ export const api = {
49
54
  assets: {
50
55
  generateUploadUrl: makeFunctionReference("assets:generateUploadUrl"),
51
56
  saveAsset: makeFunctionReference("assets:saveAsset"),
57
+ list: makeFunctionReference("assets:list"),
52
58
  },
53
59
  assetsNode: {
54
60
  importAssetFromUrl: makeFunctionReference("assetsNode:importAssetFromUrl"),