dexterm-cli 0.1.1 → 0.1.3

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 (155) hide show
  1. package/dist/packages/agents/src/bus.js +57 -6
  2. package/dist/packages/agents/src/executor.js +25 -0
  3. package/dist/packages/agents/src/follow.js +105 -0
  4. package/dist/packages/agents/src/gate.js +3 -0
  5. package/dist/packages/agents/src/identity.js +41 -2
  6. package/dist/packages/agents/src/index.js +5 -0
  7. package/dist/packages/agents/src/spectator-server.js +153 -0
  8. package/dist/packages/agents/src/spectator.js +212 -0
  9. package/dist/packages/agents/src/tiers.js +5 -0
  10. package/dist/packages/agents/src/tools.js +3 -0
  11. package/dist/packages/cli/assets/claude/analyst-system.md +6 -1
  12. package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +11 -3
  13. package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +13 -3
  14. package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +3 -3
  15. package/dist/packages/cli/src/agent-cmd.js +107 -10
  16. package/dist/packages/cli/src/agent-spectator.js +37 -0
  17. package/dist/packages/cli/src/alerts/adapters/desktop.js +40 -0
  18. package/dist/packages/cli/src/alerts/adapters/discord.js +36 -0
  19. package/dist/packages/cli/src/alerts/adapters/telegram.js +43 -0
  20. package/dist/packages/cli/src/alerts/adapters/toast.js +16 -0
  21. package/dist/packages/cli/src/alerts/creds.js +83 -0
  22. package/dist/packages/cli/src/alerts/format.js +66 -0
  23. package/dist/packages/cli/src/alerts/index.js +10 -0
  24. package/dist/packages/cli/src/alerts/notifier.js +53 -0
  25. package/dist/packages/cli/src/alerts/route.js +19 -0
  26. package/dist/packages/cli/src/alerts/types.js +1 -0
  27. package/dist/packages/cli/src/alerts-cmd.js +212 -0
  28. package/dist/packages/cli/src/app-help-gen.js +365 -0
  29. package/dist/packages/cli/src/backtest-cmd.js +62 -3
  30. package/dist/packages/cli/src/coin-target.js +2 -0
  31. package/dist/packages/cli/src/doctor.js +21 -6
  32. package/dist/packages/cli/src/export.js +235 -0
  33. package/dist/packages/cli/src/follow-cmd.js +107 -1
  34. package/dist/packages/cli/src/help-cmd.js +48 -0
  35. package/dist/packages/cli/src/live/index.js +66 -3
  36. package/dist/packages/cli/src/live/screens/token-live.js +24 -0
  37. package/dist/packages/cli/src/live/signals.js +109 -0
  38. package/dist/packages/cli/src/live/trade.js +24 -1
  39. package/dist/packages/cli/src/main.js +596 -49
  40. package/dist/packages/cli/src/mcp-cmd.js +321 -0
  41. package/dist/packages/cli/src/pnl-app.js +221 -0
  42. package/dist/packages/cli/src/pnl-card.js +615 -0
  43. package/dist/packages/cli/src/record-cmd.js +455 -0
  44. package/dist/packages/cli/src/rules.js +69 -2
  45. package/dist/packages/cli/src/screens/follows.js +91 -2
  46. package/dist/packages/cli/src/screens/overlay.js +3 -0
  47. package/dist/packages/cli/src/screens/positions.js +1 -1
  48. package/dist/packages/cli/src/screens/rules.js +1 -1
  49. package/dist/packages/cli/src/skill-eval-build.js +109 -0
  50. package/dist/packages/cli/src/spectate-cmd.js +104 -0
  51. package/dist/packages/cli/src/state.js +1 -1
  52. package/dist/packages/cli/src/update.js +294 -0
  53. package/dist/packages/cli/src/version.js +3 -0
  54. package/dist/packages/cli/src/voice-cmd.js +194 -0
  55. package/dist/packages/cli/src/voice-hud.js +1 -1
  56. package/dist/packages/cli/src/watch-cmd.js +190 -0
  57. package/dist/packages/custom/src/rules.js +3 -2
  58. package/dist/packages/custom/src/schema.js +24 -1
  59. package/dist/packages/data/src/analyst-actions.js +130 -3
  60. package/dist/packages/data/src/coin-target.js +82 -0
  61. package/dist/packages/data/src/fast-lane.js +16 -2
  62. package/dist/packages/data/src/index.js +4 -0
  63. package/dist/packages/data/src/intel/helius-history.js +12 -1
  64. package/dist/packages/data/src/intel/live-signals.js +185 -0
  65. package/dist/packages/data/src/larp/link-baseline.js +196 -0
  66. package/dist/packages/data/src/sources/dexscreener.js +14 -0
  67. package/dist/packages/data/src/sources/pumpportal.js +6 -1
  68. package/dist/packages/data/src/trade/copy/index.js +1 -1
  69. package/dist/packages/data/src/trade/copy/stats.js +36 -2
  70. package/dist/packages/data/src/trade/copy/tracker.js +3 -2
  71. package/dist/packages/mcp/assets/kb/app-help/app-help-command.md +35 -0
  72. package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +43 -0
  73. package/dist/packages/mcp/assets/kb/app-help/backtest.md +33 -0
  74. package/dist/packages/mcp/assets/kb/app-help/bubble-map.md +35 -0
  75. package/dist/packages/mcp/assets/kb/app-help/calm-mode.md +31 -0
  76. package/dist/packages/mcp/assets/kb/app-help/change-voice-speed.md +31 -0
  77. package/dist/packages/mcp/assets/kb/app-help/change-voice.md +30 -0
  78. package/dist/packages/mcp/assets/kb/app-help/chart-popup-selftest.md +32 -0
  79. package/dist/packages/mcp/assets/kb/app-help/coin-images.md +29 -0
  80. package/dist/packages/mcp/assets/kb/app-help/configure-rpc-key.md +28 -0
  81. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent-registry.md +35 -0
  82. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +39 -0
  83. package/dist/packages/mcp/assets/kb/app-help/custom-keymap.md +32 -0
  84. package/dist/packages/mcp/assets/kb/app-help/edit-kb-or-skill.md +36 -0
  85. package/dist/packages/mcp/assets/kb/app-help/edit-rules.md +34 -0
  86. package/dist/packages/mcp/assets/kb/app-help/export-a-view.md +36 -0
  87. package/dist/packages/mcp/assets/kb/app-help/follow-a-wallet.md +33 -0
  88. package/dist/packages/mcp/assets/kb/app-help/getting-started.md +37 -0
  89. package/dist/packages/mcp/assets/kb/app-help/kol-lists.md +34 -0
  90. package/dist/packages/mcp/assets/kb/app-help/larp-check.md +36 -0
  91. package/dist/packages/mcp/assets/kb/app-help/link-a-wallet.md +41 -0
  92. package/dist/packages/mcp/assets/kb/app-help/login-with-x.md +33 -0
  93. package/dist/packages/mcp/assets/kb/app-help/narrative-board.md +32 -0
  94. package/dist/packages/mcp/assets/kb/app-help/open-a-coin.md +43 -0
  95. package/dist/packages/mcp/assets/kb/app-help/open-chart-popup.md +33 -0
  96. package/dist/packages/mcp/assets/kb/app-help/order-plans.md +33 -0
  97. package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +36 -0
  98. package/dist/packages/mcp/assets/kb/app-help/price-or-marketcap.md +38 -0
  99. package/dist/packages/mcp/assets/kb/app-help/record-headless.md +34 -0
  100. package/dist/packages/mcp/assets/kb/app-help/recorder-history.md +39 -0
  101. package/dist/packages/mcp/assets/kb/app-help/rug-check.md +39 -0
  102. package/dist/packages/mcp/assets/kb/app-help/run-doctor.md +32 -0
  103. package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +37 -0
  104. package/dist/packages/mcp/assets/kb/app-help/screen-analyst.md +181 -0
  105. package/dist/packages/mcp/assets/kb/app-help/screen-kol.md +190 -0
  106. package/dist/packages/mcp/assets/kb/app-help/screen-newpairs.md +181 -0
  107. package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +180 -0
  108. package/dist/packages/mcp/assets/kb/app-help/screen-screener.md +199 -0
  109. package/dist/packages/mcp/assets/kb/app-help/screen-token.md +240 -0
  110. package/dist/packages/mcp/assets/kb/app-help/screen-trending.md +192 -0
  111. package/dist/packages/mcp/assets/kb/app-help/screen-watchlist.md +181 -0
  112. package/dist/packages/mcp/assets/kb/app-help/set-a-filter.md +36 -0
  113. package/dist/packages/mcp/assets/kb/app-help/setup-neural-voice.md +36 -0
  114. package/dist/packages/mcp/assets/kb/app-help/share-packs.md +34 -0
  115. package/dist/packages/mcp/assets/kb/app-help/sort-the-screener.md +34 -0
  116. package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +36 -0
  117. package/dist/packages/mcp/assets/kb/app-help/standing-mandates.md +33 -0
  118. package/dist/packages/mcp/assets/kb/app-help/switch-theme.md +42 -0
  119. package/dist/packages/mcp/assets/kb/app-help/turn-on-alerts.md +37 -0
  120. package/dist/packages/mcp/assets/kb/app-help/update-dexterm.md +30 -0
  121. package/dist/packages/mcp/assets/kb/app-help/use-presets.md +42 -0
  122. package/dist/packages/mcp/assets/kb/app-help/use-voice-mode.md +36 -0
  123. package/dist/packages/mcp/assets/kb/app-help/watch-a-coin.md +34 -0
  124. package/dist/packages/mcp/assets/kb/app-help/watch-headless.md +35 -0
  125. package/dist/packages/mcp/assets/kb/app-help/what-dexterm-shows-agents.md +31 -0
  126. package/dist/packages/mcp/assets/kb/app-help/workspaces.md +38 -0
  127. package/dist/packages/mcp/src/app-help.js +135 -0
  128. package/dist/packages/mcp/src/index.js +3 -0
  129. package/dist/packages/mcp/src/server.js +179 -5
  130. package/dist/packages/mcp/src/spectator-sink.js +72 -0
  131. package/dist/packages/packs/src/index.js +3 -0
  132. package/dist/packages/packs/src/skill-backtest.js +300 -0
  133. package/dist/packages/packs/src/skill-care.js +206 -0
  134. package/dist/packages/popup/src/pages/spectator.css +45 -0
  135. package/dist/packages/popup/src/pages/spectator.html +25 -0
  136. package/dist/packages/popup/src/pages/spectator.js +74 -0
  137. package/dist/packages/popup/src/selftest.js +19 -0
  138. package/dist/packages/popup/src/server.js +7 -1
  139. package/dist/packages/tui/src/index.js +3 -0
  140. package/dist/packages/tui/src/keymap.js +12 -0
  141. package/dist/packages/tui/src/raster-font.js +195 -0
  142. package/dist/packages/tui/src/raster.js +213 -0
  143. package/dist/packages/voice/src/controller.js +57 -4
  144. package/dist/packages/voice/src/doctor.js +96 -11
  145. package/dist/packages/voice/src/download.js +184 -0
  146. package/dist/packages/voice/src/engines.js +55 -8
  147. package/dist/packages/voice/src/index.js +9 -3
  148. package/dist/packages/voice/src/speakable.js +111 -0
  149. package/dist/packages/voice/src/stt/whisper.js +14 -4
  150. package/dist/packages/voice/src/tts/espeak.js +131 -0
  151. package/dist/packages/voice/src/tts/piper-setup.js +229 -0
  152. package/dist/packages/voice/src/tts/piper.js +449 -0
  153. package/dist/packages/voice/src/tts/say.js +72 -35
  154. package/dist/packages/voice/src/tts/voices.js +209 -0
  155. package/package.json +5 -9
