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.
- package/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/__tests__/_cli-switch.js +105 -0
- package/dist/__tests__/agent-wrap-probe.js +136 -0
- package/dist/agent-attribution.js +192 -0
- package/dist/agent-identity-daemon.js +135 -0
- package/dist/agent-onboard.js +156 -0
- package/dist/agent-scope.js +79 -0
- package/dist/agent-sessions.js +370 -0
- package/dist/agent-state.js +482 -0
- package/dist/app-manifest.js +221 -0
- package/dist/app-signer.js +54 -0
- package/dist/app-signing.js +127 -0
- package/dist/audio-play.js +98 -0
- package/dist/avatar-protocol.js +25 -0
- package/dist/avatar-route.js +79 -0
- package/dist/bee/cloud-proxy.js +130 -0
- package/dist/blocked-actions.js +120 -0
- package/dist/build-channel.js +101 -0
- package/dist/buildings/daemon-workspace.js +475 -0
- package/dist/claude-bridge.js +310 -0
- package/dist/claude-credential-store.js +633 -0
- package/dist/clearly-sync.js +518 -0
- package/dist/clearly.js +616 -0
- package/dist/cli-state.js +334 -0
- package/dist/cli.js +6973 -0
- package/dist/commands/agent-skills.js +302 -0
- package/dist/commands/agent.js +1310 -0
- package/dist/commands/app.js +1123 -0
- package/dist/commands/auth.js +171 -0
- package/dist/commands/building-shell.js +392 -0
- package/dist/commands/canvas.js +932 -0
- package/dist/commands/claude.js +218 -0
- package/dist/commands/compliance.js +387 -0
- package/dist/commands/compose.js +507 -0
- package/dist/commands/composition.js +95 -0
- package/dist/commands/connect-session.js +409 -0
- package/dist/commands/conversations.js +160 -0
- package/dist/commands/costs.js +443 -0
- package/dist/commands/d1.js +253 -0
- package/dist/commands/daemon-cmd.js +162 -0
- package/dist/commands/deploy.js +164 -0
- package/dist/commands/design.js +617 -0
- package/dist/commands/dev-generate.js +125 -0
- package/dist/commands/dev.js +109 -0
- package/dist/commands/devices.js +135 -0
- package/dist/commands/explain-router.js +24 -0
- package/dist/commands/explain.js +882 -0
- package/dist/commands/explore.js +264 -0
- package/dist/commands/help.js +2418 -0
- package/dist/commands/init.js +408 -0
- package/dist/commands/install.js +129 -0
- package/dist/commands/library.js +229 -0
- package/dist/commands/marketplace.js +138 -0
- package/dist/commands/mcp-install.js +476 -0
- package/dist/commands/mention.js +257 -0
- package/dist/commands/omni.js +529 -0
- package/dist/commands/secrets.js +323 -0
- package/dist/commands/self-update.js +206 -0
- package/dist/commands/sheet.js +264 -0
- package/dist/commands/shell-output.js +309 -0
- package/dist/commands/shell.js +1048 -0
- package/dist/commands/shop.js +368 -0
- package/dist/commands/skills.js +311 -0
- package/dist/commands/slides.js +418 -0
- package/dist/commands/swarm.js +341 -0
- package/dist/commands/ticket.js +1301 -0
- package/dist/commands/tool.js +312 -0
- package/dist/commands/trash.js +106 -0
- package/dist/commands/treehouse.js +120 -0
- package/dist/commands/user.js +240 -0
- package/dist/commands/voice-tts.js +319 -0
- package/dist/commands/voice.js +166 -0
- package/dist/commands/watch-git.js +401 -0
- package/dist/commands/webhook.js +159 -0
- package/dist/commands/workspace.js +3354 -0
- package/dist/composition-repos.js +91 -0
- package/dist/daemon-avatar.js +130 -0
- package/dist/daemon-claude-code-auto-install.js +52 -0
- package/dist/daemon-client.js +363 -0
- package/dist/daemon-codex.js +349 -0
- package/dist/daemon-entry.js +101 -0
- package/dist/daemon-fetch-allowlist.js +194 -0
- package/dist/daemon-gemini.js +224 -0
- package/dist/daemon-mcp-clearly.js +353 -0
- package/dist/daemon-mcp-sandbox.js +127 -0
- package/dist/daemon-omni-company.js +338 -0
- package/dist/daemon-omni-hooks.js +375 -0
- package/dist/daemon-omni-identity.js +229 -0
- package/dist/daemon-omni-pipeline.js +251 -0
- package/dist/daemon-omni-policies.js +346 -0
- package/dist/daemon-omni-principal.js +580 -0
- package/dist/daemon-omni-tool-gate.js +116 -0
- package/dist/daemon-omni.js +1442 -0
- package/dist/daemon-path-transform.js +120 -0
- package/dist/daemon-write-sandbox.js +191 -0
- package/dist/daemon.js +4471 -0
- package/dist/dashboard.js +846 -0
- package/dist/dep-installer.js +286 -0
- package/dist/dev-mode.js +67 -0
- package/dist/doc-indexer.js +169 -0
- package/dist/e2e-crypto.js +94 -0
- package/dist/feature-flags.js +154 -0
- package/dist/file-browser.js +88 -0
- package/dist/first-party-hives.js +211 -0
- package/dist/folder-sync.js +226 -0
- package/dist/health.js +56 -0
- package/dist/ipc-auth.js +299 -0
- package/dist/ipc-client.js +495 -0
- package/dist/ipc-server.js +684 -0
- package/dist/keychain.js +224 -0
- package/dist/lib/admin-client.js +73 -0
- package/dist/lib/mobile-build-parser.js +156 -0
- package/dist/local-app-manager.js +660 -0
- package/dist/log-redaction.js +89 -0
- package/dist/logger.js +108 -0
- package/dist/manifest.js +143 -0
- package/dist/marketplace.js +793 -0
- package/dist/min-version-copy.js +51 -0
- package/dist/min-version.js +214 -0
- package/dist/public-surface.js +23 -0
- package/dist/relay-presets.js +89 -0
- package/dist/relay.js +2723 -0
- package/dist/repl.js +619 -0
- package/dist/setup-relay.js +363 -0
- package/dist/setup-service.js +536 -0
- package/dist/shared/bee-memory/index.js +6 -0
- package/dist/shared/bee-memory/types.js +682 -0
- package/dist/shared/claude-code/daemon-types.js +112 -0
- package/dist/shared/claude-code/index.js +2 -0
- package/dist/shared/claude-code/types.js +1 -0
- package/dist/shared/status/schema.js +133 -0
- package/dist/shell-session.js +198 -0
- package/dist/speak-lock.js +97 -0
- package/dist/spinner.js +57 -0
- package/dist/telemetry.js +166 -0
- package/dist/terminal/block-parser.js +207 -0
- package/dist/terminal/shell-integration.js +108 -0
- package/dist/transcript-tailer.js +215 -0
- package/dist/tts-qwen3.js +547 -0
- package/dist/types.js +4 -0
- package/dist/utm.js +38 -0
- package/dist/verb-surface.js +331 -0
- package/dist/version-utils.js +85 -0
- package/dist/watcher.js +207 -0
- package/package.json +88 -0
- package/python/qwen3_tts.py +230 -0
- package/scripts/stamp-build-channel.mjs +115 -0
- package/scripts/sync-installed.mjs +159 -0
- package/scripts/sync-skills.mjs +112 -0
- package/skills/README.md +24 -0
- package/skills/agent-doodle-space/SKILL.md +118 -0
- package/skills/agent-home/SKILL.md +98 -0
- package/skills/beehaven-cli/SKILL.md +144 -0
- package/skills/claude-shopper/README.md +90 -0
- package/skills/claude-shopper/SKILL.md +151 -0
- package/skills/clearly-agent/SKILL.md +183 -0
- package/skills/clearly-canvas/SKILL.md +222 -0
- package/skills/clearly-canvas/authoring.md +160 -0
- package/skills/clearly-canvas/communications.md +32 -0
- package/skills/clearly-canvas/composition-patterns.md +84 -0
- package/skills/clearly-canvas/example.mjs +78 -0
- package/skills/clearly-canvas/project-plans.md +156 -0
- package/skills/clearly-canvas/review.mjs +68 -0
- package/skills/clearly-workspace/SKILL.md +79 -0
- package/skills/clearly-workspace/survey.mjs +101 -0
- package/skills/codebase-map/SKILL.md +142 -0
- package/skills/design-craft/SKILL.md +300 -0
- package/skills/index.json +79 -0
- package/skills/layout-systems/SKILL.md +184 -0
- 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
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
|
+
}
|