symbiot 0.58.0 → 0.58.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
4
4
 
5
+ ## 0.58.2
6
+
7
+ - **A voice that doesn't sound robotic, picked for you.** When Symbiot reads a reply out, it uses your computer's own voice only when that's a modern, natural one (Edge's Natural voices on Windows, Apple's Premium and Enhanced voices, a phone's own). Otherwise, as on Linux, where Chrome only has its older Google voices and espeak, Symbiot speaks with its own voice: Piper, a small open-source speech engine that runs on your computer. The accent follows where you are (British English for South Africa, the UK, Australia and the rest; American for the US and Canada), and both voices are public domain. Nothing to choose, no account, nothing leaves your computer. It downloads once (about 85 MB) the first time it's needed, while the system voice covers that first reply, and then answers sentence by sentence, the first in under a second.
8
+
9
+ ## 0.58.1
10
+
11
+ - **No more "couldn't reach the model" after an update.** An update restarted Symbiot in whatever folder had started it, and when that folder was later deleted, Claude Code refused to run there: Symbiot thought you'd signed out of Claude and fell back to an old API key that no longer worked. Symbiot now always runs, and asks Claude Code, from your home folder.
12
+ - **Setup installs Claude Code and signs you in.** If Claude Code isn't on your computer, setup's "Your AI" step has an **Install Claude Code** button (Anthropic's own installer, for Windows, Mac or Linux) and then **Sign in with Claude**, which opens the sign-in page and ticks itself once you've signed in. No terminal at any point. If the page shows a code instead, setup has a box to paste it in. Symbiot also finds Claude Code in `~/.local/bin`, where that installer puts it.
13
+ - **Ready for the desktop app.** Running inside Symbiot's installed app, Symbiot doesn't offer npm updates (the app updates itself) or make shortcuts of its own (the installer made them), and "Start at login" starts the app.
14
+
5
15
  ## 0.58.0
6
16
 
7
17
  - **Talk to Symbiot instead of typing.** A mic on the talk bar: click it and speak. Your words appear as you say them, and a pause sends them, as Enter would. When you spoke, Symbiot reads its answer out too, in the most natural voice your computer has (typed questions stay quiet), and talking again cuts it off. It uses the speech recognition built into Chrome and Edge: no key, nothing to pay. If the microphone is blocked or missing, it says what to do.
package/ai.mjs CHANGED
@@ -26,6 +26,10 @@ const PROVIDERS = {
26
26
  // agents. Whether it's there and signed in: `claude auth status` (a quarter of a
27
27
  // second), kept 5 minutes. SYMBIOT_CLAUDE_CMD replaces the command (the tests' stand-in).
28
28
  const CLAUDE_BIN = () => process.env.SYMBIOT_CLAUDE_CMD || "claude";
29
+ // Claude Code's own installer puts it in ~/.local/bin, which a desktop session (and
30
+ // Windows) often doesn't have on PATH yet: Symbiot, and the agents it starts, look there too.
31
+ { const dir = join(homedir(), ".local", "bin"), sep = process.platform === "win32" ? ";" : ":";
32
+ if (existsSync(dir) && !String(process.env.PATH || "").split(sep).includes(dir)) process.env.PATH = dir + sep + (process.env.PATH || ""); }
29
33
  // How to run it. Windows: npm installs `claude` as claude.cmd, which Node can only
30
34
  // start through cmd.exe (a .exe, from Claude's own installer, it starts directly), so
31
35
  // it's found with `where` and its arguments are quoted for cmd.exe.
@@ -52,7 +56,8 @@ function claudeState(fresh = false) {
52
56
  let installed = !!process.env.SYMBIOT_CLAUDE_CMD || hasCmd("claude"), signedIn = false;
53
57
  if (installed) {
54
58
  try {
55
- const r = claudeSpawn(["auth", "status"], { encoding: "utf8", timeout: 8000, env: process.env }, true);
59
+ // from home: Claude Code refuses to run in a folder that has since been deleted
60
+ const r = claudeSpawn(["auth", "status"], { cwd: homedir(), encoding: "utf8", timeout: 8000, env: process.env }, true);
56
61
  if (r.error && r.error.code === "ENOENT") installed = false;
57
62
  else { const j = JSON.parse(String(r.stdout || "").trim() || "{}"); signedIn = !!j.loggedIn && j.authMethod !== "apiKey"; }
58
63
  } catch {}
@@ -308,4 +313,4 @@ async function connectProvider(b) {
308
313
  return saveConfig(cfg) ? { ok: true, message: `Connected: ${PROVIDERS[provider].label} · ${model}` } : { ok: false, message: "Couldn't write the config file." };
309
314
  }
310
315
 
311
- export { PROVIDERS, AI_UI, resolveProvider, write, validate, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel , claudeState, claudeReady, claudeHelp , claudeCommand };
316
+ export { PROVIDERS, AI_UI, resolveProvider, write, validate, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel , claudeState, claudeReady, claudeHelp , claudeCommand , claudeSpawn };
@@ -0,0 +1,56 @@
1
+ // symbiot — Claude Code, set up from Symbiot's own setup, for someone who has never
2
+ // opened a terminal: Anthropic's installer (Windows: install.ps1 in PowerShell; Mac
3
+ // and Linux: install.sh), then `claude auth login --claudeai`, which opens the
4
+ // sign-in page in the browser and finishes by itself once they've signed in. If the
5
+ // page shows a code instead (the browser couldn't hand it back), setup has a box for
6
+ // it, which goes to the sign-in that's waiting. SYMBIOT_CLAUDE_INSTALL_CMD replaces
7
+ // the installer (tests).
8
+ import { spawn } from "node:child_process";
9
+ import { homedir } from "node:os";
10
+ import { claudeState, claudeSpawn } from "./ai.mjs";
11
+
12
+ const S = { installing: false, installError: "", signingIn: false, signinError: "", log: "", child: null };
13
+ const tail = (s) => String(s || "").replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").slice(-1500);
14
+
15
+ function installCmd(platform = process.platform) {
16
+ if (process.env.SYMBIOT_CLAUDE_INSTALL_CMD) return { cmd: process.env.SYMBIOT_CLAUDE_INSTALL_CMD, args: [], shell: true };
17
+ if (platform === "win32") return { cmd: "powershell", args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "irm https://claude.ai/install.ps1 | iex"], shell: false };
18
+ return { cmd: "bash", args: ["-c", "curl -fsSL https://claude.ai/install.sh | bash"], shell: false };
19
+ }
20
+ function installClaude() {
21
+ if (S.installing) return claudeSetup();
22
+ S.installing = true; S.installError = ""; S.log = "";
23
+ const c = installCmd();
24
+ let ch; try { ch = spawn(c.cmd, c.args, { cwd: homedir(), shell: c.shell, windowsHide: true, env: process.env }); } catch (e) { S.installing = false; S.installError = String((e && e.message) || e); return claudeSetup(); }
25
+ ch.stdout.on("data", (d) => { S.log = tail(S.log + d); }); ch.stderr.on("data", (d) => { S.log = tail(S.log + d); });
26
+ ch.on("error", (e) => { S.installing = false; S.installError = String((e && e.message) || e); });
27
+ ch.on("close", (code) => {
28
+ S.installing = false;
29
+ const st = claudeState(true);
30
+ if (code !== 0 || !st.installed) S.installError = `The installer didn't finish (${code === 0 ? "Claude Code wasn't found after it" : "exit " + code}). ${S.log.trim().split("\n").pop() || ""}`.trim();
31
+ });
32
+ return claudeSetup();
33
+ }
34
+ function signInClaude() {
35
+ if (S.signingIn) return claudeSetup();
36
+ S.signingIn = true; S.signinError = "";
37
+ let ch; try { ch = claudeSpawn(["auth", "login", "--claudeai"], { cwd: homedir(), stdio: ["pipe", "pipe", "pipe"], env: process.env }); } catch (e) { S.signingIn = false; S.signinError = String((e && e.message) || e); return claudeSetup(); }
38
+ S.child = ch;
39
+ let out = ""; ch.stdout.on("data", (d) => { out = tail(out + d); }); ch.stderr.on("data", (d) => { out = tail(out + d); });
40
+ ch.on("error", (e) => { S.signingIn = false; S.child = null; S.signinError = String((e && e.message) || e); });
41
+ ch.on("close", () => { S.signingIn = false; S.child = null; if (!claudeState(true).signedIn) S.signinError = "Signing in didn't finish. Try again, and sign in on the page that opens."; });
42
+ return claudeSetup();
43
+ }
44
+ // the code the sign-in page shows, when the browser couldn't hand it back
45
+ function sendClaudeCode(code) {
46
+ const c = String(code || "").trim();
47
+ if (!c || !S.child || !S.child.stdin) return { error: "There's no sign-in waiting for a code. Click Sign in with Claude first." };
48
+ try { S.child.stdin.write(c + "\n"); } catch (e) { return { error: String((e && e.message) || e) }; }
49
+ return claudeSetup();
50
+ }
51
+ function claudeSetup(fresh = false) {
52
+ const st = claudeState(fresh);
53
+ return { installed: st.installed, signedIn: st.signedIn, installing: S.installing, signingIn: S.signingIn, ...(S.installError ? { installError: S.installError } : {}), ...(S.signinError ? { signinError: S.signinError } : {}) };
54
+ }
55
+
56
+ export { installCmd, installClaude, signInClaude, sendClaudeCode, claudeSetup };
package/desktop.mjs CHANGED
@@ -119,7 +119,13 @@ function autostartFile(platform = OS, home = homedir()) {
119
119
  if (platform === "android") return join(home, ".termux", "boot", "symbiot");
120
120
  return join(home, ".config", "autostart", "symbiot.desktop");
121
121
  }
122
- function autostartContent(node, script, platform = OS, path = process.env.PATH || "") {
122
+ function autostartContent(node, script, platform = OS, path = process.env.PATH || "", exe = process.env.SYMBIOT_DESKTOP_EXE || "") {
123
+ if (exe) { // the installed app: it starts its tray and Symbiot itself
124
+ const q = (s) => '"' + String(s).replace(/(["`$\\])/g, "\\$1") + '"';
125
+ if (platform === "win32") return ["@echo off", `start "" "${exe}"`, ""].join("\r\n");
126
+ if (platform === "darwin") return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>co.symbiot.app</string><key>ProgramArguments</key><array><string>${String(exe).replace(/&/g, "&amp;").replace(/</g, "&lt;")}</string></array><key>RunAtLoad</key><true/></dict></plist>\n`;
127
+ return ["[Desktop Entry]", "Type=Application", "Name=Symbiot", `Exec=${q(exe)}`, "Terminal=false", "NoDisplay=true", "X-GNOME-Autostart-enabled=true", ""].join("\n");
128
+ }
123
129
  if (platform === "android-app") return "While this file exists, Symbiot's Android app starts itself when the phone starts.\n";
124
130
  if (platform === "darwin") {
125
131
  const x = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
package/home.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  // The app's sections (Map, Tasks, Agents, Week…) are shaped by adapt.mjs from
10
10
  // how you use them; this is the live data around them. Home's talk goes to the
11
11
  // same Symbiot as every chat (mind.mjs converse), told what home shows.
12
+ import { claudeSetup } from "./claudesetup.mjs";
12
13
  import { join } from "node:path";
13
14
  import { homedir } from "node:os";
14
15
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
@@ -734,7 +735,7 @@ function onboarding({ fresh = false } = {}) {
734
735
  const det = tryOr(() => detectHandoffs(), { agents: [] }), cmd = tryOr(() => handoffCmd(), "");
735
736
  return {
736
737
  pending: !!o.pending, step: ONB_STEPS.includes(o.step) ? o.step : "welcome", steps: ONB_STEPS,
737
- ai: { connected: !!r, provider: r ? r.provider : "", line: r ? `${PROVIDERS[r.provider].label}${r.model ? " · " + r.model : ""}` : "", claude: st ? { installed: st.installed, signedIn: st.signedIn } : null },
738
+ ai: { connected: !!r, provider: r ? r.provider : "", line: r ? `${PROVIDERS[r.provider].label}${r.model ? " · " + r.model : ""}` : "", claude: tryOr(() => claudeSetup(), st ? { installed: st.installed, signedIn: st.signedIn } : null) },
738
739
  work: { searching: reps.searching, at: reps.at || 0, done: reps.done || 0, total: reps.total || 0, count: reps.list.length, repos: reps.list.slice(0, 60).map((x) => ({ name: x.name, path: x.path })), roots: tryOr(() => scanRoots(), []).map((r) => (r === homedir() ? "~" : r.startsWith(homedir() + "/") ? "~" + r.slice(homedir().length) : r)) },
739
740
  agent: { cmd, pick: tryOr(() => pickAgent(), null), agents: (det.agents || []).map((a) => ({ label: a.label, tmpl: a.tmpl })) },
740
741
  apps, groups: links.groups || [], decided: apps.every((a) => a.skipped || a.state !== "off"),
package/index.mjs CHANGED
@@ -870,7 +870,9 @@ async function main() {
870
870
  if (cmd === "login" || cmd === "auth") return cmdLogin();
871
871
  if (cmd === "logout") return cmdLogout();
872
872
  if (cmd === "whoami" || cmd === "status") return cmdWhoami();
873
- if (cmd === "app" || cmd === "ui") return cmdApp();
873
+ // the app runs from home: whatever folder started it may be deleted later (a worktree,
874
+ // an old copy npm replaced), and Claude Code won't run from a deleted folder
875
+ if (cmd === "app" || cmd === "ui") { try { process.chdir(homedir()); } catch {} return cmdApp(); }
874
876
  if (cmd === "away") return cmdAway();
875
877
  if (cmd === "open") return cmdOpen();
876
878
  if (cmd === "uninstall") return cmdUninstall();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.58.0",
3
+ "version": "0.58.2",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,7 +49,9 @@
49
49
  "icon.svg",
50
50
  "scanworker.mjs",
51
51
  "estimate.mjs",
52
- "icon.ico"
52
+ "icon.ico",
53
+ "claudesetup.mjs",
54
+ "voice.mjs"
53
55
  ],
54
56
  "engines": {
55
57
  "node": ">=18"
@@ -82,7 +84,7 @@
82
84
  "@anthropic-ai/sdk": "^0.128.0"
83
85
  },
84
86
  "scripts": {
85
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/subscription.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
87
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
86
88
  "lint": "eslint .",
87
89
  "postinstall": "node postinstall.mjs"
88
90
  },
package/server.mjs CHANGED
@@ -2,6 +2,8 @@
2
2
  // (ui.mjs) and its /api routes, each a thin call into the module that does the
3
3
  // work, on 127.0.0.1 only and behind the per-install token. (Watch on your phone
4
4
  // listens on your network separately, only for that: phone.mjs.)
5
+ import { claudeSetup, installClaude, signInClaude, sendClaudeCode } from "./claudesetup.mjs";
6
+ import { voiceState, prepareVoice, speak, stopVoices } from "./voice.mjs";
5
7
  import { spawn, spawnSync } from "node:child_process";
6
8
  import { toggleAway, closeAway } from "./away.mjs";
7
9
  import { homedir } from "node:os";
@@ -146,7 +148,9 @@ function readBytes(req, max) {
146
148
  function appLink(url) { return url.replace(/^http:\/\//, "symbiot://"); }
147
149
  // Symbiot running in Termux (not the Android app's own)
148
150
  const IN_TERMUX = process.platform === "android" && process.env.SYMBIOT_ANDROID_APP !== "1";
149
- const SANDBOX = !!process.env.SYMBIOT_SANDBOX; // symbiot app --fresh (sandbox.mjs)
151
+ const SANDBOX = !!process.env.SYMBIOT_SANDBOX;
152
+ // the installed app (symbiot-desktop): it updates itself and made its own shortcuts
153
+ const DESKTOP = process.env.SYMBIOT_DESKTOP === "1"; // symbiot app --fresh (sandbox.mjs)
150
154
  const FIRST_RUN = !existsSync(CONFIG_PATH); // no config yet when Symbiot started: a brand-new install
151
155
  function openApp(url) {
152
156
  try {
@@ -228,7 +232,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
228
232
  const screenOut =(s) => (s && s.id ? { ...s, blueprint: blueprint(s), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}) } : s && s.screens ? { ...s, screens: s.screens.map(screenOut) } : s);
229
233
  // an installed Symbiot keeps its app-menu entry pointing at itself (a new Node, a moved
230
234
  // install); not a copy run from a checkout or a test
231
- if (/[\\/]node_modules[\\/]symbiot[\\/]/.test(bin || "") && !SANDBOX) { try { installLauncher({ script: bin }); } catch {} }
235
+ if (/[\\/]node_modules[\\/]symbiot[\\/]/.test(bin || "") && !SANDBOX && !process.env.SYMBIOT_NO_LAUNCHER) { try { installLauncher({ script: bin }); } catch {} }
232
236
  if (FRESH) { try { const cf = loadConfig(); if (!cf.onboarding) { cf.onboarding = { pending: true, step: "welcome", skipped: [] }; saveConfig(cf); } } catch {} }
233
237
  // your projects: kept, and found again off the main thread, so nothing waits on a search
234
238
  setScanOptions({ cache: true }); REPO_STATE.onChange = () => { try { homeState({ fresh: true }); } catch {} };
@@ -427,6 +431,14 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
427
431
  if (u.pathname === "/api/home/answer" && req.method === "POST") { const b = await readBody(req); return json(res, homeAnswer(b.id, { pick: b.pick, text: b.text })); }
428
432
  if (u.pathname === "/api/away" && req.method === "POST") { const b = await readBody(req); return json(res, toggleAway(`http://127.0.0.1:${server.address().port}/?t=${TOKEN}`, b.open)); }
429
433
  if (u.pathname === "/api/home/next" && req.method === "POST") { const b = await readBody(req); return json(res, homeNext(b.id)); }
434
+ // Symbiot's own voice (voice.mjs), for computers without a natural one: the page asks
435
+ if (u.pathname === "/api/voice") return json(res, voiceState(u.searchParams.get("lang") || ""));
436
+ if (u.pathname === "/api/voice/prepare" && req.method === "POST") { const b = await readBody(req); return json(res, prepareVoice(b.lang || "")); }
437
+ if (u.pathname === "/api/voice/say" && req.method === "POST") {
438
+ const b = await readBody(req);
439
+ try { const wav = await speak(b.text, b.lang || ""); res.writeHead(200, { "content-type": "audio/wav", "cache-control": "no-store", "content-length": wav.length }); return res.end(wav); }
440
+ catch (e) { return json(res, { error: String((e && e.message) || e) }); }
441
+ }
430
442
  if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question, { images: saveShots(b.images) })); }
431
443
  if (u.pathname === "/api/adapt") return json(res, adaptState({ from: String(u.searchParams.get("from") || ""), commit: u.searchParams.get("commit") === "1", ...(u.searchParams.has("touch") ? { touch: u.searchParams.get("touch") === "1" } : {}) }));
432
444
  if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
@@ -473,7 +485,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
473
485
  // a sandbox (symbiot app --fresh) never offers an update: it would replace your real install.
474
486
  // A headless browser (an agent's screenshot of Home) never becomes the newest window:
475
487
  // that closed the user's real one, again and again (2026-10-08, "symbiot keeps crashing").
476
- if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); const w = u.searchParams.get("w") || ""; if (/^[a-z0-9]{6,20}$/.test(w) && u.searchParams.get("new") === "1" && !headlessAgent(req.headers["user-agent"])) NEWEST_WIN = w; return json(res, { ...(NEWEST_WIN ? { window: NEWEST_WIN } : {}), version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && semverGt(LATEST_VERSION, VERSION), ...(UPDATING && UPDATING.retrying ? { retrying: UPDATING } : {}), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
488
+ if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); const w = u.searchParams.get("w") || ""; if (/^[a-z0-9]{6,20}$/.test(w) && u.searchParams.get("new") === "1" && !headlessAgent(req.headers["user-agent"])) NEWEST_WIN = w; return json(res, { ...(NEWEST_WIN ? { window: NEWEST_WIN } : {}), version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && !DESKTOP && semverGt(LATEST_VERSION, VERSION), ...(DESKTOP ? { desktop: true } : {}), ...(UPDATING && UPDATING.retrying ? { retrying: UPDATING } : {}), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
477
489
  // What's new: after an update, since the version you last saw (until you click Got it);
478
490
  // ?latest=1, what the update on offer brings, from its package on npm
479
491
  if (u.pathname === "/api/whatsnew") {
@@ -485,6 +497,11 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
485
497
  return json(res, { version: to, changes: changesSince(md, VERSION, to), ...(md ? {} : { error: `Couldn't read ${to}'s changelog from npm.` }) });
486
498
  }
487
499
  if (u.pathname === "/api/whatsnew/seen" && req.method === "POST") { const cfg = loadConfig(); cfg.seenVersion = VERSION; return json(res, saveConfig(cfg) ? { ok: true } : { error: "Couldn't write the config file." }); }
500
+ if (u.pathname === "/api/claude/setup") return json(res, claudeSetup(u.searchParams.get("fresh") === "1"));
501
+ if (u.pathname === "/api/claude/install" && req.method === "POST") return json(res, installClaude());
502
+ if (u.pathname === "/api/claude/signin" && req.method === "POST") return json(res, signInClaude());
503
+ if (u.pathname === "/api/claude/code" && req.method === "POST") { const b = await readBody(req); return json(res, sendClaudeCode(b.code)); }
504
+ if (u.pathname === "/api/update" && req.method === "POST" && DESKTOP) return json(res, { error: "This Symbiot updates with its app." });
488
505
  if (u.pathname === "/api/update" && req.method === "POST") {
489
506
  // Install the exact newest version (see updateCmd), then relaunch this
490
507
  // same app (same port+token => same URL) and exit. The page's heartbeat
@@ -505,14 +522,14 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
505
522
  return;
506
523
  }
507
524
  server.close(); if (server.closeAllConnections) server.closeAllConnections();
508
- try { const ch = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "ignore", env: { ...process.env, SYMBIOT_RELAUNCH: "1" } }); ch.unref(); } catch {}
525
+ try { const ch = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "ignore", env: { ...process.env, SYMBIOT_RELAUNCH: "1" }, cwd: homedir() }); ch.unref(); } catch {}
509
526
  setTimeout(() => process.exit(0), 1200);
510
527
  });
511
528
  UPDATING = { target, attempt: 1, at: Date.now() };
512
529
  const e = attempt(1);
513
530
  return json(res, { started: true, id: e ? e.id : "", target });
514
531
  }
515
- if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
532
+ if (u.pathname === "/api/quit") { stopVoices(); res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
516
533
  } catch (e) { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ error: String((e && e.message) || e) })); return; }