@@ -5,6 +5,13 @@
5
5
  // listen() is called — so the on-screen indicator and the real mic state can never disagree.
6
6
  import { route, isSafe } from "./router.js";
7
7
  import { spokenSummary } from "./summary.js";
8
+ import { speakable } from "./speakable.js";
9
+ import { isBannedVoice } from "./tts/voices.js";
10
+ import { DEFAULT_RATE } from "./tts/say.js";
11
+ /** Live rate-nudge bounds and step (task-1411, the < / > keys). Clamped so a voice stays intelligible. */
12
+ export const RATE_MIN = 120;
13
+ export const RATE_MAX = 320;
14
+ export const RATE_STEP = 15;
8
15
  export class VoiceController {
9
16
  stt;
10
17
  tts;
@@ -15,6 +22,7 @@ export class VoiceController {
15
22
  muted;
16
23
  voice;
17
24
  rate;
25
+ pitch;
18
26
  voices = [];
19
27
  lastTranscript = null;
20
28
  lastSay = null;
@@ -42,7 +50,8 @@ export class VoiceController {
42
50
  this.tts = opts.tts;
43
51
  this.muted = opts.muted ?? false;
44
52
  this.voice = opts.voice ?? null;
45
- this.rate = opts.rate ?? 190;
53
+ this.rate = opts.rate ?? DEFAULT_RATE;
54
+ this.pitch = opts.pitch;
46
55
  this.now = opts.now ?? (() => Date.now());
47
56
  this.maxListenMs = opts.maxListenMs ?? 15_000;
48
57
  this.setTimer = opts.setTimer ?? ((fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; });
@@ -71,12 +80,16 @@ export class VoiceController {
71
80
  get isListening() { return this.mode === 'listening'; }
72
81
  /** The mic is live OR about to be (listen() in flight): the states a key must cancel. */
73
82
  get micEngaged() { return this.mode === 'listening' || this.starting; }
74
- /** Load the voice list lazily (used by cycleVoice and the status line). */
83
+ /**
84
+ * Load the voice list lazily (used by cycleVoice and the status line). Novelty voices on the ban-list
85
+ * are filtered OUT so the user can never cycle into "Zarvox" / "Bad News" and so a null starting voice
86
+ * is seeded to a safe one, never the machine's (possibly novelty) system voice (task-1393).
87
+ */
75
88
  async loadVoices() {
76
89
  if (!this.tts)
77
90
  return;
78
91
  try {
79
- this.voices = await this.tts.listVoices();
92
+ this.voices = (await this.tts.listVoices()).filter((n) => !isBannedVoice(n));
80
93
  if (!this.voice && this.voices.length)
81
94
  this.voice = this.voices[0];
82
95
  }
@@ -92,6 +105,12 @@ export class VoiceController {
92
105
  this.message = this.stt ? null : 'voice-in unavailable on this machine — type questions; replies are still spoken';
93
106
  this.setMode('idle');
94
107
  void this.loadVoices();
108
+ // task-1416: pre-load the TTS engine (Piper loads its model into a standby process) so the first
109
+ // reply starts speaking quickly. A no-op for engines that stream from spawn (`say`).
110
+ try {
111
+ this.tts?.warmup?.(this.rate);
112
+ }
113
+ catch { /* warm-up is best-effort; never break enabling voice */ }
95
114
  }
96
115
  disable() {
97
116
  if (!this.enabled)
@@ -100,6 +119,10 @@ export class VoiceController {
100
119
  this.stopSpeaking();
101
120
  this.enabled = false;
102
121
  this.pendingLatency = null;
122
+ try {
123
+ this.tts?.dispose?.();
124
+ }
125
+ catch { /* best-effort cleanup */ }
103
126
  this.setMode('off');
104
127
  }
105
128
  toggle() { this.enabled ? this.disable() : this.enable(); }
@@ -272,6 +295,9 @@ export class VoiceController {
272
295
  }
273
296
  /** Low-level speak: stops any current reply, then reads `text`. Muted → skip audio but update state. */
274
297
  speak(text) {
298
+ // Speak like a trader (task-1411): no digit-by-digit tiny prices, big numbers in human words,
299
+ // natural percents. Applied to EVERY spoken line so an ack or an analyst answer never reads raw.
300
+ text = speakable(text);
275
301
  this.lastSay = text;
276
302
  if (!this.tts || this.muted) {
277
303
  // Still resolve the latency sample (the "spoken word" is just suppressed) so the table is complete.
@@ -285,6 +311,7 @@ export class VoiceController {
285
311
  const handle = this.tts.speak(text, {
286
312
  voice: this.voice ?? undefined,
287
313
  rate: this.rate,
314
+ pitch: this.pitch,
288
315
  onFirstAudio: () => this.recordFirstAudio(),
289
316
  });
290
317
  this.speaking = handle;
@@ -362,7 +389,7 @@ export class VoiceController {
362
389
  this.stopSpeaking(); this.notify(); }
363
390
  toggleMute() { this.setMuted(!this.muted); }
364
391
  get isMuted() { return this.muted; }
365
- /** Cycle to the next installed voice (macOS voices). No-op when there are none. */
392
+ /** Cycle to the next installed (safe) voice and persist the choice. No-op when there are none. */
366
393
  cycleVoice() {
367
394
  if (!this.voices.length) {
368
395
  this.notify();
@@ -370,10 +397,36 @@ export class VoiceController {
370
397
  }
371
398
  const i = this.voice ? this.voices.indexOf(this.voice) : -1;
372
399
  this.voice = this.voices[(i + 1) % this.voices.length];
400
+ try {
401
+ this.opts.onVoiceChange?.(this.voice);
402
+ }
403
+ catch { /* persistence is best-effort; never break the UI */ }
373
404
  this.notify();
374
405
  }
375
406
  currentVoice() { return this.voice; }
376
407
  availableVoices() { return this.voices.slice(); }
408
+ /** The current speaking rate (words-per-minute-ish), for the status line / help. */
409
+ currentRate() { return this.rate; }
410
+ /**
411
+ * Nudge the speaking rate by `steps` × RATE_STEP (— for slower, + for faster), clamp it, persist it,
412
+ * and surface it on the status line (task-1411: the < / > keys). Takes effect on the next reply; it
413
+ * does not restart a line already in flight. A no-op when the rate is already at the clamp.
414
+ */
415
+ nudgeRate(steps) {
416
+ const next = Math.max(RATE_MIN, Math.min(RATE_MAX, this.rate + steps * RATE_STEP));
417
+ if (next === this.rate) {
418
+ this.message = `voice rate ${this.rate} wpm (${next <= RATE_MIN ? 'slowest' : 'fastest'})`;
419
+ this.notify();
420
+ return;
421
+ }
422
+ this.rate = next;
423
+ this.message = `voice rate ${this.rate} wpm`;
424
+ try {
425
+ this.opts.onRateChange?.(this.rate);
426
+ }
427
+ catch { /* persistence is best-effort; never break the UI */ }
428
+ this.notify();
429
+ }
377
430
  /** A compact latency report for the handoff / `--latency` demo. */
378
431
  report() {
379
432
  const paths = ['instant', 'analyst'];
@@ -7,22 +7,107 @@
7
7
  export function voiceDoctor(deps) {
8
8
  const checks = [];
9
9
  if (deps.platform !== 'darwin') {
10
+ const win = deps.platform === 'win32';
11
+ // Speech OUT off macOS: Piper (neural, preferred) → espeak-ng (baseline) → warn (task-1408).
12
+ // NOTE: `apt install piper` on Debian is an UNRELATED gaming-mouse tool — never suggest it; the
13
+ // Piper TTS binary comes from the rhasspy/piper prebuilt release, the voice from `dexterm voice download`.
14
+ const piperRelease = 'github.com/rhasspy/piper/releases';
15
+ const whisperRelease = 'github.com/ggml-org/whisper.cpp/releases';
16
+ const piperReady = !!deps.piperPresent && !!deps.piperVoicePresent && !!deps.playerName;
17
+ // What Piper is still missing (for an upgrade hint / the no-voice fix).
18
+ const piperBits = [];
19
+ if (!deps.piperPresent)
20
+ piperBits.push(`the Piper binary (prebuilt from ${piperRelease} into \`~/.dexterm/piper/\`, NOT \`apt install piper\`)`);
21
+ if (!deps.piperVoicePresent)
22
+ piperBits.push('the voice model (`dexterm voice download piper-voice` — asks first, checksum-verified)');
23
+ if (!deps.playerName)
24
+ piperBits.push(win ? 'an audio player (ffplay from ffmpeg)' : 'an audio player (`sudo apt install alsa-utils` for aplay, or pulseaudio-utils for paplay)');
25
+ if (piperReady) {
26
+ checks.push({ name: 'Voice out (Piper)', status: 'pass', detail: `Piper neural voice ready — en_GB-alan-medium, plays via ${deps.playerName} (local, no key)` });
27
+ }
28
+ else if (deps.espeakPresent) {
29
+ checks.push({
30
+ name: 'Voice out (espeak-ng)', status: 'pass',
31
+ detail: `espeak-ng ready — British male default (local, no key). For a higher-quality neural voice, add Piper: ${piperBits.join('; ')}`,
32
+ });
33
+ }
34
+ else {
35
+ checks.push({
36
+ name: 'Voice out', status: 'warn',
37
+ detail: `spoken replies are off on ${deps.platform} until a speech engine is installed; the analyst still answers in text`,
38
+ fix: win
39
+ ? 'Install espeak-ng (`choco install espeak` or the espeak-ng release). For a neural voice, add Piper (prebuilt from '
40
+ + `${piperRelease}) + \`dexterm voice download piper-voice\` + ffplay.`
41
+ : 'Fastest: `sudo apt install espeak-ng`. Better (neural): install the Piper prebuilt binary from '
42
+ + `${piperRelease} into \`~/.dexterm/piper/\`, run \`dexterm voice download piper-voice\`, and \`sudo apt install alsa-utils\` for a player. `
43
+ + 'Do NOT `apt install piper` — on Debian that is an unrelated mouse tool.',
44
+ });
45
+ }
46
+ // Speech IN — whisper.cpp (the default off macOS). Not in Debian: use the prebuilt release.
47
+ if (deps.resolvedStt === 'whisper' || (deps.whisperBinPresent && deps.whisperModelPresent && deps.micToolPresent)) {
48
+ checks.push({ name: 'Voice in (whisper)', status: 'pass', detail: 'whisper.cpp base.en + a mic tool (local, no key)' });
49
+ }
50
+ else {
51
+ const bits = [];
52
+ if (!deps.whisperBinPresent)
53
+ bits.push('no whisper-cli');
54
+ else if (!deps.whisperModelPresent)
55
+ bits.push('no base.en model');
56
+ if (!deps.micToolPresent)
57
+ bits.push('no mic tool (arecord/sox/ffmpeg)');
58
+ checks.push({
59
+ name: 'Voice in', status: 'warn',
60
+ detail: `speech-to-text is off (${bits.join(', ') || 'whisper not set up'}); you can still type questions — voice degrades to text`,
61
+ fix: `Install whisper.cpp (not in Debian — use the prebuilt release from ${whisperRelease}) + a mic tool `
62
+ + '(`sudo apt install alsa-utils` for arecord, or sox), then `dexterm voice download whisper` (asks first, checksum-verified).',
63
+ });
64
+ }
65
+ if (win) {
66
+ checks.push({
67
+ name: 'Windows voice', status: 'warn',
68
+ detail: 'espeak-ng (or Piper) is the supported Windows speech-out path; native SAPI is not wired in this build',
69
+ fix: 'Use espeak-ng or Piper on Windows for now. (SAPI support is a later enhancement.)',
70
+ });
71
+ }
72
+ return checks;
73
+ }
74
+ // Speech OUT — Piper neural (the default when set up, task-1416) → macOS `say` (fallback) → text.
75
+ const macPiperReady = !!deps.piperPresent && !!deps.piperVoicePresent && !!deps.playerName;
76
+ if (macPiperReady) {
10
77
  checks.push({
11
- name: 'Voice mode', status: 'warn',
12
- detail: `voice mode is macOS-only for now (speech uses the built-in \`say\` and Apple Speech); on ${deps.platform} it stays off and dexterm runs as text`,
13
- fix: 'Use dexterm on macOS for voice, or just use the keyboard — every voice action has a key.',
78
+ name: 'Voice out (Piper)', status: 'pass',
79
+ detail: `neural British voice is the default — en_GB-alan-medium, plays via ${deps.playerName} (local, no key); fallback: macOS \`say\``,
14
80
  });
15
- return checks;
16
81
  }
17
- // Speech OUT — macOS say.
18
82
  if (deps.sayAvailable) {
19
- checks.push({ name: 'Voice out (say)', status: 'pass', detail: `macOS \`say\` ready · ${deps.voiceCount} voice${deps.voiceCount === 1 ? '' : 's'} (press the voice key, then cycle the voice)` });
83
+ const role = macPiperReady ? 'the fallback if Piper is removed' : 'the default — press the voice key, then cycle the voice';
84
+ checks.push({ name: macPiperReady ? 'Voice out (say, fallback)' : 'Voice out (say)', status: 'pass', detail: `macOS \`say\` ready · ${deps.voiceCount} voice${deps.voiceCount === 1 ? '' : 's'} · ${role}` });
85
+ // Which `say` voice speaks, and why — so a machine can never silently use a novelty/odd system voice (task-1393).
86
+ const voiceLabel = macPiperReady ? 'Fallback voice' : 'Default voice';
87
+ if (deps.resolvedVoice) {
88
+ checks.push({ name: voiceLabel, status: 'pass', detail: `${deps.resolvedVoiceReason ?? deps.resolvedVoice} · , changes it` });
89
+ }
90
+ else if (deps.resolvedVoice === null) {
91
+ checks.push({
92
+ name: voiceLabel, status: 'warn',
93
+ detail: deps.resolvedVoiceReason ?? 'no safe voice installed — spoken replies are off (text still works)',
94
+ fix: 'Install a British male voice in System Settings → Accessibility → Spoken Content → System Voice → Manage Voices (Daniel, English (UK)); the Enhanced/Premium download sounds best.',
95
+ });
96
+ }
97
+ // Offer the higher-quality neural voice when it is not set up yet (never silent; the user decides).
98
+ if (!macPiperReady) {
99
+ checks.push({
100
+ name: 'Neural voice (Piper)', status: 'warn',
101
+ detail: 'a higher-quality neural British voice is available but not set up; `say` is speaking for now',
102
+ fix: 'Run `dexterm voice setup` to install it (asks first; a private Python runtime + a ~61 MB checksum-verified model; runs offline after, `dexterm voice uninstall` removes it).',
103
+ });
104
+ }
20
105
  }
21
- else {
106
+ else if (!macPiperReady) {
22
107
  checks.push({
23
- name: 'Voice out (say)', status: 'warn',
108
+ name: 'Voice out', status: 'warn',
24
109
  detail: 'macOS `say` not found; spoken replies are off, the analyst still answers in text',
25
- fix: '`say` ships with macOS — if it is missing, reinstall the Command Line Tools (`xcode-select --install`).',
110
+ fix: '`say` ships with macOS — if it is missing, reinstall the Command Line Tools (`xcode-select --install`). Or set up the neural voice: `dexterm voice setup`.',
26
111
  });
27
112
  }
28
113
  // Speech IN — Apple Speech (preferred) or whisper (fallback).
@@ -47,8 +132,8 @@ export function voiceDoctor(deps) {
47
132
  name: 'Voice in', status: 'warn',
48
133
  detail: `speech-to-text is off (${bits.join(', ')}); you can still type questions — voice degrades to text`,
49
134
  fix: 'Fastest free path: install Xcode command line tools for Apple Speech (`xcode-select --install`). '
50
- + 'Or whisper: `brew install whisper-cpp sox`, then download base.en to '
51
- + '`~/.dexterm/models/ggml-base.en.bin` (from the whisper.cpp model repo). Never use large-v3-turbo on a CPU-only Mac — it is unusably slow.',
135
+ + 'Or whisper: `brew install whisper-cpp sox`, then `dexterm voice download whisper` (asks first, checksum-verified, writes '
136
+ + '`~/.dexterm/models/ggml-base.en.bin`). Never use large-v3-turbo on a CPU-only Mac — it is unusably slow.',
52
137
  });
53
138
  }
54
139
  // Mic-capture tool note (only relevant to the whisper path; Apple Speech captures its own audio).
@@ -0,0 +1,184 @@
1
+ // Voice model downloader (task-1408). The whisper.cpp base.en model and the Piper en_GB-alan-medium
2
+ // voice are NOT shipped (free-forever rule: no bundled large binaries, no keys). dexterm downloads them
3
+ // on request, and ONLY on request:
4
+ // - from the OFFICIAL source (the ggml-org / rhasspy repositories on Hugging Face),
5
+ // - after EXPLICIT user confirmation (never a silent auto-download),
6
+ // - and verified against a checksum pinned here from the upstream LFS pointer, so a corrupted or
7
+ // tampered download is rejected and never written into place.
8
+ //
9
+ // This module is pure logic + injected fetch/confirm/log, so it is unit-testable with a fake registry
10
+ // and never touches the real network in tests. The CLI (`dexterm voice download …`) wires the real
11
+ // global fetch, a readline y/N prompt, and stdout.
12
+ import { createHash } from 'node:crypto';
13
+ import { createWriteStream } from 'node:fs';
14
+ import { mkdir, rename, rm, readFile } from 'node:fs/promises';
15
+ import { existsSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { dirname, join } from 'node:path';
18
+ import { Readable } from 'node:stream';
19
+ import { once } from 'node:events';
20
+ /** Total download size of a spec, in whole MB (for the prompt). */
21
+ export function specSizeMB(spec) {
22
+ return Math.round(spec.files.reduce((n, f) => n + (f.size ?? 0), 0) / (1024 * 1024));
23
+ }
24
+ /**
25
+ * The downloadable voice models, keyed by id. `home` is the dexterm home (~/.dexterm) so tests can
26
+ * point it at a temp dir. Checksums are pinned from the upstream Hugging Face LFS pointers (verified
27
+ * 2026-10-16): ggerganov/whisper.cpp and rhasspy/piper-voices@v1.0.0.
28
+ */
29
+ export function modelRegistry(home = join(homedir(), '.dexterm')) {
30
+ const models = join(home, 'models');
31
+ return {
32
+ whisper: {
33
+ id: 'whisper',
34
+ label: 'whisper.cpp base.en (speech-to-text)',
35
+ note: 'whisper.cpp base.en — on-device speech recognition, ~141 MB, MIT (ggml-org/whisper.cpp)',
36
+ files: [{
37
+ url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin',
38
+ dest: join(models, 'ggml-base.en.bin'),
39
+ sha256: 'a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002',
40
+ size: 147964211,
41
+ }],
42
+ },
43
+ 'piper-voice': {
44
+ id: 'piper-voice',
45
+ label: 'Piper en_GB-alan-medium (neural voice)',
46
+ note: 'Piper en_GB-alan-medium — British male neural voice, ~61 MB, MIT (rhasspy/piper-voices)',
47
+ files: [
48
+ {
49
+ url: 'https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_GB/alan/medium/en_GB-alan-medium.onnx',
50
+ dest: join(models, 'en_GB-alan-medium.onnx'),
51
+ sha256: '0a309668932205e762801f1efc2736cd4b0120329622adf62be09e56339d3330',
52
+ size: 63201294,
53
+ },
54
+ {
55
+ url: 'https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_GB/alan/medium/en_GB-alan-medium.onnx.json',
56
+ dest: join(models, 'en_GB-alan-medium.onnx.json'),
57
+ validateJson: true,
58
+ },
59
+ ],
60
+ },
61
+ };
62
+ }
63
+ /** sha256 of an existing file, or null if it cannot be read. */
64
+ async function fileSha256(path) {
65
+ try {
66
+ const h = createHash('sha256');
67
+ h.update(await readFile(path));
68
+ return h.digest('hex');
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ /** True when `file` is already present AND valid (checksum match, or parses as JSON for config files). */
75
+ async function isPresentAndValid(file) {
76
+ if (!existsSync(file.dest))
77
+ return false;
78
+ if (file.validateJson) {
79
+ try {
80
+ JSON.parse(await readFile(file.dest, 'utf8'));
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ if (file.sha256)
88
+ return (await fileSha256(file.dest)) === file.sha256.toLowerCase();
89
+ return true;
90
+ }
91
+ /**
92
+ * Download and verify one file. Streams to a `.part` temp alongside the destination (bounded memory),
93
+ * verifies size + sha256 (or JSON), then atomically renames into place. On ANY failure the temp is
94
+ * removed and nothing is written to the destination.
95
+ */
96
+ async function downloadFile(file, fetchImpl, log) {
97
+ await mkdir(dirname(file.dest), { recursive: true });
98
+ const tmp = `${file.dest}.part`;
99
+ await rm(tmp, { force: true });
100
+ log(` ↓ ${file.url}`);
101
+ let res;
102
+ try {
103
+ res = await fetchImpl(file.url, { redirect: 'follow' });
104
+ }
105
+ catch (e) {
106
+ throw new Error(`download failed (${file.url}): ${e.message}`);
107
+ }
108
+ if (!res.ok || !res.body)
109
+ throw new Error(`download failed (${file.url}): HTTP ${res.status}`);
110
+ const hash = createHash('sha256');
111
+ const out = createWriteStream(tmp);
112
+ let bytes = 0;
113
+ try {
114
+ for await (const chunk of Readable.fromWeb(res.body)) {
115
+ const buf = chunk;
116
+ bytes += buf.length;
117
+ hash.update(buf);
118
+ if (!out.write(buf))
119
+ await once(out, 'drain');
120
+ }
121
+ out.end();
122
+ await once(out, 'finish');
123
+ }
124
+ catch (e) {
125
+ out.destroy();
126
+ await rm(tmp, { force: true });
127
+ throw new Error(`download failed (${file.url}): ${e.message}`);
128
+ }
129
+ // Verify before anything lands at the real path.
130
+ try {
131
+ if (file.size !== undefined && bytes !== file.size) {
132
+ throw new Error(`size mismatch: got ${bytes} bytes, expected ${file.size}`);
133
+ }
134
+ if (file.sha256) {
135
+ const got = hash.digest('hex');
136
+ if (got !== file.sha256.toLowerCase())
137
+ throw new Error(`checksum mismatch: got ${got}, expected ${file.sha256}`);
138
+ }
139
+ else if (file.validateJson) {
140
+ JSON.parse(await readFile(tmp, 'utf8'));
141
+ }
142
+ }
143
+ catch (e) {
144
+ await rm(tmp, { force: true });
145
+ throw new Error(`verification failed (${file.url}): ${e.message}`);
146
+ }
147
+ await rename(tmp, file.dest);
148
+ const mb = (bytes / (1024 * 1024)).toFixed(1);
149
+ log(` ✓ ${file.dest} (${mb} MB${file.sha256 ? ', sha256 ok' : ', json ok'})`);
150
+ }
151
+ /**
152
+ * Download a model after explicit confirmation. If every file is already present and valid, returns
153
+ * `already` WITHOUT prompting or touching the network. Otherwise it builds a one-prompt summary of what
154
+ * will be fetched and where, calls `confirm()`, and only then downloads + verifies each missing file.
155
+ */
156
+ export async function downloadModel(spec, deps) {
157
+ const fetchImpl = deps.fetchImpl ?? fetch;
158
+ const log = deps.log ?? (() => { });
159
+ const presence = await Promise.all(spec.files.map(isPresentAndValid));
160
+ const missing = spec.files.filter((_, i) => !presence[i]);
161
+ if (missing.length === 0) {
162
+ log(`${spec.label} is already installed and verified.`);
163
+ return { status: 'already', files: spec.files.map((f) => f.dest) };
164
+ }
165
+ const dir = dirname(spec.files[0].dest);
166
+ const prompt = [
167
+ `Download ${spec.note}`,
168
+ ` from the official source to ${dir}`,
169
+ ` ${missing.length} file${missing.length === 1 ? '' : 's'}, ~${specSizeMB(spec)} MB, checksum-verified. Proceed?`,
170
+ ].join('\n');
171
+ if (!(await deps.confirm(prompt))) {
172
+ log('Cancelled — nothing downloaded.');
173
+ return { status: 'cancelled', files: spec.files.filter((_, i) => presence[i]).map((f) => f.dest) };
174
+ }
175
+ for (const f of missing)
176
+ await downloadFile(f, fetchImpl, log);
177
+ log(`${spec.label} installed.`);
178
+ return { status: 'ok', files: spec.files.map((f) => f.dest) };
179
+ }
180
+ /** Which model files exist and verify right now (for a status report; never touches the network). */
181
+ export async function modelStatus(spec) {
182
+ const files = await Promise.all(spec.files.map(async (f) => ({ dest: f.dest, ok: await isPresentAndValid(f) })));
183
+ return { installed: files.every((f) => f.ok), files };
184
+ }
@@ -5,7 +5,10 @@ import { AppleStt } from "./stt/apple.js";
5
5
  import { WhisperStt } from "./stt/whisper.js";
6
6
  import { FakeStt } from "./stt/fake.js";
7
7
  import { SayTts } from "./tts/say.js";
8
+ import { EspeakTts, espeakVoiceFor } from "./tts/espeak.js";
9
+ import { PiperTts } from "./tts/piper.js";
8
10
  import { FakeTts } from "./tts/fake.js";
11
+ import { resolveVoice } from "./tts/voices.js";
9
12
  /**
10
13
  * Resolve the STT engine. `auto` prefers Apple Speech (on-device, streaming), then whisper base.en,
11
14
  * then none. An explicit choice is returned even if it later proves unavailable, so the doctor and the
@@ -29,16 +32,60 @@ export async function selectStt(choice = process.env.DEXTERM_VOICE_STT || 'auto'
29
32
  return whisper;
30
33
  return null;
31
34
  }
32
- /** Resolve the TTS engine. `auto` uses macOS `say` when present, else none (voice replies off). */
33
- export async function selectTts(choice = process.env.DEXTERM_VOICE_TTS || 'auto', defaultVoice) {
35
+ /**
36
+ * Resolve the default `say` voice for this machine: the user's configured voice if it is installed and
37
+ * safe, else a deep British male (Daniel first), never a novelty voice and never the OS system default.
38
+ * Returns the full resolution (voice + why) so the controller and `dexterm doctor` can show it.
39
+ */
40
+ export async function resolveSayVoice(say, configured) {
41
+ const installed = await say.listVoicesDetailed();
42
+ return resolveVoice({ configured, installed });
43
+ }
44
+ /**
45
+ * Resolve the TTS engine. `auto` uses macOS `say` when present, else none (voice replies off). When
46
+ * `say` is chosen, its default voice is resolved to an explicit deep British male so it can NEVER fall
47
+ * back to the machine's system voice (task-1393). `configuredVoice` is the user's `config.voice.name`.
48
+ */
49
+ export async function selectTts(choice = process.env.DEXTERM_VOICE_TTS || 'auto', configuredVoice) {
34
50
  if (choice === 'none')
35
51
  return null;
36
52
  if (choice === 'fake')
37
53
  return new FakeTts();
38
- if (choice === 'say')
39
- return new SayTts(defaultVoice);
40
- const say = new SayTts(defaultVoice);
41
- if (await say.available())
42
- return say;
43
- return null;
54
+ // Piper and espeak are the off-macOS engines. A macOS voice name (config.voice.name is e.g.
55
+ // "Eddy (English (UK))") is NEVER passed to them verbatim: Piper's voice is its model, and espeak
56
+ // only honours a real espeak voice token (espeakVoiceFor drops a Mac name → the British default).
57
+ if (choice === 'piper') {
58
+ const p = new PiperTts();
59
+ return (await p.available()) ? p : null;
60
+ }
61
+ if (choice === 'espeak') {
62
+ const e = new EspeakTts(espeakVoiceFor(configuredVoice) ?? undefined);
63
+ return (await e.available()) ? e : null;
64
+ }
65
+ // On macOS, prefer Piper alan (neural) when the managed runtime is installed (task-1416:
66
+ // `dexterm voice setup` → venv + model + afplay), else the built-in `say`. Off macOS, `say` does not
67
+ // exist — use the portable engines below. An explicit `choice==='say'` always skips Piper.
68
+ if (choice === 'say' || (choice === 'auto' && process.platform === 'darwin')) {
69
+ if (choice === 'auto') {
70
+ const piperMac = new PiperTts();
71
+ if (await piperMac.available())
72
+ return piperMac;
73
+ }
74
+ const probe = new SayTts();
75
+ if (choice === 'auto' && !(await probe.available()))
76
+ return null;
77
+ const r = await resolveSayVoice(probe, configuredVoice);
78
+ // No safe voice installed (only novelty voices): voice-out stays OFF so `say` can never be invoked
79
+ // without an explicit voice and fall back to the machine's system voice (task-1393). Text still works.
80
+ if (r.voice === null)
81
+ return null;
82
+ return new SayTts(r.voice);
83
+ }
84
+ // auto, non-darwin: Piper (neural, higher quality) when it can speak aloud, else espeak-ng, else
85
+ // text-only. Neither engine is ever handed a macOS voice name (task-1408).
86
+ const piper = new PiperTts();
87
+ if (await piper.available())
88
+ return piper;
89
+ const espeak = new EspeakTts(espeakVoiceFor(configuredVoice) ?? undefined);
90
+ return (await espeak.available()) ? espeak : null;
44
91
  }
@@ -6,11 +6,17 @@
6
6
  export * from "./types.js";
7
7
  export { route, isSafe } from "./router.js";
8
8
  export { spokenSummary, splitSentences } from "./summary.js";
9
+ export { speakable, humanizeNumber } from "./speakable.js";
9
10
  export { VoiceController } from "./controller.js";
10
- export { selectStt, selectTts } from "./engines.js";
11
+ export { selectStt, selectTts, resolveSayVoice } from "./engines.js";
11
12
  export { voiceDoctor } from "./doctor.js";
12
- export { SayTts } from "./tts/say.js";
13
+ export { resolveVoice, isBannedVoice, normalizeVoiceName, parseSayVoices, sanitizeSpeech, BANNED_VOICE_NAMES, BRITISH_MALE_PREFERENCE, MAX_SPEAK_CHARS, VOICE_STYLE_DEFAULTS, voiceStyleDefaults, } from "./tts/voices.js";
14
+ export { SayTts, DEFAULT_RATE } from "./tts/say.js";
15
+ export { EspeakTts, DEFAULT_ESPEAK_VOICE, resolveEspeakBin, espeakVoiceFor } from "./tts/espeak.js";
16
+ export { PiperTts, DEFAULT_PIPER_VOICE, PIPER_MODEL, PIPER_PLAYERS, resolvePiperBin, resolvePiperRuntime, managedVenvPython, pickPlayer, } from "./tts/piper.js";
17
+ export { setupPiperRuntime, uninstallPiperRuntime, piperRuntimeStatus, piperRuntimePaths, readRuntimeRecord, detectPython, parsePythonVersion, pythonVersionOk, pythonFixLine, PIPER_PYPI_VERSION, MIN_PYTHON, DEFAULT_PYTHON_CANDIDATES, } from "./tts/piper-setup.js";
13
18
  export { FakeTts } from "./tts/fake.js";
14
- export { WhisperStt, WHISPER_MODEL, WHISPER_MODEL_DIR } from "./stt/whisper.js";
19
+ export { WhisperStt, WHISPER_MODEL, WHISPER_MODEL_DIR, pickRecorder } from "./stt/whisper.js";
20
+ export { modelRegistry, downloadModel, modelStatus, specSizeMB, } from "./download.js";
15
21
  export { AppleStt, ensureHelper } from "./stt/apple.js";
16
22
  export { FakeStt } from "./stt/fake.js";