@clearlysh/beehaven 0.8.16

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.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/__tests__/_cli-switch.js +105 -0
  4. package/dist/__tests__/agent-wrap-probe.js +136 -0
  5. package/dist/agent-attribution.js +192 -0
  6. package/dist/agent-identity-daemon.js +135 -0
  7. package/dist/agent-onboard.js +156 -0
  8. package/dist/agent-scope.js +79 -0
  9. package/dist/agent-sessions.js +370 -0
  10. package/dist/agent-state.js +482 -0
  11. package/dist/app-manifest.js +221 -0
  12. package/dist/app-signer.js +54 -0
  13. package/dist/app-signing.js +127 -0
  14. package/dist/audio-play.js +98 -0
  15. package/dist/avatar-protocol.js +25 -0
  16. package/dist/avatar-route.js +79 -0
  17. package/dist/bee/cloud-proxy.js +130 -0
  18. package/dist/blocked-actions.js +120 -0
  19. package/dist/build-channel.js +101 -0
  20. package/dist/buildings/daemon-workspace.js +475 -0
  21. package/dist/claude-bridge.js +310 -0
  22. package/dist/claude-credential-store.js +633 -0
  23. package/dist/clearly-sync.js +518 -0
  24. package/dist/clearly.js +616 -0
  25. package/dist/cli-state.js +334 -0
  26. package/dist/cli.js +6973 -0
  27. package/dist/commands/agent-skills.js +302 -0
  28. package/dist/commands/agent.js +1310 -0
  29. package/dist/commands/app.js +1123 -0
  30. package/dist/commands/auth.js +171 -0
  31. package/dist/commands/building-shell.js +392 -0
  32. package/dist/commands/canvas.js +932 -0
  33. package/dist/commands/claude.js +218 -0
  34. package/dist/commands/compliance.js +387 -0
  35. package/dist/commands/compose.js +507 -0
  36. package/dist/commands/composition.js +95 -0
  37. package/dist/commands/connect-session.js +409 -0
  38. package/dist/commands/conversations.js +160 -0
  39. package/dist/commands/costs.js +443 -0
  40. package/dist/commands/d1.js +253 -0
  41. package/dist/commands/daemon-cmd.js +162 -0
  42. package/dist/commands/deploy.js +164 -0
  43. package/dist/commands/design.js +617 -0
  44. package/dist/commands/dev-generate.js +125 -0
  45. package/dist/commands/dev.js +109 -0
  46. package/dist/commands/devices.js +135 -0
  47. package/dist/commands/explain-router.js +24 -0
  48. package/dist/commands/explain.js +882 -0
  49. package/dist/commands/explore.js +264 -0
  50. package/dist/commands/help.js +2418 -0
  51. package/dist/commands/init.js +408 -0
  52. package/dist/commands/install.js +129 -0
  53. package/dist/commands/library.js +229 -0
  54. package/dist/commands/marketplace.js +138 -0
  55. package/dist/commands/mcp-install.js +476 -0
  56. package/dist/commands/mention.js +257 -0
  57. package/dist/commands/omni.js +529 -0
  58. package/dist/commands/secrets.js +323 -0
  59. package/dist/commands/self-update.js +206 -0
  60. package/dist/commands/sheet.js +264 -0
  61. package/dist/commands/shell-output.js +309 -0
  62. package/dist/commands/shell.js +1048 -0
  63. package/dist/commands/shop.js +368 -0
  64. package/dist/commands/skills.js +311 -0
  65. package/dist/commands/slides.js +418 -0
  66. package/dist/commands/swarm.js +341 -0
  67. package/dist/commands/ticket.js +1301 -0
  68. package/dist/commands/tool.js +312 -0
  69. package/dist/commands/trash.js +106 -0
  70. package/dist/commands/treehouse.js +120 -0
  71. package/dist/commands/user.js +240 -0
  72. package/dist/commands/voice-tts.js +319 -0
  73. package/dist/commands/voice.js +166 -0
  74. package/dist/commands/watch-git.js +401 -0
  75. package/dist/commands/webhook.js +159 -0
  76. package/dist/commands/workspace.js +3354 -0
  77. package/dist/composition-repos.js +91 -0
  78. package/dist/daemon-avatar.js +130 -0
  79. package/dist/daemon-claude-code-auto-install.js +52 -0
  80. package/dist/daemon-client.js +363 -0
  81. package/dist/daemon-codex.js +349 -0
  82. package/dist/daemon-entry.js +101 -0
  83. package/dist/daemon-fetch-allowlist.js +194 -0
  84. package/dist/daemon-gemini.js +224 -0
  85. package/dist/daemon-mcp-clearly.js +353 -0
  86. package/dist/daemon-mcp-sandbox.js +127 -0
  87. package/dist/daemon-omni-company.js +338 -0
  88. package/dist/daemon-omni-hooks.js +375 -0
  89. package/dist/daemon-omni-identity.js +229 -0
  90. package/dist/daemon-omni-pipeline.js +251 -0
  91. package/dist/daemon-omni-policies.js +346 -0
  92. package/dist/daemon-omni-principal.js +580 -0
  93. package/dist/daemon-omni-tool-gate.js +116 -0
  94. package/dist/daemon-omni.js +1442 -0
  95. package/dist/daemon-path-transform.js +120 -0
  96. package/dist/daemon-write-sandbox.js +191 -0
  97. package/dist/daemon.js +4471 -0
  98. package/dist/dashboard.js +846 -0
  99. package/dist/dep-installer.js +286 -0
  100. package/dist/dev-mode.js +67 -0
  101. package/dist/doc-indexer.js +169 -0
  102. package/dist/e2e-crypto.js +94 -0
  103. package/dist/feature-flags.js +154 -0
  104. package/dist/file-browser.js +88 -0
  105. package/dist/first-party-hives.js +211 -0
  106. package/dist/folder-sync.js +226 -0
  107. package/dist/health.js +56 -0
  108. package/dist/ipc-auth.js +299 -0
  109. package/dist/ipc-client.js +495 -0
  110. package/dist/ipc-server.js +684 -0
  111. package/dist/keychain.js +224 -0
  112. package/dist/lib/admin-client.js +73 -0
  113. package/dist/lib/mobile-build-parser.js +156 -0
  114. package/dist/local-app-manager.js +660 -0
  115. package/dist/log-redaction.js +89 -0
  116. package/dist/logger.js +108 -0
  117. package/dist/manifest.js +143 -0
  118. package/dist/marketplace.js +793 -0
  119. package/dist/min-version-copy.js +51 -0
  120. package/dist/min-version.js +214 -0
  121. package/dist/public-surface.js +23 -0
  122. package/dist/relay-presets.js +89 -0
  123. package/dist/relay.js +2723 -0
  124. package/dist/repl.js +619 -0
  125. package/dist/setup-relay.js +363 -0
  126. package/dist/setup-service.js +536 -0
  127. package/dist/shared/bee-memory/index.js +6 -0
  128. package/dist/shared/bee-memory/types.js +682 -0
  129. package/dist/shared/claude-code/daemon-types.js +112 -0
  130. package/dist/shared/claude-code/index.js +2 -0
  131. package/dist/shared/claude-code/types.js +1 -0
  132. package/dist/shared/status/schema.js +133 -0
  133. package/dist/shell-session.js +198 -0
  134. package/dist/speak-lock.js +97 -0
  135. package/dist/spinner.js +57 -0
  136. package/dist/telemetry.js +166 -0
  137. package/dist/terminal/block-parser.js +207 -0
  138. package/dist/terminal/shell-integration.js +108 -0
  139. package/dist/transcript-tailer.js +215 -0
  140. package/dist/tts-qwen3.js +547 -0
  141. package/dist/types.js +4 -0
  142. package/dist/utm.js +38 -0
  143. package/dist/verb-surface.js +331 -0
  144. package/dist/version-utils.js +85 -0
  145. package/dist/watcher.js +207 -0
  146. package/package.json +88 -0
  147. package/python/qwen3_tts.py +230 -0
  148. package/scripts/stamp-build-channel.mjs +115 -0
  149. package/scripts/sync-installed.mjs +159 -0
  150. package/scripts/sync-skills.mjs +112 -0
  151. package/skills/README.md +24 -0
  152. package/skills/agent-doodle-space/SKILL.md +118 -0
  153. package/skills/agent-home/SKILL.md +98 -0
  154. package/skills/beehaven-cli/SKILL.md +144 -0
  155. package/skills/claude-shopper/README.md +90 -0
  156. package/skills/claude-shopper/SKILL.md +151 -0
  157. package/skills/clearly-agent/SKILL.md +183 -0
  158. package/skills/clearly-canvas/SKILL.md +222 -0
  159. package/skills/clearly-canvas/authoring.md +160 -0
  160. package/skills/clearly-canvas/communications.md +32 -0
  161. package/skills/clearly-canvas/composition-patterns.md +84 -0
  162. package/skills/clearly-canvas/example.mjs +78 -0
  163. package/skills/clearly-canvas/project-plans.md +156 -0
  164. package/skills/clearly-canvas/review.mjs +68 -0
  165. package/skills/clearly-workspace/SKILL.md +79 -0
  166. package/skills/clearly-workspace/survey.mjs +101 -0
  167. package/skills/codebase-map/SKILL.md +142 -0
  168. package/skills/design-craft/SKILL.md +300 -0
  169. package/skills/index.json +79 -0
  170. package/skills/layout-systems/SKILL.md +184 -0
  171. package/skills/pair-on-canvas/SKILL.md +92 -0
