dexterm-cli 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/packages/agents/src/bus.js +20 -5
  2. package/dist/packages/agents/src/executor.js +25 -0
  3. package/dist/packages/agents/src/gate.js +3 -0
  4. package/dist/packages/agents/src/identity.js +41 -2
  5. package/dist/packages/agents/src/tiers.js +5 -0
  6. package/dist/packages/agents/src/tools.js +3 -0
  7. package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +11 -3
  8. package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +13 -3
  9. package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +3 -3
  10. package/dist/packages/cli/src/agent-cmd.js +36 -3
  11. package/dist/packages/cli/src/alerts/adapters/desktop.js +40 -0
  12. package/dist/packages/cli/src/alerts/adapters/discord.js +36 -0
  13. package/dist/packages/cli/src/alerts/adapters/telegram.js +43 -0
  14. package/dist/packages/cli/src/alerts/adapters/toast.js +16 -0
  15. package/dist/packages/cli/src/alerts/creds.js +83 -0
  16. package/dist/packages/cli/src/alerts/format.js +41 -0
  17. package/dist/packages/cli/src/alerts/index.js +10 -0
  18. package/dist/packages/cli/src/alerts/notifier.js +53 -0
  19. package/dist/packages/cli/src/alerts/route.js +19 -0
  20. package/dist/packages/cli/src/alerts/types.js +1 -0
  21. package/dist/packages/cli/src/alerts-cmd.js +212 -0
  22. package/dist/packages/cli/src/backtest-cmd.js +62 -3
  23. package/dist/packages/cli/src/coin-target.js +2 -0
  24. package/dist/packages/cli/src/doctor.js +20 -6
  25. package/dist/packages/cli/src/export.js +235 -0
  26. package/dist/packages/cli/src/live/index.js +2 -0
  27. package/dist/packages/cli/src/main.js +243 -41
  28. package/dist/packages/cli/src/mcp-cmd.js +326 -0
  29. package/dist/packages/cli/src/rules.js +21 -2
  30. package/dist/packages/cli/src/skill-eval-build.js +109 -0
  31. package/dist/packages/cli/src/update.js +294 -0
  32. package/dist/packages/cli/src/version.js +3 -0
  33. package/dist/packages/cli/src/voice-cmd.js +119 -0
  34. package/dist/packages/cli/src/watch-cmd.js +190 -0
  35. package/dist/packages/custom/src/rules.js +1 -1
  36. package/dist/packages/custom/src/schema.js +24 -1
  37. package/dist/packages/data/src/coin-target.js +82 -0
  38. package/dist/packages/data/src/index.js +3 -0
  39. package/dist/packages/data/src/intel/helius-history.js +12 -1
  40. package/dist/packages/data/src/larp/link-baseline.js +196 -0
  41. package/dist/packages/data/src/sources/dexscreener.js +14 -0
  42. package/dist/packages/data/src/sources/pumpportal.js +6 -1
  43. package/dist/packages/mcp/src/server.js +89 -2
  44. package/dist/packages/packs/src/index.js +1 -0
  45. package/dist/packages/packs/src/skill-backtest.js +300 -0
  46. package/dist/packages/tui/src/index.js +3 -0
  47. package/dist/packages/tui/src/keymap.js +2 -0
  48. package/dist/packages/tui/src/raster-font.js +195 -0
  49. package/dist/packages/tui/src/raster.js +213 -0
  50. package/dist/packages/voice/src/controller.js +15 -3
  51. package/dist/packages/voice/src/doctor.js +75 -7
  52. package/dist/packages/voice/src/download.js +184 -0
  53. package/dist/packages/voice/src/engines.js +48 -8
  54. package/dist/packages/voice/src/index.js +7 -3
  55. package/dist/packages/voice/src/stt/whisper.js +14 -4
  56. package/dist/packages/voice/src/tts/espeak.js +131 -0
  57. package/dist/packages/voice/src/tts/piper.js +206 -0
  58. package/dist/packages/voice/src/tts/say.js +71 -35
  59. package/dist/packages/voice/src/tts/voices.js +206 -0
  60. package/package.json +5 -9