517
534
  res.writeHead(404); res.end("not found");
518
535
  });
package/ui.mjs CHANGED
@@ -3026,7 +3026,7 @@ function voiceSay(t){LQ.talkMin=false;lqTalkMode(true);LQ.talk.push({me:false,te
3026
3026
  var VOICE_WHY={'not-allowed':'Symbiot couldn’t use your microphone. Allow it for this window (the icon at the top of the window, or your system’s privacy settings), then click the mic again.','service-not-allowed':'Symbiot couldn’t use your microphone. Allow it for this window, then click the mic again.','audio-capture':'No microphone was found. Plug one in, or check it’s switched on, then try again.','network':'Talking needs an internet connection: your browser turns speech into text online.'};
3027
3027
  function voiceToggle(){
3028
3028
  if(VOICE.on){try{VOICE.rec.stop();}catch(e){}return;}
3029
- try{if(window.speechSynthesis)window.speechSynthesis.cancel();}catch(e){}
3029
+ voiceStop();
3030
3030
  var R=window.SpeechRecognition||window.webkitSpeechRecognition;if(!R)return;
3031
3031
  var r;try{r=new R();}catch(e){return;}
3032
3032
  var ask=$('lqask');VOICE.rec=r;VOICE.final='';VOICE.err='';VOICE.base=((ask&&ask.value)||'').trim();
@@ -3039,13 +3039,41 @@ r.onend=function(){VOICE.on=false;voiceMic(false);VOICE.rec=null;var said=((ask&
3039
3039
  if(why){voiceSay(why);return;}
3040
3040
  if(VOICE.final.trim()&&said){VOICE.spoke=true;lqSay();}};
3041
3041
  try{r.start();}catch(e){VOICE.on=false;voiceMic(false);}}
3042
- // read out: plain words (no markdown marks), up to a few sentences; a voice in your language, a natural one if there is
3043
- function voiceSpeak(t){if(!window.speechSynthesis||!t)return;
3044
- var clean=String(t).replace(/[*_#>|]+/g,' ').replace(new RegExp(String.fromCharCode(96),'g'),'').replace(/https?:[^ ]+/g,'a link').replace(/ +/g,' ').trim();
3042
+ // Speaking, with no voice to choose: the system's own when it has a natural one
3043
+ // (Edge on Windows, Macs with Apple's better voices, phones), in your region's accent if it has that too;
3044
+ // otherwise Symbiot's own (voice.mjs: Piper on this computer), fetched once in the
3045
+ // background the first time, with the system's voice meanwhile. Plain words only.
3046
+ var SPEAK={audio:null,n:0,own:null,asked:false};
3047
+ function voiceLang(){return navigator.language||'en-US';}
3048
+ function voiceTag(v){return String((v&&v.lang)||'').toLowerCase().split('_').join('-');}
3049
+ function voiceNatural(){if(!window.speechSynthesis)return null;var tag=voiceLang().toLowerCase().split('_').join('-'),lang=tag.slice(0,2);
3050
+ var vs=window.speechSynthesis.getVoices().filter(function(v){return voiceTag(v).indexOf(lang)===0;});
3051
+ // modern voices only: Edge's Natural ones, Apple's Premium and Enhanced. Chrome's Google voices and espeak sound robotic.
3052
+ var good=vs.filter(function(v){return /natural|neural|enhanced|premium|siri/i.test(v.name||'')&&!/espeak|google/i.test(v.name||'');});
3053
+ if(!good.length&&/android|iphone|ipad/i.test(navigator.userAgent||''))good=vs; // a phone's own voices are good ones
3054
+ good.sort(function(a,b){return (voiceTag(b)===tag)-(voiceTag(a)===tag);});
3055
+ return {best:good[0]||null,any:good[0]||vs[0]||null};}
3056
+ function voiceStop(){SPEAK.n++;try{if(window.speechSynthesis)window.speechSynthesis.cancel();}catch(e){}if(SPEAK.audio){try{SPEAK.audio.pause();}catch(e){}SPEAK.audio=null;}}
3057
+ function voiceClean(t){var clean=String(t||'').replace(/[*_#>|]+/g,' ').replace(new RegExp(String.fromCharCode(96),'g'),'').replace(/https?:[^ ]+/g,'a link').replace(/ +/g,' ').trim();
3045
3058
  if(clean.length>700){var cut=clean.slice(0,700),at=Math.max(cut.lastIndexOf('. '),cut.lastIndexOf('? '),cut.lastIndexOf('! '));clean=(at>200?cut.slice(0,at+1):cut)+' The rest is on screen.';}
3046
- try{var u=new SpeechSynthesisUtterance(clean),lang=(navigator.language||'en').slice(0,2).toLowerCase(),vs=window.speechSynthesis.getVoices().filter(function(v){return (v.lang||'').toLowerCase().indexOf(lang)===0;});
3047
- var best=vs.filter(function(v){return /natural|neural|google|online/i.test(v.name);})[0]||vs[0];if(best)u.voice=best;u.lang=navigator.language||'en-US';u.rate=1.02;
3048
- window.speechSynthesis.cancel();window.speechSynthesis.speak(u);}catch(e){}}
3059
+ return clean;}
3060
+ function voiceSystem(text,v){if(!window.speechSynthesis||!text)return;try{var u=new SpeechSynthesisUtterance(text);if(v)u.voice=v;u.lang=voiceLang();u.rate=1.02;window.speechSynthesis.cancel();window.speechSynthesis.speak(u);}catch(e){}}
3061
+ // a sentence at a time, so the first is heard while the next is made
3062
+ function voiceSentences(t){var out=[],cur='';t.split(' ').forEach(function(w){cur+=(cur?' ':'')+w;var e=w.charAt(w.length-1);if((e==='.'||e==='?'||e==='!')&&cur.length>24){out.push(cur);cur='';}});if(cur)out.push(cur);return out;}
3063
+ function voiceOwn(text,lang){var my=SPEAK.n,parts=voiceSentences(text),got=[];
3064
+ var make=function(i){if(i>=parts.length||got[i])return;got[i]=fetch('/api/voice/say',{method:'POST',headers:{'x-symbiot-token':T,'content-type':'application/json'},body:JSON.stringify({text:parts[i],lang:lang})}).then(function(r){if((r.headers.get('content-type')||'').indexOf('audio')<0)throw new Error('no audio');return r.blob();});};
3065
+ var play=function(i){if(my!==SPEAK.n||i>=parts.length)return;make(i);make(i+1);
3066
+ got[i].then(function(b){if(my!==SPEAK.n)return;var a=new Audio(URL.createObjectURL(b));SPEAK.audio=a;a.onended=function(){try{URL.revokeObjectURL(a.src);}catch(e){}play(i+1);};var pl=a.play();if(pl&&pl.catch)pl.catch(function(){});})
3067
+ .catch(function(){if(my!==SPEAK.n)return;SPEAK.own=null;var nat=voiceNatural();voiceSystem(parts.slice(i).join(' '),nat&&nat.any);});};
3068
+ play(0);}
3069
+ function voiceSpeak(t){if(!t)return;voiceStop();var clean=voiceClean(t);if(!clean)return;var nat=voiceNatural();
3070
+ if(nat&&nat.best){voiceSystem(clean,nat.best);return;}
3071
+ var lang=voiceLang(),go=function(st){if(st&&st.state==='ready'){voiceOwn(clean,lang);return;}
3072
+ if(st&&(st.state==='missing'||st.state==='failed')&&!SPEAK.asked){SPEAK.asked=true;api('/api/voice/prepare',{lang:lang}).catch(function(){});}
3073
+ voiceSystem(clean,nat&&nat.any);};
3074
+ if(SPEAK.own&&SPEAK.own.state==='ready')go(SPEAK.own);else api('/api/voice?lang='+encodeURIComponent(lang)).then(function(st){SPEAK.own=st;go(st);}).catch(function(){go(null);});}
3075
+ // Chrome lists its voices only after a first ask
3076
+ try{if(window.speechSynthesis)window.speechSynthesis.getVoices();}catch(e){}
3049
3077
  function lqSay(){var spoke=VOICE.spoke;VOICE.spoke=false;var i=$('lqask');var q=((i&&i.value)||'').trim(),shots=LQ.atts.slice();if(!q&&!shots.length)return;if(!q)q='Take a look at this screenshot.';i.value='';lqGrowAsk();LQ.atts=[];lqAttsShow();lqAct();
3050
3078
  var low=q.toLowerCase().replace(/^(please |can you |could you )/,''),hit='';
3051
3079
  Object.keys(LQNAMES).forEach(function(k){var nm=LQNAMES[k].toLowerCase();if(hit)return;['open ','show ','go to ','take me to '].forEach(function(v){if(low.indexOf(v+nm)===0||low.indexOf(v+'my '+nm)===0)hit=k;});if(low===nm)hit=k;});
@@ -3231,7 +3259,12 @@ h="<img class='onborb' src='/favicon.svg?look="+encodeURIComponent(lqLookGet()||
3231
3259
  else if(o.step==='ai'){var a=o.ai||{},c=a.claude||{};
3232
3260
  h="<h2 id='onbt'>Your AI</h2><p class='onbl'>Symbiot thinks with an AI: it reads what comes in, talks with you and writes for you.</p>";
3233
3261
  if(a.connected)h+="<div class='onbok'><i>✓</i><div>Symbiot is using <b>"+esc(a.line)+"</b>"+(a.provider==='claude'?". It’s the Claude account you’re signed in with in Claude Code: no key, nothing extra to pay.":".")+"</div></div>";
3234
- else h+="<div class='onbbox'><h3>Your Claude subscription (recommended)</h3><p>"+(c.installed?"Claude Code is on this computer but not signed in. Open a terminal, run <code>claude</code> once and sign in with your Claude account.":"Install Claude Code (<code>npm install -g @anthropic-ai/claude-code</code>), run <code>claude</code> once and sign in with your Claude account.")+" No key, and nothing extra to pay.</p><button class='ghost' id='onbrecheck'>I’ve signed in: check again</button></div>"+
3262
+ else h+="<div class='onbbox'><h3>Your Claude subscription (recommended)</h3><p>Symbiot thinks through Claude Code, Anthropic’s own app, with your Claude account: no key, and nothing extra to pay.</p>"+
3263
+ (c.installing?"<p><span class='onbspin'></span>Installing Claude Code… this takes about a minute.</p>":
3264
+ !c.installed?"<button class='act' id='onbccinst'>Install Claude Code</button>"+(c.installError?"<p class='onbnote' style='margin-top:10px'>"+esc(c.installError)+"</p>":""):
3265
+ c.signingIn?"<p><span class='onbspin'></span>Sign in on the page that opened in your browser. This ticks itself when you’re done.</p><div class='onbrow'><input id='onbcccode' placeholder='If the page shows a code, paste it here'><button class='ghost' id='onbccsend'>Send</button></div>":
3266
+ "<p>Claude Code is installed. Now sign in with your Claude account.</p><button class='act' id='onbccsign'>Sign in with Claude</button>"+(c.signinError?"<p class='onbnote' style='margin-top:10px'>"+esc(c.signinError)+"</p>":""))+
3267
+ "<p class='onbnote' style='margin:12px 0 0'>Already done it yourself? <button class='back' id='onbrecheck' style='padding:0'>Check again</button></p></div>"+
3235
3268
  "<div class='onbbox'><h3>Or an API key, or a free local model</h3><div class='onbrow'><select id='onbprov'><option value='anthropic'>Claude (Anthropic) API key</option><option value='openai'>OpenAI API key</option><option value='gemini'>Gemini API key</option><option value='ollama'>Local model (Ollama), no key</option></select><input id='onbkey' type='password' placeholder='paste your key'><button class='ghost' id='onbconnect'>Connect</button></div><p id='onbaimsg' style='margin:8px 0 0'></p></div>";
3236
3269
  h+=onbNav('welcome','work','Continue',!!a.connected);}
3237
3270
  else if(o.step==='work'){var w=o.work||{};
@@ -3269,6 +3302,9 @@ b.innerHTML=h;
3269
3302
  b.querySelectorAll('[data-go]').forEach(function(x){x.addEventListener('click',function(){onbGo(x.getAttribute('data-go'));});});
3270
3303
  var on=function(id,f){var x=$(id);if(x)x.addEventListener('click',f);};
3271
3304
  on('onbrecheck',function(){onbLoad(true);});
3305
+ on('onbccinst',function(){api('/api/claude/install',{}).then(function(){onbLoad(true);});});
3306
+ on('onbccsign',function(){api('/api/claude/signin',{}).then(function(){onbLoad(true);});});
3307
+ on('onbccsend',function(){var v=$('onbcccode').value.trim();if(!v)return;api('/api/claude/code',{code:v}).then(function(){onbLoad(true);});});
3272
3308
  on('onbconnect',function(){var pv=$('onbprov').value,m=$('onbaimsg');m.textContent='Checking…';api('/api/connect',{provider:pv,key:$('onbkey').value.trim(),model:''}).then(function(r){m.textContent=r.message||'';onbLoad(true);});});
3273
3309
  var pvs=$('onbprov');if(pvs)pvs.addEventListener('change',function(){$('onbkey').style.display=pvs.value==='ollama'?'none':'';});
3274
3310
  on('onbaddroot',function(){var v=$('onbroot').value.trim();if(!v)return;api('/api/scanroots/add',{path:v}).then(function(){onbLoad();});});
package/voice.mjs ADDED
@@ -0,0 +1,113 @@
1
+ // symbiot — Symbiot's own voice, for computers whose built-in voices sound robotic.
2
+ // The page picks the voice itself: a natural voice the system already has (Windows
3
+ // and Edge, Macs, phones) is used as it is. Where there's none (Linux, mostly: only
4
+ // espeak), Symbiot speaks with Piper, a small open-source speech engine that runs on
5
+ // the computer, with a public-domain voice picked from where you are: British English
6
+ // (Cori) for the UK, South Africa, Australia and the rest, American (LJSpeech) for the
7
+ // US, Canada and the Philippines. Nothing to choose, no account, nothing leaves the
8
+ // computer. Downloaded once, the first time it's needed (~25 MB engine, ~60 MB voice),
9
+ // into ~/.config/symbiot/voice/. Kept warm while you talk, closed after 5 quiet minutes.
10
+ // SYMBIOT_VOICE_DIR / SYMBIOT_PIPER_URL / SYMBIOT_VOICES_URL point elsewhere (tests).
11
+ import { spawn, spawnSync } from "node:child_process";
12
+ import { existsSync, mkdirSync, createWriteStream, readFileSync, rmSync, renameSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { CONFIG_DIR } from "./core.mjs";
15
+
16
+ const PIPER_REL = "2023.11.14-2";
17
+ const PIPER_ASSET = { "linux-x64": "piper_linux_x86_64.tar.gz", "linux-arm64": "piper_linux_aarch64.tar.gz", "linux-arm": "piper_linux_armv7l.tar.gz", "darwin-x64": "piper_macos_x64.tar.gz", "darwin-arm64": "piper_macos_aarch64.tar.gz", "win32-x64": "piper_windows_amd64.zip" };
18
+ // public-domain voices only: free to use in Symbiot, commercially, with no conditions
19
+ const VOICES = {
20
+ gb: { id: "en_GB-cori-medium", path: "en/en_GB/cori/medium", who: "British English" },
21
+ us: { id: "en_US-ljspeech-medium", path: "en/en_US/ljspeech/medium", who: "American English" },
22
+ };
23
+ const US_LIKE = new Set(["us", "ca", "ph", "lr", "pr", "um", "as", "gu", "vi"]);
24
+ const IDLE_MS = 5 * 60 * 1000;
25
+
26
+ const vdir = () => process.env.SYMBIOT_VOICE_DIR || join(CONFIG_DIR, "voice");
27
+ const piperUrl = (asset) => (process.env.SYMBIOT_PIPER_URL || `https://github.com/rhasspy/piper/releases/download/${PIPER_REL}`) + "/" + asset;
28
+ const voiceUrl = (v, ext) => (process.env.SYMBIOT_VOICES_URL || "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0") + `/${v.path}/${v.id}${ext}`;
29
+
30
+ // Which voice for this language ("en-ZA", "en_US", "en"): null when Piper has none we use.
31
+ function voiceFor(lang = "") {
32
+ const [l, r = ""] = String(lang).toLowerCase().replace("_", "-").split("-");
33
+ if (l !== "en") return null;
34
+ return US_LIKE.has(r) ? VOICES.us : VOICES.gb;
35
+ }
36
+ function assetFor(platform = process.platform, arch = process.arch) { return PIPER_ASSET[`${platform}-${arch}`] || null; }
37
+ function piperBin(platform = process.platform) { return join(vdir(), "piper", platform === "win32" ? "piper.exe" : "piper"); }
38
+ const modelPath = (v) => join(vdir(), "voices", v.id + ".onnx");
39
+ const haveVoice = (v) => existsSync(modelPath(v)) && existsSync(modelPath(v) + ".json");
40
+
41
+ const S = { busy: null, error: "", got: 0, total: 0 };
42
+ function voiceState(lang = "") {
43
+ const v = voiceFor(lang);
44
+ if (!v) return { state: "none", why: "no voice for this language" };
45
+ if (!assetFor()) return { state: "none", why: "not on this kind of computer" };
46
+ if (existsSync(piperBin()) && haveVoice(v)) return { state: "ready", voice: v.who };
47
+ if (S.busy) return { state: "downloading", voice: v.who, ...(S.total ? { percent: Math.min(99, Math.round((S.got / S.total) * 100)) } : {}) };
48
+ return { state: S.error ? "failed" : "missing", voice: v.who, ...(S.error ? { error: S.error } : {}) };
49
+ }
50
+
51
+ async function download(url, to) {
52
+ const r = await fetch(url, { redirect: "follow" });
53
+ if (!r.ok || !r.body) throw new Error(`couldn't download ${url.split("/").pop()} (${r.status})`);
54
+ S.total += Number(r.headers.get("content-length")) || 0;
55
+ const part = to + ".part", out = createWriteStream(part);
56
+ for await (const chunk of r.body) { S.got += chunk.length; if (!out.write(chunk)) await new Promise((ok) => out.once("drain", ok)); }
57
+ await new Promise((ok, bad) => out.end((e) => (e ? bad(e) : ok())));
58
+ renameSync(part, to);
59
+ }
60
+ // The engine and this language's voice, once. Safe to call again: it only fetches what's missing.
61
+ function prepareVoice(lang = "") {
62
+ const v = voiceFor(lang), asset = assetFor();
63
+ if (!v || !asset) return voiceState(lang);
64
+ if (S.busy) return voiceState(lang);
65
+ if (existsSync(piperBin()) && haveVoice(v)) return voiceState(lang);
66
+ S.error = ""; S.got = 0; S.total = 0;
67
+ S.busy = (async () => {
68
+ const dir = vdir(); mkdirSync(join(dir, "voices"), { recursive: true });
69
+ if (!existsSync(piperBin())) {
70
+ const arc = join(dir, asset);
71
+ await download(piperUrl(asset), arc);
72
+ // tar unpacks both: .tar.gz everywhere, and .zip on Windows 10+ (bsdtar)
73
+ const t = spawnSync("tar", [asset.endsWith(".zip") ? "-xf" : "-xzf", arc, "-C", dir], { encoding: "utf8", windowsHide: true });
74
+ rmSync(arc, { force: true });
75
+ if (t.status !== 0 || !existsSync(piperBin())) throw new Error("couldn't unpack the speech engine" + (t.stderr ? ": " + String(t.stderr).trim().split("\n").pop() : ""));
76
+ }
77
+ if (!haveVoice(v)) { await download(voiceUrl(v, ".onnx.json"), modelPath(v) + ".json"); await download(voiceUrl(v, ".onnx"), modelPath(v)); }
78
+ })().catch((e) => { S.error = String((e && e.message) || e); }).finally(() => { S.busy = null; });
79
+ return voiceState(lang);
80
+ }
81
+
82
+ // One warm engine per voice: a line of text in, the path of its .wav out, in order.
83
+ const WARM = new Map();
84
+ function engine(v) {
85
+ let w = WARM.get(v.id);
86
+ if (w && !w.dead) { clearTimeout(w.idle); w.idle = setTimeout(() => stopEngine(v.id), IDLE_MS); return w; }
87
+ const out = join(vdir(), "out"); mkdirSync(out, { recursive: true });
88
+ const ch = spawn(piperBin(), ["--model", modelPath(v), "--output_dir", out], { cwd: vdir(), stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
89
+ w = { ch, wait: [], buf: "", dead: false, idle: setTimeout(() => stopEngine(v.id), IDLE_MS) };
90
+ ch.stdout.on("data", (d) => {
91
+ w.buf += d; let i;
92
+ while ((i = w.buf.indexOf("\n")) >= 0) { const line = w.buf.slice(0, i).trim(); w.buf = w.buf.slice(i + 1); if (line) { const p = w.wait.shift(); if (p) p.ok(line); } }
93
+ });
94
+ const fail = (e) => { w.dead = true; clearTimeout(w.idle); for (const p of w.wait.splice(0)) p.bad(e); WARM.delete(v.id); };
95
+ ch.on("error", fail); ch.on("exit", () => fail(new Error("the speech engine stopped")));
96
+ WARM.set(v.id, w);
97
+ return w;
98
+ }
99
+ function stopEngine(id) { const w = WARM.get(id); if (!w) return; WARM.delete(id); w.dead = true; clearTimeout(w.idle); try { w.ch.stdin.end(); w.ch.kill(); } catch {} }
100
+ function stopVoices() { for (const id of [...WARM.keys()]) stopEngine(id); }
101
+
102
+ // Speak one piece of text (a sentence or two): the WAV's bytes.
103
+ async function speak(text, lang = "") {
104
+ const v = voiceFor(lang);
105
+ if (!v || voiceState(lang).state !== "ready") throw new Error("Symbiot's voice isn't ready yet");
106
+ const line = String(text || "").replace(/\s+/g, " ").trim().slice(0, 600);
107
+ if (!line) throw new Error("nothing to say");
108
+ const w = engine(v);
109
+ const file = await new Promise((ok, bad) => { w.wait.push({ ok, bad }); w.ch.stdin.write(line + "\n"); });
110
+ try { return readFileSync(file); } finally { rmSync(file, { force: true }); }
111
+ }
112
+
113
+ export { voiceFor, assetFor, voiceState, prepareVoice, speak, stopVoices, VOICES, PIPER_REL };