@@ -0,0 +1,547 @@
1
+ /**
2
+ * tts-qwen3.ts — the `qwen3-mlx` TTS provider: Qwen3-TTS under Apple MLX,
3
+ * running entirely on this machine, with 3-second voice cloning.
4
+ *
5
+ * WHY. Every other voice `beehaven speak` can use is either a network call
6
+ * (cloud Kokoro over the relay, ElevenLabs on the canvas) or a canned system
7
+ * voice (macOS `say`). So an agent in a terminal with no daemon token has no
8
+ * voice at all, and nobody has *their own* voice. Qwen3-TTS fixes both: it's
9
+ * local (no relay, no API key, no audio leaves the box) and it clones from a
10
+ * short reference recording.
11
+ *
12
+ * SHAPE. This file is the Node half; `python/qwen3_tts.py` is the MLX half.
13
+ * They talk over one JSON object on stdin and one on stdout — see that file's
14
+ * header for the contract. Everything that could reasonably be done in Node
15
+ * (path resolution, the voice registry, install) lives HERE, so the Python
16
+ * side stays a thin, replaceable synthesis shim.
17
+ *
18
+ * STATE ON DISK (`~/.beehaven/voice/qwen3-mlx/`):
19
+ *
20
+ * venv/ the Python environment (mlx-audio + mlx)
21
+ * voices/<name>.json voice profile: { name, refAudio, refText, ... }
22
+ * voices/<name>.<ext> a COPY of the reference recording
23
+ *
24
+ * The reference is copied, not linked, on purpose: a profile that points into
25
+ * ~/Downloads breaks the first time the user cleans that folder out, and it
26
+ * would break silently — mid-sentence, weeks later. Profiles are self-contained.
27
+ *
28
+ * Models themselves are NOT stored here — they land in the shared Hugging Face
29
+ * cache (`~/.cache/huggingface`), so a machine that already pulled Qwen3 for
30
+ * something else doesn't download it twice.
31
+ */
32
+ import { execFileSync, spawn } from 'child_process';
33
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'fs';
34
+ import { homedir, tmpdir } from 'os';
35
+ import { dirname, extname, join } from 'path';
36
+ import { fileURLToPath } from 'url';
37
+ const __dirname = dirname(fileURLToPath(import.meta.url));
38
+ /** Everything this provider owns lives under one directory. */
39
+ export const QWEN3_ROOT = join(homedir(), '.beehaven', 'voice', 'qwen3-mlx');
40
+ const VENV_DIR = join(QWEN3_ROOT, 'venv');
41
+ const VOICES_DIR = join(QWEN3_ROOT, 'voices');
42
+ const VENV_PYTHON = join(VENV_DIR, 'bin', 'python');
43
+ /**
44
+ * The default checkpoint. `-Base` is REQUIRED for cloning — the other two
45
+ * families can't take a reference recording at all (CustomVoice is fixed
46
+ * predefined speakers, VoiceDesign builds a voice from a text description).
47
+ * 0.6B-4bit is the small/fast end of the family: ~0.4 GB, roughly real-time
48
+ * on an M-series laptop, which is the right trade for one-line status
49
+ * utterances. Override with `BEEHAVEN_QWEN3_TTS_MODEL` (e.g. the 1.7B-8bit
50
+ * checkpoint for higher fidelity at ~2 GB and slower generation).
51
+ */
52
+ export const QWEN3_DEFAULT_MODEL = process.env.BEEHAVEN_QWEN3_TTS_MODEL || 'mlx-community/Qwen3-TTS-12Hz-0.6B-Base-4bit';
53
+ /**
54
+ * The CustomVoice sibling — the ONLY family that honours a named speaker.
55
+ *
56
+ * ⚠⚠ THE TWO FAMILIES ARE MUTUALLY EXCLUSIVE, WHICH IS WHY THIS IS A SECOND
57
+ * CONSTANT RATHER THAN A NEW DEFAULT. Base takes a reference recording and ignores
58
+ * speaker names; CustomVoice takes speaker names and cannot clone. Switching the
59
+ * default outright would silently break every cloned voice on the machine.
60
+ *
61
+ * `synthesize` therefore picks the checkpoint FROM THE VOICE it was asked for. A
62
+ * caller who pins the model explicitly (`BEEHAVEN_QWEN3_TTS_MODEL`, or `opts.model`)
63
+ * keeps it — their choice is more specific than this inference.
64
+ */
65
+ export const QWEN3_CUSTOMVOICE_MODEL = process.env.BEEHAVEN_QWEN3_CUSTOMVOICE_MODEL
66
+ || 'mlx-community/Qwen3-TTS-12Hz-0.6B-CustomVoice-4bit';
67
+ /**
68
+ * The model's built-in speakers.
69
+ *
70
+ * ⚠⚠ ONLY MEANINGFUL ON A CustomVoice CHECKPOINT. Base discards the name entirely —
71
+ * measured at **0.6% difference between two different speakers**, i.e. the same
72
+ * voice. Asking for a preset therefore SWITCHES THE MODEL (see `synthesize`).
73
+ *
74
+ * ⚠⚠ THIS LIST WAS WRONG ONCE AND THE MODEL IS THE AUTHORITY. An earlier version
75
+ * held `ethan, chelsie, cherry, nofish, jada, sunny` — names that do not exist on
76
+ * any checkpoint here; the real set came from the runtime error
77
+ * (`Speaker 'ethan' not supported. Available: [...]`). Treat this as a hint for
78
+ * error messages and completion, never as validation the model has not confirmed:
79
+ * mlx-audio raises with the true list, which is worth more than anything hardcoded.
80
+ *
81
+ * ⚠ SEPARATE NAMESPACE FROM THE CLONED REGISTRY (`~/.beehaven/voice/qwen3-mlx/
82
+ * voices/`). A cloned profile wins if the names collide, because it is the more
83
+ * specific thing the user made themselves — and on Base it is the only thing that
84
+ * actually pins a voice.
85
+ */
86
+ export const QWEN3_PRESET_SPEAKERS = [
87
+ // ── measured reliable, most consistent first ──
88
+ 'serena', 'aiden', 'vivian',
89
+ // ⚠⚠ THE REST PRODUCE RUNAWAY TAKES — see QWEN3_UNRELIABLE_SPEAKERS below.
90
+ 'dylan', 'uncle_fu', 'ryan', 'sohee', 'eric', 'ono_anna',
91
+ ];
92
+ /**
93
+ * ⚠⚠ SPEAKERS THAT GENERATE FOR MINUTES ON A SIX-SECOND SENTENCE.
94
+ *
95
+ * The model advertises nine speakers; only three behave. Measured, 4 runs each of a
96
+ * 72-character sentence that should be ~6s of speech:
97
+ *
98
+ * serena 6.2s aiden 5.9s vivian 6.5s ← usable
99
+ * uncle_fu 90.7s ryan 87.5s eric 86.8s
100
+ * sohee 167.3s dylan 87.2s (at temp 0.3) ← runaway
101
+ * ono_anna — degenerate at every temperature tried
102
+ *
103
+ * This is not a quality difference, it is a failure mode: the decoder never stops,
104
+ * so a one-line status utterance blocks for minutes and plays nonsense. Worth a
105
+ * loud warning rather than a silent bad experience — a caller who names one of
106
+ * these has almost certainly not tested it.
107
+ *
108
+ * ⚠ NOT A HARD BLOCK. They may behave on another checkpoint, another quantisation
109
+ * or a different temperature, and refusing outright would make that untestable.
110
+ */
111
+ export const QWEN3_UNRELIABLE_SPEAKERS = [
112
+ 'uncle_fu', 'ryan', 'sohee', 'eric', 'ono_anna', 'dylan',
113
+ ];
114
+ /**
115
+ * Sampling temperature when the caller has not chosen one.
116
+ *
117
+ * ⚠ NOT A STYLE KNOB — at this model's default of 0.7 the speaker itself is
118
+ * resampled per utterance. See the note in `synthesize`. Override per call, or with
119
+ * `BEEHAVEN_QWEN3_TEMPERATURE` for a machine that wants more variation.
120
+ */
121
+ export const QWEN3_STABLE_TEMPERATURE = Number(process.env.BEEHAVEN_QWEN3_TEMPERATURE) || 0.01;
122
+ /**
123
+ * Default temperature for a PRESET speaker — deliberately higher than the cloning
124
+ * default above, which is the opposite of what you would expect.
125
+ *
126
+ * ⚠⚠ ON CustomVoice, NEAR-ZERO TEMPERATURE IS WHAT *CAUSES* THE RUNAWAY TAKES.
127
+ * The cloning path is steadier the lower you go (a reference recording pins the
128
+ * timbre, so sampling only adds noise). CustomVoice has no reference, and at 0.01
129
+ * the decoder falls into loops — `ryan` produced an unusable take at 0.3 but so did
130
+ * several others at 0.01, and `dylan` was fine at 0.01 and ran away at 0.3.
131
+ *
132
+ * Measured over 4 runs each: at 0.3, `serena` spread 14.0% / 6.8% / 14.1%
133
+ * (zcr/centroid/duration) at a sane 6.2s. Do not "unify" the two constants —
134
+ * they describe different mechanisms and were measured separately.
135
+ */
136
+ export const QWEN3_PRESET_TEMPERATURE = Number(process.env.BEEHAVEN_QWEN3_PRESET_TEMPERATURE) || 0.3;
137
+ /**
138
+ * Per-speaker temperature, because one number does not fit them.
139
+ *
140
+ * ⚠⚠ THE RELIABLE SPEAKERS DISAGREE ABOUT WHICH REGIME SUITS THEM, and the gap is
141
+ * not marginal. Measured over 4 runs each (zcr / centroid / duration spread):
142
+ *
143
+ * serena 0.3 → 14.0% / 6.8% / 14.1% 0.01 → 8.0% / 24.1% / 11.1%
144
+ * vivian 0.3 → 15.3% / 12.0% / 8.7% 0.01 → 26.6% / 22.0% / 15.0%
145
+ * aiden 0.01 → 15.4% / 7.3% / 12.2% 0.3 → 10.4% / 58.1% / 21.9%
146
+ *
147
+ * `aiden` at the shared 0.3 default has an EIGHT-FOLD worse spectral spread than at
148
+ * 0.01 — it would sound like the least consistent of the three while being, at its
149
+ * own setting, the joint best. A single default silently hands some speakers their
150
+ * worst configuration.
151
+ *
152
+ * ⚠ ONLY THE MEASURED THREE ARE LISTED. Everything else falls back to
153
+ * `QWEN3_PRESET_TEMPERATURE`, which is the safer regime for speakers whose failure
154
+ * mode is a runaway take. Add a row only with numbers behind it.
155
+ */
156
+ export const QWEN3_SPEAKER_TEMPERATURE = {
157
+ aiden: 0.01,
158
+ serena: 0.3,
159
+ vivian: 0.3,
160
+ };
161
+ /** Minimum mlx-audio with the Qwen3-TTS Base + ICL cloning path. */
162
+ export const MLX_AUDIO_SPEC = process.env.BEEHAVEN_MLX_AUDIO_SPEC || 'mlx-audio>=0.4.6';
163
+ /** Python the venv is built with. mlx-audio needs >=3.10; 3.13 has wheels for every dep. */
164
+ const VENV_PYTHON_VERSION = '3.13';
165
+ // ── Capability + install ────────────────────────────────────────────────────
166
+ /** MLX is Apple-Silicon-only; on anything else this provider can't exist. */
167
+ export function isSupported() {
168
+ return process.platform === 'darwin' && process.arch === 'arm64';
169
+ }
170
+ export function unsupportedReason() {
171
+ if (process.platform !== 'darwin')
172
+ return `qwen3-mlx needs macOS (this is ${process.platform})`;
173
+ if (process.arch !== 'arm64')
174
+ return `qwen3-mlx needs Apple Silicon (this is ${process.arch})`;
175
+ return '';
176
+ }
177
+ export function isInstalled() {
178
+ return existsSync(VENV_PYTHON);
179
+ }
180
+ /** The bundled Python half. Resolved, not assumed — dev tree and npm tarball differ. */
181
+ function helperScript() {
182
+ const candidates = [
183
+ // dist/cli.js → ../python/qwen3_tts.py (dev tree AND the published tarball)
184
+ join(__dirname, '..', 'python', 'qwen3_tts.py'),
185
+ // If a bundler ever flattens python/ next to the JS.
186
+ join(__dirname, 'python', 'qwen3_tts.py'),
187
+ // Running straight from src/ under tsx.
188
+ join(__dirname, '..', '..', 'python', 'qwen3_tts.py'),
189
+ ];
190
+ for (const p of candidates)
191
+ if (existsSync(p))
192
+ return p;
193
+ throw new Error('qwen3_tts.py not found — the CLI install is incomplete. Reinstall with:\n' +
194
+ ' cd beehaven_sdk/packages/cli && npm run build && npm link');
195
+ }
196
+ function which(cmd) {
197
+ const probe = process.platform === 'win32' ? 'where' : 'which';
198
+ try {
199
+ const out = execFileSync(probe, [cmd], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
200
+ const first = out.split('\n')[0]?.trim();
201
+ return first || null;
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ }
207
+ /** `uv` if it's anywhere we can find it — it builds the venv in seconds, pip takes minutes. */
208
+ function findUv() {
209
+ return which('uv') ?? [join(homedir(), '.local', 'bin', 'uv'), '/opt/homebrew/bin/uv'].find(existsSync) ?? null;
210
+ }
211
+ function run(cmd, args, onLine) {
212
+ return new Promise((resolve) => {
213
+ const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
214
+ let stderr = '';
215
+ const feed = (buf) => {
216
+ const s = buf.toString();
217
+ stderr += s;
218
+ if (onLine)
219
+ for (const l of s.split('\n'))
220
+ if (l.trim())
221
+ onLine(l.trim());
222
+ };
223
+ p.stdout.on('data', feed);
224
+ p.stderr.on('data', feed);
225
+ p.on('error', (e) => resolve({ code: 1, stderr: String(e?.message ?? e) }));
226
+ p.on('close', (code) => resolve({ code: code ?? 1, stderr }));
227
+ });
228
+ }
229
+ /**
230
+ * Create `~/.beehaven/voice/qwen3-mlx/venv` and install mlx-audio into it.
231
+ *
232
+ * Deliberately its OWN venv rather than the user's system Python: mlx-audio
233
+ * pins `transformers` and `mlx` hard, and silently upgrading those inside
234
+ * whatever environment happens to be active is the kind of thing that breaks
235
+ * an unrelated project a week later.
236
+ */
237
+ export async function install(onLine) {
238
+ if (!isSupported())
239
+ throw new Error(unsupportedReason());
240
+ mkdirSync(QWEN3_ROOT, { recursive: true });
241
+ const uv = findUv();
242
+ if (uv) {
243
+ // `--python 3.13` lets uv download a managed CPython if the machine has
244
+ // none — so setup works on a Mac with only the Xcode python.
245
+ const venv = await run(uv, ['venv', '--python', VENV_PYTHON_VERSION, VENV_DIR], onLine);
246
+ if (venv.code !== 0)
247
+ throw new Error(`uv venv failed:\n${venv.stderr.trim()}`);
248
+ const pip = await run(uv, ['pip', 'install', '--python', VENV_PYTHON, MLX_AUDIO_SPEC], onLine);
249
+ if (pip.code !== 0)
250
+ throw new Error(`uv pip install failed:\n${pip.stderr.trim()}`);
251
+ }
252
+ else {
253
+ const py = ['python3.13', 'python3.12', 'python3.11'].map(which).find(Boolean) ?? which('python3');
254
+ if (!py) {
255
+ throw new Error('No suitable Python found. Install uv (https://astral.sh/uv) or Python 3.11+, then re-run `beehaven voice setup`.');
256
+ }
257
+ const venv = await run(py, ['-m', 'venv', VENV_DIR], onLine);
258
+ if (venv.code !== 0)
259
+ throw new Error(`python -m venv failed:\n${venv.stderr.trim()}`);
260
+ const pip = await run(VENV_PYTHON, ['-m', 'pip', 'install', '-U', MLX_AUDIO_SPEC], onLine);
261
+ if (pip.code !== 0)
262
+ throw new Error(`pip install failed:\n${pip.stderr.trim()}`);
263
+ }
264
+ if (!existsSync(VENV_PYTHON))
265
+ throw new Error(`venv build produced no interpreter at ${VENV_PYTHON}`);
266
+ }
267
+ // ── Talking to the Python half ──────────────────────────────────────────────
268
+ /**
269
+ * Run one helper command. JSON in, JSON out.
270
+ *
271
+ * `stderr` is captured rather than inherited because mlx-audio writes download
272
+ * bars and tokenizer warnings there, and dumping those into an agent's
273
+ * terminal mid-`speak` is noise. On failure the tail is folded into the thrown
274
+ * message — that's where the real cause usually is, since mlx-audio catches
275
+ * model-load errors itself and only prints them.
276
+ */
277
+ async function helper(command, request, opts = {}) {
278
+ if (!isInstalled()) {
279
+ throw new Error('qwen3-mlx is not installed yet — run `beehaven voice setup`');
280
+ }
281
+ const script = helperScript();
282
+ const timeoutMs = opts.timeoutMs ?? 10 * 60_000;
283
+ return new Promise((resolve, reject) => {
284
+ const p = spawn(VENV_PYTHON, [script, command], {
285
+ stdio: ['pipe', 'pipe', 'pipe'],
286
+ // Keep HF's progress bars off; we surface our own status.
287
+ env: { ...process.env, HF_HUB_DISABLE_PROGRESS_BARS: '1', PYTHONUNBUFFERED: '1' },
288
+ });
289
+ let stdout = '';
290
+ let stderr = '';
291
+ const timer = setTimeout(() => {
292
+ p.kill('SIGKILL');
293
+ reject(new Error(`qwen3-mlx ${command} timed out after ${Math.round(timeoutMs / 1000)}s`));
294
+ }, timeoutMs);
295
+ p.stdout.on('data', (b) => (stdout += b.toString()));
296
+ p.stderr.on('data', (b) => {
297
+ const s = b.toString();
298
+ // Bound the buffer — a stuck download could stream megabytes.
299
+ stderr = (stderr + s).slice(-8000);
300
+ if (opts.onLine)
301
+ for (const l of s.split('\n'))
302
+ if (l.trim())
303
+ opts.onLine(l.trim());
304
+ });
305
+ p.on('error', (e) => {
306
+ clearTimeout(timer);
307
+ reject(new Error(`could not start python: ${e?.message ?? e}`));
308
+ });
309
+ p.on('close', () => {
310
+ clearTimeout(timer);
311
+ const line = stdout.trim().split('\n').filter(Boolean).pop();
312
+ if (!line) {
313
+ reject(new Error(`qwen3-mlx ${command} produced no result.\n${stderr.trim().slice(-1200)}`));
314
+ return;
315
+ }
316
+ let parsed;
317
+ try {
318
+ parsed = JSON.parse(line);
319
+ }
320
+ catch {
321
+ reject(new Error(`qwen3-mlx ${command} returned unparsable output: ${line.slice(0, 300)}`));
322
+ return;
323
+ }
324
+ if (parsed?.ok === false) {
325
+ reject(new Error(`${parsed.error}\n${stderr.trim().slice(-800)}`.trim()));
326
+ return;
327
+ }
328
+ resolve(parsed);
329
+ });
330
+ p.stdin.write(JSON.stringify(request));
331
+ p.stdin.end();
332
+ });
333
+ }
334
+ export function probe() {
335
+ return helper('probe', {}, { timeoutMs: 60_000 });
336
+ }
337
+ // ── Voice registry ──────────────────────────────────────────────────────────
338
+ /** Voice names become filenames, so keep them boring. */
339
+ function assertVoiceName(name) {
340
+ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(name)) {
341
+ throw new Error(`invalid voice name ${JSON.stringify(name)} — use letters, digits, dot, dash, underscore`);
342
+ }
343
+ }
344
+ export function listVoices() {
345
+ if (!existsSync(VOICES_DIR))
346
+ return [];
347
+ const out = [];
348
+ for (const f of readdirSync(VOICES_DIR)) {
349
+ if (!f.endsWith('.json'))
350
+ continue;
351
+ try {
352
+ out.push(JSON.parse(readFileSync(join(VOICES_DIR, f), 'utf8')));
353
+ }
354
+ catch {
355
+ /* a hand-mangled profile shouldn't hide the rest of the list */
356
+ }
357
+ }
358
+ return out.sort((a, b) => a.name.localeCompare(b.name));
359
+ }
360
+ export function getVoice(name) {
361
+ const p = join(VOICES_DIR, `${name}.json`);
362
+ if (!existsSync(p))
363
+ return null;
364
+ try {
365
+ return JSON.parse(readFileSync(p, 'utf8'));
366
+ }
367
+ catch {
368
+ return null;
369
+ }
370
+ }
371
+ export function removeVoice(name) {
372
+ const v = getVoice(name);
373
+ if (!v)
374
+ return false;
375
+ rmSync(join(VOICES_DIR, `${name}.json`), { force: true });
376
+ // Only remove the audio we copied in — never a path outside our own dir.
377
+ if (v.refAudio?.startsWith(VOICES_DIR))
378
+ rmSync(v.refAudio, { force: true });
379
+ return true;
380
+ }
381
+ /** Transcribe a recording with whisper (via the helper). Slow — clone-time only. */
382
+ export function transcribe(audioPath, opts = {}) {
383
+ return helper('transcribe', { audio: audioPath }, { timeoutMs: 15 * 60_000, onLine: opts.onLine });
384
+ }
385
+ /**
386
+ * Register a cloned voice from a reference recording.
387
+ *
388
+ * `refText` — the transcript — is not optional to Qwen3: cloning is in-context
389
+ * learning, so the model needs to see the reference audio AND what was said in
390
+ * it. We resolve it ONCE here (whisper, or the caller's `--ref-text`) and store
391
+ * it, so synthesis never has to load a 1.6 GB STT model again.
392
+ */
393
+ export async function cloneVoice(name, sourceAudio, opts = {}) {
394
+ assertVoiceName(name);
395
+ if (!existsSync(sourceAudio))
396
+ throw new Error(`reference audio not found: ${sourceAudio}`);
397
+ if (getVoice(name) && !opts.force) {
398
+ throw new Error(`voice "${name}" already exists — pass --force to overwrite`);
399
+ }
400
+ mkdirSync(VOICES_DIR, { recursive: true });
401
+ const ext = extname(sourceAudio) || '.wav';
402
+ const stored = join(VOICES_DIR, `${name}${ext}`);
403
+ copyFileSync(sourceAudio, stored);
404
+ let refText = opts.refText?.trim();
405
+ let transcribedBy;
406
+ if (!refText) {
407
+ const t = await transcribe(stored, { onLine: opts.onLine });
408
+ refText = t.text;
409
+ transcribedBy = t.model;
410
+ }
411
+ const voice = {
412
+ name,
413
+ refAudio: stored,
414
+ refText: refText,
415
+ source: sourceAudio,
416
+ createdAt: Date.now(),
417
+ ...(transcribedBy ? { transcribedBy } : {}),
418
+ };
419
+ writeFileSync(join(VOICES_DIR, `${name}.json`), JSON.stringify(voice, null, 2));
420
+ return voice;
421
+ }
422
+ /**
423
+ * Synthesize `text` to a wav on disk.
424
+ *
425
+ * Returns the path rather than bytes because the consumer is `audio-play.ts`,
426
+ * which writes a temp file and hands it to `afplay` anyway — passing 300 KB
427
+ * through a pipe just to write it back out would be pure ceremony.
428
+ */
429
+ export async function synthesize(text, opts = {}) {
430
+ const out = opts.out ?? join(tmpdir(), `beehaven-qwen3-${process.pid}-${Date.now()}.wav`);
431
+ const req = {
432
+ text,
433
+ out,
434
+ model: opts.model ?? QWEN3_DEFAULT_MODEL,
435
+ ...(opts.speed ? { speed: opts.speed } : {}),
436
+ ...(opts.temperature ? { temperature: opts.temperature } : {}),
437
+ // ⚠ `python/qwen3_tts.py` has forwarded this since it was written; the Node half
438
+ // simply never sent it, so the single biggest expression control was
439
+ // unreachable from the CLI. Same shape as preset speakers being refused here
440
+ // while Python supported them.
441
+ ...(opts.instruct ? { instruct: opts.instruct } : {}),
442
+ };
443
+ let cloned = null;
444
+ if (opts.voice) {
445
+ cloned = getVoice(opts.voice);
446
+ if (cloned) {
447
+ req.refAudio = cloned.refAudio;
448
+ req.refText = cloned.refText;
449
+ /**
450
+ * ⚠ SAY SO RATHER THAN SILENTLY IGNORING IT. A style direction that measures
451
+ * 1–6% of movement is not a subtle effect, it is no effect — and a caller who
452
+ * asked for "in a whisper" and got a normal voice will blame the wording,
453
+ * not the checkpoint.
454
+ */
455
+ if (opts.instruct) {
456
+ console.error(` ! style direction "${opts.instruct}" has almost no effect on a cloned voice — ` +
457
+ 'the reference recording sets the delivery. Use a CustomVoice preset ' +
458
+ `(${QWEN3_PRESET_SPEAKERS.slice(0, 3).join(', ')}) to direct the style.`);
459
+ }
460
+ }
461
+ else if (QWEN3_PRESET_SPEAKERS.includes(opts.voice)) {
462
+ /**
463
+ * ⚠⚠ A PRESET SPEAKER SWITCHES THE CHECKPOINT, because Base cannot honour one.
464
+ *
465
+ * Measured on Base: `ethan` vs `eric` came out 0.6% apart on a speaker
466
+ * fingerprint — the name is discarded and the model keeps drawing a speaker
467
+ * per utterance. On CustomVoice the same comparison separates by 10–91%, so
468
+ * the name genuinely selects a voice.
469
+ *
470
+ * ⚠ AN EXPLICIT MODEL WINS. If the caller pinned one (`opts.model`, or
471
+ * `BEEHAVEN_QWEN3_TTS_MODEL`), respect it and let mlx-audio raise its own
472
+ * "Speaker 'x' not supported. Available: [...]" — that error names the true
473
+ * set, which is more useful than anything this file could assert.
474
+ */
475
+ const modelWasChosen = !!opts.model || !!process.env.BEEHAVEN_QWEN3_TTS_MODEL;
476
+ if (!modelWasChosen)
477
+ req.model = QWEN3_CUSTOMVOICE_MODEL;
478
+ /**
479
+ * ⚠ STILL REFUSE IF THE RESOLVED MODEL IS A Base ONE — which now only happens
480
+ * when the caller pinned it themselves. Accepting the name there would report
481
+ * `voice "x"` while the model kept drawing a speaker per utterance: a label
482
+ * that agrees with the request and disagrees with the audio. That combination
483
+ * is why "the voice still switches" survived the first fix, and it is worth a
484
+ * loud refusal rather than a silent no-op.
485
+ */
486
+ if (/-Base(-|$)/i.test(String(req.model))) {
487
+ throw new Error(`speaker "${opts.voice}" is ignored by this model (${req.model}) — Base checkpoints take a timbre only from a reference recording. ` +
488
+ `Unset BEEHAVEN_QWEN3_TTS_MODEL to use ${QWEN3_CUSTOMVOICE_MODEL}, or clone a voice with \`beehaven voice clone <name> <audio>\`.`);
489
+ }
490
+ req.voice = opts.voice;
491
+ // ⚠ WARN, DO NOT REFUSE — see QWEN3_UNRELIABLE_SPEAKERS. Someone naming one
492
+ // of these has almost certainly not heard it; someone testing one needs it
493
+ // to still run.
494
+ if (QWEN3_UNRELIABLE_SPEAKERS.includes(opts.voice)) {
495
+ const msg = ` ! speaker "${opts.voice}" measured unreliable — it can generate for minutes on a short sentence. ` +
496
+ `Reliable: ${QWEN3_PRESET_SPEAKERS.slice(0, 3).join(', ')}.`;
497
+ /**
498
+ * ⚠ STDERR DIRECTLY, NOT ONLY `onLine`. The speak ladder calls `synthesize`
499
+ * without an `onLine` handler, so routing this through the callback alone
500
+ * put the warning nowhere — the exact failure it exists to prevent, one
501
+ * layer up. Verified by running it: the callback version printed nothing.
502
+ */
503
+ console.error(msg);
504
+ opts.onLine?.(msg);
505
+ }
506
+ }
507
+ else {
508
+ const known = listVoices().map((v) => v.name);
509
+ throw new Error(`unknown voice "${opts.voice}" — presets: ${QWEN3_PRESET_SPEAKERS.join(', ')}` +
510
+ (known.length ? `; cloned: ${known.join(', ')}` : '; clone one with `beehaven voice clone <name> <audio>`'));
511
+ }
512
+ }
513
+ /**
514
+ * ⚠⚠ TEMPERATURE IS A VOICE-IDENTITY SETTING HERE, NOT A STYLE KNOB — AND ITS
515
+ * DEFAULT WAS WHY "THE VOICE CHANGED RANDOMLY".
516
+ *
517
+ * With no reference audio the model SAMPLES a speaker, so at the Python half's
518
+ * default of 0.7 every utterance is a different person. Measured over 3 runs of
519
+ * one sentence, spread of a crude speaker fingerprint (zero-crossing rate /
520
+ * spectral centroid):
521
+ *
522
+ * base voice, temp 0.7 → 75% / 81% a different voice each time
523
+ * "ethan", temp 0.01 → 16% / 14% recognisably one voice
524
+ *
525
+ * So the fix is BOTH: name a speaker, and stop sampling. This pins the low
526
+ * temperature whenever the caller has not asked for one, and leaves an explicit
527
+ * `--tts-voice`-style override alone.
528
+ *
529
+ * ⚠ n=3 per cell and the fingerprint is crude, so do not read the exact numbers
530
+ * as precise — the property that matters is that ≪0.7 is dramatically steadier.
531
+ * Raise it deliberately if the delivery starts sounding flat.
532
+ */
533
+ if (opts.temperature === undefined) {
534
+ // ⚠ WHICH DEFAULT DEPENDS ON WHICH MECHANISM IS PINNING THE VOICE. A preset
535
+ // (CustomVoice) needs the HIGHER value; a reference recording (Base) needs
536
+ // the lower one. See both constants — this was measured, not assumed, and
537
+ // getting it backwards is what produces minutes-long runaway takes.
538
+ req.temperature = req.voice
539
+ ? (QWEN3_SPEAKER_TEMPERATURE[String(req.voice)] ?? QWEN3_PRESET_TEMPERATURE)
540
+ : QWEN3_STABLE_TEMPERATURE;
541
+ }
542
+ const res = await helper('speak', req, {
543
+ timeoutMs: opts.timeoutMs ?? 10 * 60_000,
544
+ onLine: opts.onLine,
545
+ });
546
+ return { ...res, ...(cloned ? { voice: cloned.name } : {}) };
547
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ // ============================================================================
2
+ // Beehaven Daemon — Type Definitions
3
+ // ============================================================================
4
+ export {};
package/dist/utm.js ADDED
@@ -0,0 +1,38 @@
1
+ // ============================================================================
2
+ // UTM tagging for browser-opened clearly.sh URLs
3
+ // ----------------------------------------------------------------------------
4
+ // When the CLI opens a clearly.sh URL in the user's default browser (login,
5
+ // signup, admin console, canvas), the navigation carries no web referrer — so
6
+ // GA4 buckets the session as "(direct)", inflating direct traffic and hiding
7
+ // real attribution. `withUtm()` stamps utm_source/utm_medium so those hits are
8
+ // attributable to the CLI.
9
+ //
10
+ // ONLY clearly.sh / *.clearly.sh hosts are tagged. OAuth provider URLs
11
+ // (accounts.google.com), Stripe, localhost dev servers, staging *.pages.dev,
12
+ // and any other third-party URL pass through untouched. Existing query strings
13
+ // are preserved and existing utm_* params are never overwritten (idempotent).
14
+ // ============================================================================
15
+ const DEFAULT_SOURCE = 'cli';
16
+ const DEFAULT_MEDIUM = 'cli';
17
+ function isClearlyHost(hostname) {
18
+ const h = hostname.toLowerCase();
19
+ return h === 'clearly.sh' || h.endsWith('.clearly.sh');
20
+ }
21
+ export function withUtm(rawUrl, source = DEFAULT_SOURCE, medium = DEFAULT_MEDIUM) {
22
+ try {
23
+ const u = new URL(rawUrl);
24
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
25
+ return rawUrl;
26
+ if (!isClearlyHost(u.hostname))
27
+ return rawUrl;
28
+ if (!u.searchParams.has('utm_source'))
29
+ u.searchParams.set('utm_source', source);
30
+ if (!u.searchParams.has('utm_medium'))
31
+ u.searchParams.set('utm_medium', medium);
32
+ return u.toString();
33
+ }
34
+ catch {
35
+ // Non-absolute or malformed URL — leave it exactly as-is.
36
+ return rawUrl;
37
+ }
38
+ }