dexterm-cli 0.1.2 → 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 (118) hide show
  1. package/dist/packages/agents/src/bus.js +37 -1
  2. package/dist/packages/agents/src/follow.js +105 -0
  3. package/dist/packages/agents/src/index.js +5 -0
  4. package/dist/packages/agents/src/spectator-server.js +153 -0
  5. package/dist/packages/agents/src/spectator.js +212 -0
  6. package/dist/packages/cli/assets/claude/analyst-system.md +6 -1
  7. package/dist/packages/cli/src/agent-cmd.js +71 -7
  8. package/dist/packages/cli/src/agent-spectator.js +37 -0
  9. package/dist/packages/cli/src/alerts/format.js +25 -0
  10. package/dist/packages/cli/src/app-help-gen.js +365 -0
  11. package/dist/packages/cli/src/doctor.js +6 -5
  12. package/dist/packages/cli/src/follow-cmd.js +107 -1
  13. package/dist/packages/cli/src/help-cmd.js +48 -0
  14. package/dist/packages/cli/src/live/index.js +64 -3
  15. package/dist/packages/cli/src/live/screens/token-live.js +24 -0
  16. package/dist/packages/cli/src/live/signals.js +109 -0
  17. package/dist/packages/cli/src/live/trade.js +24 -1
  18. package/dist/packages/cli/src/main.js +355 -10
  19. package/dist/packages/cli/src/mcp-cmd.js +2 -7
  20. package/dist/packages/cli/src/pnl-app.js +221 -0
  21. package/dist/packages/cli/src/pnl-card.js +615 -0
  22. package/dist/packages/cli/src/record-cmd.js +455 -0
  23. package/dist/packages/cli/src/rules.js +50 -2
  24. package/dist/packages/cli/src/screens/follows.js +91 -2
  25. package/dist/packages/cli/src/screens/overlay.js +3 -0
  26. package/dist/packages/cli/src/screens/positions.js +1 -1
  27. package/dist/packages/cli/src/screens/rules.js +1 -1
  28. package/dist/packages/cli/src/spectate-cmd.js +104 -0
  29. package/dist/packages/cli/src/state.js +1 -1
  30. package/dist/packages/cli/src/version.js +1 -1
  31. package/dist/packages/cli/src/voice-cmd.js +86 -11
  32. package/dist/packages/cli/src/voice-hud.js +1 -1
  33. package/dist/packages/custom/src/rules.js +2 -1
  34. package/dist/packages/data/src/analyst-actions.js +130 -3
  35. package/dist/packages/data/src/fast-lane.js +16 -2
  36. package/dist/packages/data/src/index.js +1 -0
  37. package/dist/packages/data/src/intel/live-signals.js +185 -0
  38. package/dist/packages/data/src/trade/copy/index.js +1 -1
  39. package/dist/packages/data/src/trade/copy/stats.js +36 -2
  40. package/dist/packages/data/src/trade/copy/tracker.js +3 -2
  41. package/dist/packages/mcp/assets/kb/app-help/app-help-command.md +35 -0
  42. package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +43 -0
  43. package/dist/packages/mcp/assets/kb/app-help/backtest.md +33 -0
  44. package/dist/packages/mcp/assets/kb/app-help/bubble-map.md +35 -0
  45. package/dist/packages/mcp/assets/kb/app-help/calm-mode.md +31 -0
  46. package/dist/packages/mcp/assets/kb/app-help/change-voice-speed.md +31 -0
  47. package/dist/packages/mcp/assets/kb/app-help/change-voice.md +30 -0
  48. package/dist/packages/mcp/assets/kb/app-help/chart-popup-selftest.md +32 -0
  49. package/dist/packages/mcp/assets/kb/app-help/coin-images.md +29 -0
  50. package/dist/packages/mcp/assets/kb/app-help/configure-rpc-key.md +28 -0
  51. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent-registry.md +35 -0
  52. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +39 -0
  53. package/dist/packages/mcp/assets/kb/app-help/custom-keymap.md +32 -0
  54. package/dist/packages/mcp/assets/kb/app-help/edit-kb-or-skill.md +36 -0
  55. package/dist/packages/mcp/assets/kb/app-help/edit-rules.md +34 -0
  56. package/dist/packages/mcp/assets/kb/app-help/export-a-view.md +36 -0
  57. package/dist/packages/mcp/assets/kb/app-help/follow-a-wallet.md +33 -0
  58. package/dist/packages/mcp/assets/kb/app-help/getting-started.md +37 -0
  59. package/dist/packages/mcp/assets/kb/app-help/kol-lists.md +34 -0
  60. package/dist/packages/mcp/assets/kb/app-help/larp-check.md +36 -0
  61. package/dist/packages/mcp/assets/kb/app-help/link-a-wallet.md +41 -0
  62. package/dist/packages/mcp/assets/kb/app-help/login-with-x.md +33 -0
  63. package/dist/packages/mcp/assets/kb/app-help/narrative-board.md +32 -0
  64. package/dist/packages/mcp/assets/kb/app-help/open-a-coin.md +43 -0
  65. package/dist/packages/mcp/assets/kb/app-help/open-chart-popup.md +33 -0
  66. package/dist/packages/mcp/assets/kb/app-help/order-plans.md +33 -0
  67. package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +36 -0
  68. package/dist/packages/mcp/assets/kb/app-help/price-or-marketcap.md +38 -0
  69. package/dist/packages/mcp/assets/kb/app-help/record-headless.md +34 -0
  70. package/dist/packages/mcp/assets/kb/app-help/recorder-history.md +39 -0
  71. package/dist/packages/mcp/assets/kb/app-help/rug-check.md +39 -0
  72. package/dist/packages/mcp/assets/kb/app-help/run-doctor.md +32 -0
  73. package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +37 -0
  74. package/dist/packages/mcp/assets/kb/app-help/screen-analyst.md +181 -0
  75. package/dist/packages/mcp/assets/kb/app-help/screen-kol.md +190 -0
  76. package/dist/packages/mcp/assets/kb/app-help/screen-newpairs.md +181 -0
  77. package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +180 -0
  78. package/dist/packages/mcp/assets/kb/app-help/screen-screener.md +199 -0
  79. package/dist/packages/mcp/assets/kb/app-help/screen-token.md +240 -0
  80. package/dist/packages/mcp/assets/kb/app-help/screen-trending.md +192 -0
  81. package/dist/packages/mcp/assets/kb/app-help/screen-watchlist.md +181 -0
  82. package/dist/packages/mcp/assets/kb/app-help/set-a-filter.md +36 -0
  83. package/dist/packages/mcp/assets/kb/app-help/setup-neural-voice.md +36 -0
  84. package/dist/packages/mcp/assets/kb/app-help/share-packs.md +34 -0
  85. package/dist/packages/mcp/assets/kb/app-help/sort-the-screener.md +34 -0
  86. package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +36 -0
  87. package/dist/packages/mcp/assets/kb/app-help/standing-mandates.md +33 -0
  88. package/dist/packages/mcp/assets/kb/app-help/switch-theme.md +42 -0
  89. package/dist/packages/mcp/assets/kb/app-help/turn-on-alerts.md +37 -0
  90. package/dist/packages/mcp/assets/kb/app-help/update-dexterm.md +30 -0
  91. package/dist/packages/mcp/assets/kb/app-help/use-presets.md +42 -0
  92. package/dist/packages/mcp/assets/kb/app-help/use-voice-mode.md +36 -0
  93. package/dist/packages/mcp/assets/kb/app-help/watch-a-coin.md +34 -0
  94. package/dist/packages/mcp/assets/kb/app-help/watch-headless.md +35 -0
  95. package/dist/packages/mcp/assets/kb/app-help/what-dexterm-shows-agents.md +31 -0
  96. package/dist/packages/mcp/assets/kb/app-help/workspaces.md +38 -0
  97. package/dist/packages/mcp/src/app-help.js +135 -0
  98. package/dist/packages/mcp/src/index.js +3 -0
  99. package/dist/packages/mcp/src/server.js +90 -3
  100. package/dist/packages/mcp/src/spectator-sink.js +72 -0
  101. package/dist/packages/packs/src/index.js +2 -0
  102. package/dist/packages/packs/src/skill-care.js +206 -0
  103. package/dist/packages/popup/src/pages/spectator.css +45 -0
  104. package/dist/packages/popup/src/pages/spectator.html +25 -0
  105. package/dist/packages/popup/src/pages/spectator.js +74 -0
  106. package/dist/packages/popup/src/selftest.js +19 -0
  107. package/dist/packages/popup/src/server.js +7 -1
  108. package/dist/packages/tui/src/keymap.js +10 -0
  109. package/dist/packages/voice/src/controller.js +42 -1
  110. package/dist/packages/voice/src/doctor.js +25 -8
  111. package/dist/packages/voice/src/engines.js +8 -1
  112. package/dist/packages/voice/src/index.js +3 -1
  113. package/dist/packages/voice/src/speakable.js +111 -0
  114. package/dist/packages/voice/src/tts/piper-setup.js +229 -0
  115. package/dist/packages/voice/src/tts/piper.js +309 -66
  116. package/dist/packages/voice/src/tts/say.js +3 -2
  117. package/dist/packages/voice/src/tts/voices.js +8 -5
  118. package/package.json +1 -1