@@ -0,0 +1,213 @@
1
+ import { ATTR_BOLD, ATTR_DIM, ATTR_UNDERLINE } from "./canvas.js";
2
+ import { hexToRgb, mix } from "./color.js";
3
+ import { glyphBits, FONT_W, FONT_H } from "./raster-font.js";
4
+ class Px {
5
+ w;
6
+ h;
7
+ rgba;
8
+ constructor(w, h) { this.w = w; this.h = h; this.rgba = new Uint8Array(w * h * 4); }
9
+ rect(x, y, w, h, c, a = 1) {
10
+ const x0 = Math.max(0, Math.floor(x)), y0 = Math.max(0, Math.floor(y));
11
+ const x1 = Math.min(this.w, Math.floor(x + w)), y1 = Math.min(this.h, Math.floor(y + h));
12
+ for (let yy = y0; yy < y1; yy++)
13
+ for (let xx = x0; xx < x1; xx++) {
14
+ const i = (yy * this.w + xx) * 4;
15
+ if (a >= 1) {
16
+ this.rgba[i] = c[0];
17
+ this.rgba[i + 1] = c[1];
18
+ this.rgba[i + 2] = c[2];
19
+ }
20
+ else {
21
+ this.rgba[i] = Math.round(this.rgba[i] * (1 - a) + c[0] * a);
22
+ this.rgba[i + 1] = Math.round(this.rgba[i + 1] * (1 - a) + c[1] * a);
23
+ this.rgba[i + 2] = Math.round(this.rgba[i + 2] * (1 - a) + c[2] * a);
24
+ }
25
+ this.rgba[i + 3] = 255;
26
+ }
27
+ }
28
+ }
29
+ // Box drawing: up, down, left, right weights (0 none, 1 light, 2 heavy/double) and a dashed flag.
30
+ const BOX = {
31
+ '─': '0011', '━': '0022', '│': '1100', '┃': '2200',
32
+ '┌': '0101', '╭': '0101', '┐': '0110', '╮': '0110', '└': '1001', '╰': '1001', '┘': '1010', '╯': '1010',
33
+ '┏': '0202', '┓': '0220', '┗': '2002', '┛': '2020',
34
+ '├': '1101', '┤': '1110', '┬': '0111', '┴': '1011', '┼': '1111',
35
+ '┣': '2202', '┫': '2220', '┳': '0222', '┻': '2022', '╋': '2222',
36
+ '═': '0022', '║': '2200', '╔': '0202', '╗': '0220', '╚': '2002', '╝': '2020', '╠': '2202', '╣': '2220', '╦': '0222', '╩': '2022', '╬': '2222',
37
+ '╴': '0010', '╵': '1000', '╶': '0001', '╷': '0100', '╸': '0020', '╹': '2000', '╺': '0002', '╻': '0200',
38
+ };
39
+ const DASH_H = new Set(['┄', '┅', '┈', '┉', '╌', '╍']);
40
+ const DASH_V = new Set(['┆', '┇', '┊', '┋', '╎', '╏']);
41
+ const EIGHTHS_LOW = '▁▂▃▄▅▆▇█';
42
+ const EIGHTHS_LEFT = '▏▎▍▌▋▊▉█';
43
+ const SHADES = { '░': 0.25, '▒': 0.5, '▓': 0.75 };
44
+ // Quadrants: TL, TR, BL, BR.
45
+ const QUAD = {
46
+ '▘': [1, 0, 0, 0], '▝': [0, 1, 0, 0], '▖': [0, 0, 1, 0], '▗': [0, 0, 0, 1], '▚': [1, 0, 0, 1], '▞': [0, 1, 1, 0],
47
+ '▛': [1, 1, 1, 0], '▜': [1, 1, 0, 1], '▙': [1, 0, 1, 1], '▟': [0, 1, 1, 1], '▀': [1, 1, 0, 0], '▄': [0, 0, 1, 1], '▌': [1, 0, 1, 0], '▐': [0, 1, 0, 1],
48
+ };
49
+ /** Draw one non-text glyph procedurally. Returns false when the glyph is not one of these families. */
50
+ function drawSpecial(p, ch, x, y, cw, chh, fg, bg, scale) {
51
+ const box = BOX[ch];
52
+ if (box) {
53
+ const t1 = Math.max(1, scale), t2 = Math.max(2, scale * 2);
54
+ const [u, d, l, r] = [...box].map(Number);
55
+ const cx = x + Math.floor(cw / 2), cy = y + Math.floor(chh / 2);
56
+ const th = (wgt) => (wgt === 2 ? t2 : t1);
57
+ const hT = Math.max(th(l), th(r)), vT = Math.max(th(u), th(d));
58
+ if (l)
59
+ p.rect(x, cy - Math.floor(th(l) / 2), cx - x + Math.ceil(vT / 2), th(l), fg);
60
+ if (r)
61
+ p.rect(cx - Math.floor(vT / 2), cy - Math.floor(th(r) / 2), x + cw - (cx - Math.floor(vT / 2)), th(r), fg);
62
+ if (u)
63
+ p.rect(cx - Math.floor(th(u) / 2), y, th(u), cy - y + Math.ceil(hT / 2), fg);
64
+ if (d)
65
+ p.rect(cx - Math.floor(th(d) / 2), cy - Math.floor(hT / 2), th(d), y + chh - (cy - Math.floor(hT / 2)), fg);
66
+ return true;
67
+ }
68
+ if (DASH_H.has(ch)) {
69
+ const t = Math.max(1, scale);
70
+ const seg = Math.max(2, Math.floor(cw / 4));
71
+ for (let k = 0; k < cw; k += seg * 2)
72
+ p.rect(x + k, y + Math.floor(chh / 2) - Math.floor(t / 2), seg, t, fg);
73
+ return true;
74
+ }
75
+ if (DASH_V.has(ch)) {
76
+ const t = Math.max(1, scale);
77
+ const seg = Math.max(2, Math.floor(chh / 6));
78
+ for (let k = 0; k < chh; k += seg * 2)
79
+ p.rect(x + Math.floor(cw / 2) - Math.floor(t / 2), y + k, t, seg, fg);
80
+ return true;
81
+ }
82
+ const low = EIGHTHS_LOW.indexOf(ch);
83
+ if (low >= 0) {
84
+ const hh = Math.round((chh * (low + 1)) / 8);
85
+ p.rect(x, y + chh - hh, cw, hh, fg);
86
+ return true;
87
+ }
88
+ const left = EIGHTHS_LEFT.indexOf(ch);
89
+ if (left >= 0) {
90
+ p.rect(x, y, Math.round((cw * (left + 1)) / 8), chh, fg);
91
+ return true;
92
+ }
93
+ if (ch === '▔') {
94
+ p.rect(x, y, cw, Math.max(1, Math.round(chh / 8)), fg);
95
+ return true;
96
+ }
97
+ if (ch === '▕') {
98
+ const ww = Math.max(1, Math.round(cw / 8));
99
+ p.rect(x + cw - ww, y, ww, chh, fg);
100
+ return true;
101
+ }
102
+ const shade = SHADES[ch];
103
+ if (shade !== undefined) {
104
+ p.rect(x, y, cw, chh, [Math.round(bg[0] + (fg[0] - bg[0]) * shade), Math.round(bg[1] + (fg[1] - bg[1]) * shade), Math.round(bg[2] + (fg[2] - bg[2]) * shade)]);
105
+ return true;
106
+ }
107
+ const q = QUAD[ch];
108
+ if (q) {
109
+ const hw = Math.floor(cw / 2), hh = Math.floor(chh / 2);
110
+ if (q[0])
111
+ p.rect(x, y, hw, hh, fg);
112
+ if (q[1])
113
+ p.rect(x + hw, y, cw - hw, hh, fg);
114
+ if (q[2])
115
+ p.rect(x, y + hh, hw, chh - hh, fg);
116
+ if (q[3])
117
+ p.rect(x + hw, y + hh, cw - hw, chh - hh, fg);
118
+ return true;
119
+ }
120
+ const code = ch.codePointAt(0) ?? 0;
121
+ if (code >= 0x2800 && code <= 0x28ff) {
122
+ const bits = code - 0x2800;
123
+ // dot n → (column, row): 1(0,0) 2(0,1) 3(0,2) 4(1,0) 5(1,1) 6(1,2) 7(0,3) 8(1,3)
124
+ const dots = [[0x01, 0, 0], [0x02, 0, 1], [0x04, 0, 2], [0x08, 1, 0], [0x10, 1, 1], [0x20, 1, 2], [0x40, 0, 3], [0x80, 1, 3]];
125
+ const dw = Math.max(1, Math.floor(cw / 4)), dh = Math.max(1, Math.floor(chh / 8));
126
+ for (const [bit, col, row] of dots)
127
+ if (bits & bit)
128
+ p.rect(x + Math.floor((cw * (col * 2 + 1)) / 4) - Math.floor(dw / 2), y + Math.floor((chh * (row * 2 + 1)) / 8) - Math.floor(dh / 2), dw, dh, fg);
129
+ return true;
130
+ }
131
+ if (ch === '╱' || ch === '╲' || ch === '╳') {
132
+ const t = Math.max(1, scale);
133
+ for (let k = 0; k < chh; k++) {
134
+ const fx = Math.floor((k * cw) / chh);
135
+ if (ch !== '╲')
136
+ p.rect(x + cw - 1 - fx - Math.floor(t / 2), y + k, t, 1, fg);
137
+ if (ch !== '╱')
138
+ p.rect(x + fx - Math.floor(t / 2), y + k, t, 1, fg);
139
+ }
140
+ return true;
141
+ }
142
+ return false;
143
+ }
144
+ /** Rasterize every cell of a canvas: background, glyph (font or procedural), attributes, then image slots. */
145
+ export function rasterizeCanvas(c, opts = {}) {
146
+ const cw = opts.cellW ?? 12, chh = opts.cellH ?? 24, scale = opts.scale ?? 2;
147
+ const p = new Px(c.w * cw, c.h * chh);
148
+ const gx = Math.floor((cw - FONT_W * scale) / 2), gy = Math.floor((chh - FONT_H * scale) / 2);
149
+ const rgbCache = new Map();
150
+ const rgb = (h) => { let v = rgbCache.get(h); if (!v) {
151
+ v = hexToRgb(h);
152
+ rgbCache.set(h, v);
153
+ } return v; };
154
+ for (let row = 0; row < c.h; row++)
155
+ for (let col = 0; col < c.w; col++) {
156
+ const i = row * c.w + col;
157
+ const x = col * cw, y = row * chh;
158
+ const bgHex = c.bg[i];
159
+ const bg = rgb(bgHex);
160
+ p.rect(x, y, cw, chh, bg);
161
+ const ch = c.ch[i];
162
+ if (ch === ' ' || ch === '')
163
+ continue;
164
+ const at = c.at[i];
165
+ const fg = rgb(at & ATTR_DIM ? mix(c.fg[i], bgHex, 0.45) : c.fg[i]);
166
+ if (drawSpecial(p, ch, x, y, cw, chh, fg, bg, scale))
167
+ continue;
168
+ let bits = glyphBits(ch);
169
+ // an accented LETTER falls back to its base letter (é → e); never a digit or symbol, whose compatibility form
170
+ // can change meaning (a subscript ₅ is not a 5)
171
+ if (!bits && /^\p{L}$/u.test(ch)) {
172
+ const base = ch.normalize('NFD').replace(/\p{M}+/gu, '');
173
+ if (base && base !== ch)
174
+ bits = glyphBits(base[0]);
175
+ }
176
+ if (!bits) {
177
+ // tofu: a hollow box so an unsupported glyph is visible, never silently dropped
178
+ const t = Math.max(1, Math.floor(scale / 2));
179
+ const bx = x + gx + scale, by = y + gy + scale * 2, bw = (FONT_W - 2) * scale, bh = (FONT_H - 4) * scale;
180
+ p.rect(bx, by, bw, t, fg);
181
+ p.rect(bx, by + bh - t, bw, t, fg);
182
+ p.rect(bx, by, t, bh, fg);
183
+ p.rect(bx + bw - t, by, t, bh, fg);
184
+ continue;
185
+ }
186
+ const bold = (at & ATTR_BOLD) !== 0;
187
+ for (let yy = 0; yy < FONT_H; yy++)
188
+ for (let xx = 0; xx < FONT_W; xx++) {
189
+ if (!bits[yy * FONT_W + xx])
190
+ continue;
191
+ p.rect(x + gx + xx * scale, y + gy + yy * scale, scale + (bold ? 1 : 0), scale, fg);
192
+ }
193
+ if (at & ATTR_UNDERLINE)
194
+ p.rect(x, y + chh - Math.max(1, scale), cw, Math.max(1, Math.floor(scale / 2)), fg);
195
+ }
196
+ // Pixel image slots (coin images) recorded by the canvas in 'pixels' mode: nearest-neighbour into the cell rect.
197
+ for (const s of c.images) {
198
+ const rx = s.x * cw, ry = s.y * chh, rw = s.w * cw, rh = s.h * chh;
199
+ const { w: iw, h: ih, rgba } = s.img;
200
+ if (!iw || !ih)
201
+ continue;
202
+ for (let yy = 0; yy < rh; yy++)
203
+ for (let xx = 0; xx < rw; xx++) {
204
+ const sx = Math.min(iw - 1, Math.floor((xx * iw) / rw)), sy = Math.min(ih - 1, Math.floor((yy * ih) / rh));
205
+ const si = (sy * iw + sx) * 4;
206
+ const a = rgba[si + 3] / 255;
207
+ if (a <= 0)
208
+ continue;
209
+ p.rect(rx + xx, ry + yy, 1, 1, [rgba[si], rgba[si + 1], rgba[si + 2]], a);
210
+ }
211
+ }
212
+ return { w: p.w, h: p.h, rgba: p.rgba };
213
+ }
@@ -5,6 +5,7 @@
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 { isBannedVoice } from "./tts/voices.js";
8
9
  export class VoiceController {
9
10
  stt;
10
11
  tts;
@@ -15,6 +16,7 @@ export class VoiceController {
15
16
  muted;
16
17
  voice;
17
18
  rate;
19
+ pitch;
18
20
  voices = [];
19
21
  lastTranscript = null;
20
22
  lastSay = null;
@@ -43,6 +45,7 @@ export class VoiceController {
43
45
  this.muted = opts.muted ?? false;
44
46
  this.voice = opts.voice ?? null;
45
47
  this.rate = opts.rate ?? 190;
48
+ this.pitch = opts.pitch;
46
49
  this.now = opts.now ?? (() => Date.now());
47
50
  this.maxListenMs = opts.maxListenMs ?? 15_000;
48
51
  this.setTimer = opts.setTimer ?? ((fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; });
@@ -71,12 +74,16 @@ export class VoiceController {
71
74
  get isListening() { return this.mode === 'listening'; }
72
75
  /** The mic is live OR about to be (listen() in flight): the states a key must cancel. */
73
76
  get micEngaged() { return this.mode === 'listening' || this.starting; }
74
- /** Load the voice list lazily (used by cycleVoice and the status line). */
77
+ /**
78
+ * Load the voice list lazily (used by cycleVoice and the status line). Novelty voices on the ban-list
79
+ * are filtered OUT so the user can never cycle into "Zarvox" / "Bad News" and so a null starting voice
80
+ * is seeded to a safe one, never the machine's (possibly novelty) system voice (task-1393).
81
+ */
75
82
  async loadVoices() {
76
83
  if (!this.tts)
77
84
  return;
78
85
  try {
79
- this.voices = await this.tts.listVoices();
86
+ this.voices = (await this.tts.listVoices()).filter((n) => !isBannedVoice(n));
80
87
  if (!this.voice && this.voices.length)
81
88
  this.voice = this.voices[0];
82
89
  }
@@ -285,6 +292,7 @@ export class VoiceController {
285
292
  const handle = this.tts.speak(text, {
286
293
  voice: this.voice ?? undefined,
287
294
  rate: this.rate,
295
+ pitch: this.pitch,
288
296
  onFirstAudio: () => this.recordFirstAudio(),
289
297
  });
290
298
  this.speaking = handle;
@@ -362,7 +370,7 @@ export class VoiceController {
362
370
  this.stopSpeaking(); this.notify(); }
363
371
  toggleMute() { this.setMuted(!this.muted); }
364
372
  get isMuted() { return this.muted; }
365
- /** Cycle to the next installed voice (macOS voices). No-op when there are none. */
373
+ /** Cycle to the next installed (safe) voice and persist the choice. No-op when there are none. */
366
374
  cycleVoice() {
367
375
  if (!this.voices.length) {
368
376
  this.notify();
@@ -370,6 +378,10 @@ export class VoiceController {
370
378
  }
371
379
  const i = this.voice ? this.voices.indexOf(this.voice) : -1;
372
380
  this.voice = this.voices[(i + 1) % this.voices.length];
381
+ try {
382
+ this.opts.onVoiceChange?.(this.voice);
383
+ }
384
+ catch { /* persistence is best-effort; never break the UI */ }
373
385
  this.notify();
374
386
  }
375
387
  currentVoice() { return this.voice; }
@@ -7,16 +7,84 @@
7
7
  export function voiceDoctor(deps) {
8
8
  const checks = [];
9
9
  if (deps.platform !== 'darwin') {
10
- 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.',
14
- });
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
+ }
15
72
  return checks;
16
73
  }
17
74
  // Speech OUT — macOS say.
18
75
  if (deps.sayAvailable) {
19
76
  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)` });
77
+ // Which voice speaks, and why — so a machine can never silently use a novelty/odd system voice (task-1393).
78
+ if (deps.resolvedVoice) {
79
+ checks.push({ name: 'Default voice', status: 'pass', detail: `${deps.resolvedVoiceReason ?? deps.resolvedVoice} · , changes it` });
80
+ }
81
+ else if (deps.resolvedVoice === null) {
82
+ checks.push({
83
+ name: 'Default voice', status: 'warn',
84
+ detail: deps.resolvedVoiceReason ?? 'no safe voice installed — spoken replies are off (text still works)',
85
+ 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.',
86
+ });
87
+ }
20
88
  }
21
89
  else {
22
90
  checks.push({
@@ -47,8 +115,8 @@ export function voiceDoctor(deps) {
47
115
  name: 'Voice in', status: 'warn',
48
116
  detail: `speech-to-text is off (${bits.join(', ')}); you can still type questions — voice degrades to text`,
49
117
  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.',
118
+ + 'Or whisper: `brew install whisper-cpp sox`, then `dexterm voice download whisper` (asks first, checksum-verified, writes '
119
+ + '`~/.dexterm/models/ggml-base.en.bin`). Never use large-v3-turbo on a CPU-only Mac — it is unusably slow.',
52
120
  });
53
121
  }
54
122
  // 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,53 @@ 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 `say`. Off macOS, `say` does not exist — use the portable engines below.
66
+ if (choice === 'say' || (choice === 'auto' && process.platform === 'darwin')) {
67
+ const probe = new SayTts();
68
+ if (choice === 'auto' && !(await probe.available()))
69
+ return null;
70
+ const r = await resolveSayVoice(probe, configuredVoice);
71
+ // No safe voice installed (only novelty voices): voice-out stays OFF so `say` can never be invoked
72
+ // without an explicit voice and fall back to the machine's system voice (task-1393). Text still works.
73
+ if (r.voice === null)
74
+ return null;
75
+ return new SayTts(r.voice);
76
+ }
77
+ // auto, non-darwin: Piper (neural, higher quality) when it can speak aloud, else espeak-ng, else
78
+ // text-only. Neither engine is ever handed a macOS voice name (task-1408).
79
+ const piper = new PiperTts();
80
+ if (await piper.available())
81
+ return piper;
82
+ const espeak = new EspeakTts(espeakVoiceFor(configuredVoice) ?? undefined);
83
+ return (await espeak.available()) ? espeak : null;
44
84
  }
@@ -7,10 +7,14 @@ export * from "./types.js";
7
7
  export { route, isSafe } from "./router.js";
8
8
  export { spokenSummary, splitSentences } from "./summary.js";
9
9
  export { VoiceController } from "./controller.js";
10
- export { selectStt, selectTts } from "./engines.js";
10
+ export { selectStt, selectTts, resolveSayVoice } from "./engines.js";
11
11
  export { voiceDoctor } from "./doctor.js";
12
- export { SayTts } from "./tts/say.js";
12
+ export { resolveVoice, isBannedVoice, normalizeVoiceName, parseSayVoices, sanitizeSpeech, BANNED_VOICE_NAMES, BRITISH_MALE_PREFERENCE, MAX_SPEAK_CHARS, VOICE_STYLE_DEFAULTS, voiceStyleDefaults, } from "./tts/voices.js";
13
+ export { SayTts, DEFAULT_RATE } from "./tts/say.js";
14
+ export { EspeakTts, DEFAULT_ESPEAK_VOICE, resolveEspeakBin, espeakVoiceFor } from "./tts/espeak.js";
15
+ export { PiperTts, DEFAULT_PIPER_VOICE, PIPER_MODEL, PIPER_PLAYERS, resolvePiperBin, pickPlayer } from "./tts/piper.js";
13
16
  export { FakeTts } from "./tts/fake.js";
14
- export { WhisperStt, WHISPER_MODEL, WHISPER_MODEL_DIR } from "./stt/whisper.js";
17
+ export { WhisperStt, WHISPER_MODEL, WHISPER_MODEL_DIR, pickRecorder } from "./stt/whisper.js";
18
+ export { modelRegistry, downloadModel, modelStatus, specSizeMB, } from "./download.js";
15
19
  export { AppleStt, ensureHelper } from "./stt/apple.js";
16
20
  export { FakeStt } from "./stt/fake.js";