@@ -0,0 +1,111 @@
1
+ // Make a line "speakable" like a trader, not digit by digit (task-1411). A tester heard Jarvis read a raw
2
+ // per-token price ("zero point zero zero zero zero zero two nine") and big raw numbers, which sounds
3
+ // crazy. This normaliser runs on EVERY spoken line (controller.speak) and applies these rules, in order:
4
+ //
5
+ // 1. Tiny per-token prices are NEVER read digit by digit. A sub-cent decimal ($0.0000029), the app's
6
+ // own subscript rendering ($0.0₅29), or any value below a cent becomes the coin's MARKET CAP when
7
+ // the line (or the caller) knows it, else the phrase "a tiny price". We NEVER invent a number.
8
+ // A sub-cent value that is an AMOUNT of something (e.g. "0.005 SOL", "0.0025 SOL fee") is left
9
+ // alone — it is a quantity, not a per-token price.
10
+ // 2. Big raw numbers become human words: 340000 → "340 thousand", 1200000 → "1.2 million",
11
+ // 4500000000 → "4.5 billion". Word boundaries keep digits inside an address untouched, and a bare
12
+ // 4-digit YEAR (1900–2099) is left as-is so "launched in 2024" never reads "launched in 2 thousand".
13
+ // 3. Percentages read naturally: "12%" → "12 percent".
14
+ //
15
+ // Pure and dependency-free so every rule is unit-tested without spawning a voice.
16
+ const SUBSCRIPT = '₀₁₂₃₄₅₆₇₈₉'; // ₀..₉
17
+ // Units that follow a small decimal and mark it as an AMOUNT, not a per-token price (so "0.005 SOL" is
18
+ // a quantity we read normally). Case-insensitive, whole word.
19
+ const AMOUNT_UNIT = /^\s*(sol|eth|btc|usdc|usdt|tokens?|coins?)\b/i;
20
+ /** Round to one decimal and drop a trailing ".0" so "1.0 million" reads "1 million". */
21
+ function trimNum(n) {
22
+ const r = Math.round(n * 10) / 10;
23
+ return (Number.isInteger(r) ? String(r) : r.toFixed(1));
24
+ }
25
+ /** A raw number (>= 1000) as human words: "340 thousand", "1.2 million", "4.5 billion". */
26
+ export function humanizeNumber(n) {
27
+ const abs = Math.abs(n);
28
+ if (abs >= 1e9)
29
+ return `${trimNum(n / 1e9)} billion`;
30
+ if (abs >= 1e6)
31
+ return `${trimNum(n / 1e6)} million`;
32
+ if (abs >= 1e3)
33
+ return `${trimNum(n / 1e3)} thousand`;
34
+ return trimNum(n);
35
+ }
36
+ /** A number is a 4-digit calendar year (1900–2099): spoken as-is, never "2 thousand twenty four". */
37
+ function isYear(v) {
38
+ return Number.isInteger(v) && v >= 1900 && v <= 2099;
39
+ }
40
+ /**
41
+ * Read a market cap out of the analyst line, e.g. "market cap $340K", "mcap of 1.2M", "cap: 340000",
42
+ * returned as a human phrase ("340 thousand"). The brief: a tiny price is replaced WITH THE COIN'S
43
+ * MARKET CAP WHEN THE ANALYST TEXT INCLUDES IT. Returns null when the line names no market cap.
44
+ */
45
+ function marketCapFromText(text) {
46
+ const m = /\b(?:market\s*cap|mcap|m\.?c\.?|cap)\b[^$\d]{0,8}\$?([\d,]+(?:\.\d+)?)\s*([KkMmBb])?\b/.exec(text);
47
+ if (!m)
48
+ return null;
49
+ let v = parseFloat(m[1].replace(/,/g, ''));
50
+ if (!Number.isFinite(v))
51
+ return null;
52
+ const suf = m[2];
53
+ if (suf)
54
+ v *= /[Kk]/.test(suf) ? 1e3 : /[Mm]/.test(suf) ? 1e6 : 1e9;
55
+ if (v < 1000)
56
+ return null; // "cap 2 holders" etc. is not a market cap
57
+ return humanizeNumber(v);
58
+ }
59
+ /**
60
+ * Normalise a spoken line. `opts.marketCap`, when given, is the human market-cap phrase substituted for
61
+ * a tiny per-token price; otherwise speakable reads a market cap out of the line, and failing that uses
62
+ * "a tiny price" (never a made-up number).
63
+ */
64
+ export function speakable(text, opts = {}) {
65
+ let s = String(text ?? '');
66
+ const mcap = (opts.marketCap && opts.marketCap.trim()) || marketCapFromText(s);
67
+ const tiny = mcap ? `a market cap of ${String(mcap).trim()}` : 'a tiny price';
68
+ // 1a. Subscript price forms the app renders, e.g. "$0.0₅29" / "0.0₄1686" — never read the digits.
69
+ s = s.replace(new RegExp(`\\$?\\d*\\.?\\d*[${SUBSCRIPT}]+[${SUBSCRIPT}0-9]*`, 'g'), tiny);
70
+ // 1b. Sub-cent decimal prices, e.g. "$0.0000029", "0.00004". A value below a cent is a per-token price,
71
+ // not an amount; the leading-zeros case (>=2 zeros after the point) is always a price — UNLESS it
72
+ // is immediately followed by a unit word (SOL/ETH/tokens…), in which case it is an AMOUNT: leave it.
73
+ s = s.replace(/\$?\d*\.\d+/g, (m, idx, full) => {
74
+ const v = parseFloat(m.replace(/[$,]/g, ''));
75
+ if (!Number.isFinite(v))
76
+ return m;
77
+ const after = full.slice(idx + m.length);
78
+ if (AMOUNT_UNIT.test(after))
79
+ return m; // "0.005 SOL" is a quantity, not a per-token price
80
+ if (/\.0{2,}/.test(m) || (v > 0 && v < 0.01))
81
+ return tiny;
82
+ return m;
83
+ });
84
+ // 2. Percentages read as words before the big-number pass (so "12%" isn't seen as a bare number).
85
+ s = s.replace(/(\d+(?:\.\d+)?)\s*%/g, '$1 percent');
86
+ // 2b. Abbreviated magnitudes the app and analysts use: "$340K" / "2.1M" / "4.5B" → words. Lowercase
87
+ // "m" is intentionally EXCLUDED: "15m" / "6h" are a token's AGE (minutes), not millions.
88
+ s = s.replace(/\$?(\d+(?:\.\d+)?)\s*([KkMBb])\b/g, (_m, num, suf) => {
89
+ const word = /[Kk]/.test(suf) ? 'thousand' : suf === 'M' ? 'million' : 'billion';
90
+ return `${num} ${word}`;
91
+ });
92
+ // 3. Big raw numbers -> human words. `\b` keeps runs of digits inside an address/word untouched, and a
93
+ // number already followed by a scale word (k/m/b/thousand/…) or "percent" is left alone, as is a
94
+ // bare 4-digit year.
95
+ s = s.replace(/\$?\b\d{1,3}(?:,\d{3})+(?:\.\d+)?\b|\$?\b\d{4,}(?:\.\d+)?\b/g, (m, ...rest) => {
96
+ const full = rest[rest.length - 1]; // the whole input string
97
+ const idx = rest[rest.length - 2];
98
+ const after = full.slice(idx + m.length).trimStart().toLowerCase();
99
+ if (/^(k|m|b|bn|thousand|million|billion|percent|%)\b/.test(after))
100
+ return m;
101
+ const v = parseFloat(m.replace(/[$,]/g, ''));
102
+ if (!Number.isFinite(v) || Math.abs(v) < 1000)
103
+ return m;
104
+ // A bare 4-digit year ("launched in 2024") stays a year; a dollar-prefixed or comma-grouped number
105
+ // ("$2024", "2,024") is a real amount and is humanised.
106
+ if (isYear(v) && !/[$,]/.test(m))
107
+ return m;
108
+ return humanizeNumber(v);
109
+ });
110
+ return s;
111
+ }
@@ -0,0 +1,229 @@
1
+ // Managed Piper runtime for macOS (task-1416). The prebuilt macOS Piper tarball from rhasspy is BROKEN
2
+ // (it needs a libespeak-ng dylib it does not bundle), so on macOS dexterm builds its own private Python
3
+ // runtime instead: a virtualenv at `~/.dexterm/piper/venv` with `pip install piper-tts`, plus the
4
+ // checksum-verified en_GB-alan-medium voice model. After setup the voice runs fully OFFLINE, needs no
5
+ // key, and is driven by `<venv>/bin/python -m piper` (see piper.ts resolvePiperRuntime).
6
+ //
7
+ // Everything that touches the system (running python/pip, the network) is INJECTED, so this module is
8
+ // unit-testable with fakes and never spawns or downloads in tests. `dexterm voice setup` wires the real
9
+ // child_process + global fetch (see packages/cli/src/voice-cmd.ts).
10
+ //
11
+ // Hosts contacted, ONLY while the user runs setup (documented on the site trust page):
12
+ // - https://pypi.org + https://files.pythonhosted.org (pip resolves + downloads the piper-tts wheel and its deps)
13
+ // - https://huggingface.co (the voice model, via the existing checksum-verified downloader)
14
+ import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { downloadModel, modelRegistry, modelStatus } from "../download.js";
17
+ /** Pinned piper-tts version (verified working on macOS/Python 3.9 in task-1416). */
18
+ export const PIPER_PYPI_VERSION = '1.8.0';
19
+ /** The minimum Python the managed runtime needs (piper-tts wheels + onnxruntime). */
20
+ export const MIN_PYTHON = [3, 9];
21
+ /** Paths inside the managed runtime, derived from the dexterm home. */
22
+ export function piperRuntimePaths(home) {
23
+ const dir = join(home, 'piper');
24
+ const venv = join(dir, 'venv');
25
+ const binDir = process.platform === 'win32' ? 'Scripts' : 'bin';
26
+ const exe = process.platform === 'win32' ? '.exe' : '';
27
+ return {
28
+ dir,
29
+ venv,
30
+ python: join(venv, binDir, `python${exe}`),
31
+ pip: join(venv, binDir, `pip${exe}`),
32
+ record: join(dir, 'runtime.json'),
33
+ };
34
+ }
35
+ /** Parse "Python 3.11.7" → [3, 11, 7]; null when it does not look like a version. */
36
+ export function parsePythonVersion(s) {
37
+ const m = s.match(/Python\s+(\d+)\.(\d+)(?:\.(\d+))?/i);
38
+ if (!m)
39
+ return null;
40
+ return [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)];
41
+ }
42
+ /** True when a parsed version is at least MIN_PYTHON. */
43
+ export function pythonVersionOk(v) {
44
+ if (!v)
45
+ return false;
46
+ if (v[0] !== MIN_PYTHON[0])
47
+ return v[0] > MIN_PYTHON[0];
48
+ return v[1] >= MIN_PYTHON[1];
49
+ }
50
+ /** The exact fix line shown when no suitable python3 is found (platform-specific). */
51
+ export function pythonFixLine(platform = process.platform) {
52
+ if (platform === 'darwin') {
53
+ return 'Install Python 3.9+ : `brew install python@3.12` (Homebrew) or the Xcode Command Line Tools (`xcode-select --install`), then re-run `dexterm voice setup`.';
54
+ }
55
+ if (platform === 'win32') {
56
+ return 'Install Python 3.9+ from python.org (or `winget install Python.Python.3.12`), then re-run `dexterm voice setup`.';
57
+ }
58
+ return 'Install Python 3.9+ and venv: `sudo apt install python3 python3-venv` (Debian/Ubuntu), then re-run `dexterm voice setup`.';
59
+ }
60
+ /** The default python executables to probe, best-first. The CLI passes this (and may prepend DEXTERM_PYTHON). */
61
+ export const DEFAULT_PYTHON_CANDIDATES = [
62
+ 'python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3.9', 'python3', 'python',
63
+ ];
64
+ /** Find the first candidate whose `--version` is >= 3.9. Returns the executable + parsed version, or null. */
65
+ export async function detectPython(deps) {
66
+ const seen = new Set();
67
+ for (const cand of deps.pythonCandidates ?? DEFAULT_PYTHON_CANDIDATES) {
68
+ if (!cand || seen.has(cand))
69
+ continue;
70
+ seen.add(cand);
71
+ let res;
72
+ try {
73
+ res = await deps.run(cand, ['--version']);
74
+ }
75
+ catch {
76
+ continue;
77
+ }
78
+ const v = parsePythonVersion(`${res.stdout} ${res.stderr}`.trim());
79
+ if (res.code === 0 && pythonVersionOk(v))
80
+ return { python: cand, version: v };
81
+ }
82
+ return null;
83
+ }
84
+ /** Read the managed-runtime record, or null when it is absent/invalid. */
85
+ export function readRuntimeRecord(home) {
86
+ const { record } = piperRuntimePaths(home);
87
+ try {
88
+ return JSON.parse(readFileSync(record, 'utf8'));
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ /** Inspect the managed runtime WITHOUT touching python or the network (for `voice status` / the doctor). */
95
+ export async function piperRuntimeStatus(home) {
96
+ const paths = piperRuntimePaths(home);
97
+ const model = await modelStatus(modelRegistry(home)['piper-voice']);
98
+ return {
99
+ installed: existsSync(paths.python) && model.installed,
100
+ python: paths.python,
101
+ pythonPresent: existsSync(paths.python),
102
+ modelInstalled: model.installed,
103
+ record: readRuntimeRecord(home),
104
+ };
105
+ }
106
+ /**
107
+ * Build the managed Piper runtime: detect python3 → confirm once → create the venv → `pip install
108
+ * piper-tts` (pinned, wheels only) → download + verify the voice model → write the record. Idempotent:
109
+ * returns 'already' without prompting when the venv python, piper-tts, and the model are all present.
110
+ * Any failure is reported (never thrown) with a copy-pasteable fix.
111
+ */
112
+ export async function setupPiperRuntime(deps) {
113
+ const log = deps.log ?? (() => { });
114
+ const now = deps.now ?? Date.now;
115
+ const paths = piperRuntimePaths(deps.home);
116
+ const piperVoice = modelRegistry(deps.home)['piper-voice'];
117
+ // Already fully installed? Verify piper-tts imports too, so a half-built venv is repaired.
118
+ if (existsSync(paths.python) && (await modelStatus(piperVoice)).installed) {
119
+ const check = await deps.run(paths.python, ['-c', 'import piper']);
120
+ if (check.code === 0) {
121
+ log('Piper runtime is already installed and verified.');
122
+ return { status: 'already', python: paths.python, model: piperVoice.files.map((f) => f.dest) };
123
+ }
124
+ }
125
+ const found = await detectPython(deps);
126
+ if (!found)
127
+ return { status: 'no-python', fix: pythonFixLine() };
128
+ const versionStr = found.version.join('.');
129
+ const prompt = [
130
+ 'Set up the neural voice (Piper en_GB-alan-medium). This will, only now:',
131
+ ` • create a private Python environment at ${paths.venv}`,
132
+ ` • pip install piper-tts==${PIPER_PYPI_VERSION} (wheels only) from pypi.org / files.pythonhosted.org`,
133
+ ` • download the ~61 MB voice model from huggingface.co (checksum-verified)`,
134
+ 'It runs fully offline afterwards, needs no key, and `dexterm voice uninstall` removes it. Proceed?',
135
+ ].join('\n');
136
+ if (!(await deps.confirm(prompt))) {
137
+ log('Cancelled — nothing installed.');
138
+ return { status: 'cancelled' };
139
+ }
140
+ // 1. venv
141
+ try {
142
+ mkdirSync(paths.dir, { recursive: true });
143
+ }
144
+ catch { /* reported by the venv step if it matters */ }
145
+ if (!existsSync(paths.python)) {
146
+ log(` • creating the Python environment (${found.python} ${versionStr})…`);
147
+ const venv = await deps.run(found.python, ['-m', 'venv', paths.venv]);
148
+ if (venv.code !== 0 || !existsSync(paths.python)) {
149
+ return { status: 'failed', python: found.python, pythonVersion: versionStr,
150
+ fix: `Creating the venv failed: ${(venv.stderr || venv.stdout).slice(-300).trim() || 'unknown error'}. On Debian/Ubuntu install \`python3-venv\` and re-run \`dexterm voice setup\`.` };
151
+ }
152
+ }
153
+ // 2. pip install piper-tts (pinned, wheels only so there is never a source build / compiler requirement)
154
+ log(` • installing piper-tts==${PIPER_PYPI_VERSION} (this downloads a few wheels)…`);
155
+ const pip = await deps.run(paths.python, ['-m', 'pip', 'install', '--disable-pip-version-check', '--only-binary', ':all:', `piper-tts==${PIPER_PYPI_VERSION}`]);
156
+ if (pip.code !== 0) {
157
+ return { status: 'failed', python: found.python, pythonVersion: versionStr,
158
+ fix: `pip install failed: ${(pip.stderr || pip.stdout).slice(-300).trim() || 'unknown error'}. Check your network and that a wheel exists for this Python, then re-run \`dexterm voice setup\`.` };
159
+ }
160
+ const imp = await deps.run(paths.python, ['-c', 'import piper']);
161
+ if (imp.code !== 0) {
162
+ return { status: 'failed', python: found.python, pythonVersion: versionStr,
163
+ fix: `piper-tts installed but does not import: ${(imp.stderr || imp.stdout).slice(-300).trim() || 'unknown error'}.` };
164
+ }
165
+ // 3. model (reuse the checksum-verified downloader; setup already confirmed, so do not prompt again)
166
+ let dl;
167
+ try {
168
+ const download = deps.downloadModelImpl ?? downloadModel;
169
+ dl = await download(piperVoice, { confirm: () => Promise.resolve(true), log, fetchImpl: deps.fetchImpl });
170
+ }
171
+ catch (e) {
172
+ return { status: 'failed', python: found.python, pythonVersion: versionStr,
173
+ fix: `Voice model download failed: ${e.message}. Re-run \`dexterm voice setup\` (it resumes) once your network is back.` };
174
+ }
175
+ // 4. record
176
+ const record = {
177
+ kind: 'venv',
178
+ piperTts: PIPER_PYPI_VERSION,
179
+ python: paths.python,
180
+ pythonVersion: versionStr,
181
+ model: piperVoice.files[0].dest,
182
+ platform: process.platform,
183
+ installedAt: new Date(now()).toISOString(),
184
+ };
185
+ try {
186
+ writeFileSync(paths.record, JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
187
+ }
188
+ catch { /* non-fatal */ }
189
+ log('Neural voice ready. It is now the default speaking voice (fallback: the system voice).');
190
+ return { status: 'ok', python: found.python, pythonVersion: versionStr, model: dl.files };
191
+ }
192
+ /**
193
+ * Remove the managed Piper runtime (the venv + its record). The downloaded voice model is LEFT in place
194
+ * by default because it is shared with `dexterm voice download piper-voice` and the Linux binary path;
195
+ * pass `purgeModel` to delete it too.
196
+ */
197
+ export async function uninstallPiperRuntime(home, opts = {}) {
198
+ const paths = piperRuntimePaths(home);
199
+ const removed = [];
200
+ let found = false;
201
+ for (const p of [paths.venv, paths.record]) {
202
+ if (existsSync(p)) {
203
+ found = true;
204
+ try {
205
+ rmSync(p, { recursive: true, force: true });
206
+ removed.push(p);
207
+ }
208
+ catch { /* ignore */ }
209
+ }
210
+ }
211
+ if (opts.purgeModel) {
212
+ for (const f of modelRegistry(home)['piper-voice'].files) {
213
+ if (existsSync(f.dest)) {
214
+ found = true;
215
+ try {
216
+ rmSync(f.dest, { force: true });
217
+ removed.push(f.dest);
218
+ }
219
+ catch { /* ignore */ }
220
+ }
221
+ }
222
+ }
223
+ // Remove the now-empty piper dir if nothing else lives in it.
224
+ try {
225
+ rmSync(paths.dir, { recursive: false });
226
+ }
227
+ catch { /* not empty / absent — fine */ }
228
+ return { status: found ? 'ok' : 'absent', removed };
229
+ }