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,294 @@
1
+ // task-1397 — `dexterm update` and the quiet startup notice.
2
+ //
3
+ // The check is two anonymous GETs: the npm registry's `latest` for dexterm-cli and GitHub's latest published
4
+ // release of dexterm-xyz/dexterm. Both carry only fixed headers (no version, no OS, no ids, no cookies); nothing
5
+ // about the user or the machine is sent beyond the IP any request has. No telemetry.
6
+ //
7
+ // Startup: a live interactive run reads ~/.dexterm/update-check.json (no network) and shows one toast when a
8
+ // newer version was seen; at most once a day it refreshes that file in the background (never blocks, never in
9
+ // --fixtures/--replay, never with DEXTERM_OFFLINE/CI). Off switches: `dexterm update --off`,
10
+ // DEXTERM_NO_UPDATE_CHECK=1 or NO_UPDATE_NOTIFIER=1. `dexterm update` itself never upgrades anything: it prints
11
+ // the exact command for how this copy was installed (npm, Homebrew, release tarball, git checkout).
12
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
13
+ import { dirname, join, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dextermHome } from "./boot-state.js";
16
+ import { VERSION } from "./version.js";
17
+ export const NPM_PACKAGE = 'dexterm-cli';
18
+ export const GITHUB_REPO = 'dexterm-xyz/dexterm';
19
+ export const NPM_LATEST_URL = `https://registry.npmjs.org/${NPM_PACKAGE}/latest`;
20
+ export const GITHUB_LATEST_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
21
+ export const RELEASES_URL = `https://github.com/${GITHUB_REPO}/releases`;
22
+ /** Fixed request headers: the same bytes for every user (GitHub's API requires a user-agent). */
23
+ export const REQUEST_HEADERS = Object.freeze({ accept: 'application/json', 'user-agent': 'dexterm' });
24
+ export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
25
+ export const REQUEST_TIMEOUT_MS = 4000;
26
+ export const UPDATE_FILE = 'update-check.json';
27
+ export const UPDATE_USAGE = `usage: dexterm update check npm + GitHub now and print the upgrade command for this install
28
+ dexterm update --off turn the once-a-day startup check off (dexterm update still works)
29
+ dexterm update --on turn it back on`;
30
+ /** `1.2.3`, `v1.2.3`, `1.2.3-rc.1` → parts; anything else → null (never guessed). */
31
+ export function parseVersion(s) {
32
+ if (typeof s !== 'string')
33
+ return null;
34
+ const m = /^v?(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:-([0-9A-Za-z.-]{1,64}))?(?:\+[0-9A-Za-z.-]{1,64})?$/.exec(s.trim());
35
+ if (!m)
36
+ return null;
37
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ? m[4].split('.') : [] };
38
+ }
39
+ /** Semver precedence: < 0 when a is older than b. Unparseable versions compare equal (no notice on junk). */
40
+ export function compareVersions(a, b) {
41
+ const x = parseVersion(a), y = parseVersion(b);
42
+ if (!x || !y)
43
+ return 0;
44
+ for (const k of ['major', 'minor', 'patch'])
45
+ if (x[k] !== y[k])
46
+ return x[k] - y[k];
47
+ if (!x.pre.length || !y.pre.length)
48
+ return y.pre.length - x.pre.length; // a release outranks its prereleases
49
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
50
+ const p = x.pre[i], q = y.pre[i];
51
+ if (p === undefined)
52
+ return -1;
53
+ if (q === undefined)
54
+ return 1;
55
+ const pn = /^\d+$/.test(p), qn = /^\d+$/.test(q);
56
+ if (pn && qn) {
57
+ if (Number(p) !== Number(q))
58
+ return Number(p) - Number(q);
59
+ }
60
+ else if (pn !== qn)
61
+ return pn ? -1 : 1;
62
+ else if (p !== q)
63
+ return p < q ? -1 : 1;
64
+ }
65
+ return 0;
66
+ }
67
+ const bare = (v) => v.trim().replace(/^v/, '');
68
+ /** This module's real path (symlinks resolved): …/node_modules/dexterm-cli/dist/…, …/Cellar/dexterm/…, or a source tree. */
69
+ export function selfPath() {
70
+ const p = fileURLToPath(import.meta.url);
71
+ try {
72
+ return realpathSync(p);
73
+ }
74
+ catch {
75
+ return p;
76
+ }
77
+ }
78
+ /** The source tree's root for a tarball/checkout install (this file is packages/cli/src/update.ts). */
79
+ export function sourceRoot(path = selfPath()) {
80
+ return resolve(dirname(path), '..', '..', '..');
81
+ }
82
+ export function detectInstall(path = selfPath(), exists = existsSync) {
83
+ const p = path.replace(/\\/g, '/');
84
+ if (p.includes(`/node_modules/${NPM_PACKAGE}/`))
85
+ return 'npm';
86
+ if (/\/Cellar\/dexterm\/|\/opt\/homebrew\/|\/home\/linuxbrew\/\.linuxbrew\//.test(p))
87
+ return 'brew';
88
+ return exists(join(sourceRoot(path), '.git')) ? 'checkout' : 'tarball';
89
+ }
90
+ export const INSTALL_LABEL = {
91
+ npm: `npm (${NPM_PACKAGE})`, brew: 'Homebrew (dexterm-xyz/dexterm tap)', tarball: 'release tarball (install.sh)', checkout: 'git checkout',
92
+ };
93
+ /** The exact commands that move this install to `latest`. Nothing here is ever run by dexterm. */
94
+ export function upgradeCommands(kind, latest, opts = {}) {
95
+ const v = bare(latest);
96
+ if (kind === 'npm')
97
+ return [`npm i -g ${NPM_PACKAGE}@${v}`];
98
+ if (kind === 'brew')
99
+ return ['brew update', 'brew upgrade dexterm'];
100
+ if (kind === 'checkout')
101
+ return [`git -C ${shellQuote(opts.root ?? sourceRoot())} pull --ff-only`];
102
+ const base = `${RELEASES_URL}/download/v${v}`;
103
+ const file = `dexterm-${v}-src.tar.gz`;
104
+ const sum = (opts.platform ?? process.platform) === 'darwin' ? 'shasum -a 256 -c' : 'sha256sum -c';
105
+ return [
106
+ `curl -fL -O ${base}/${file} -O ${base}/SHA256SUMS`,
107
+ `grep ' ${file}$' SHA256SUMS | ${sum}`,
108
+ `tar -xzf ${file} && cd dexterm-${v} && ./install.sh`,
109
+ ];
110
+ }
111
+ function shellQuote(s) {
112
+ return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
113
+ }
114
+ async function getJson(fetchFn, url, timeoutMs, cancel) {
115
+ const timeout = AbortSignal.timeout(timeoutMs);
116
+ const res = await fetchFn(url, { headers: { ...REQUEST_HEADERS }, signal: cancel ? AbortSignal.any([cancel, timeout]) : timeout, redirect: 'follow' });
117
+ if (!res.ok)
118
+ throw new Error(`HTTP ${res.status}`);
119
+ return res.json();
120
+ }
121
+ function why(e) {
122
+ const err = e;
123
+ if (err?.name === 'TimeoutError' || err?.name === 'AbortError')
124
+ return 'timed out';
125
+ return String(err?.message ?? e).slice(0, 120);
126
+ }
127
+ /** Ask both sources. Never throws; each side reports its own error. */
128
+ export async function fetchLatest(fetchFn = fetch, timeoutMs = REQUEST_TIMEOUT_MS, cancel) {
129
+ const npm = getJson(fetchFn, NPM_LATEST_URL, timeoutMs, cancel).then((b) => {
130
+ const v = b?.version;
131
+ return parseVersion(v) ? { ok: true, version: bare(v), url: `https://www.npmjs.com/package/${NPM_PACKAGE}` } : { ok: false, error: 'no version in the registry reply' };
132
+ }, (e) => ({ ok: false, error: why(e) }));
133
+ const github = getJson(fetchFn, GITHUB_LATEST_URL, timeoutMs, cancel).then((b) => {
134
+ const r = b;
135
+ if (r?.draft || r?.prerelease || !parseVersion(r?.tag_name))
136
+ return { ok: false, error: 'no published release in the reply' };
137
+ const tag = r.tag_name;
138
+ return { ok: true, version: bare(tag), url: `${RELEASES_URL}/tag/${tag}` };
139
+ }, (e) => ({ ok: false, error: why(e) }));
140
+ return { npm: await npm, github: await github };
141
+ }
142
+ /** The newest stable version either source reported, or null. */
143
+ export function newestOf(r) {
144
+ let best = null;
145
+ for (const s of [r.npm, r.github]) {
146
+ if (!s.ok || parseVersion(s.version)?.pre.length)
147
+ continue;
148
+ if (best === null || compareVersions(s.version, best) > 0)
149
+ best = s.version;
150
+ }
151
+ return best;
152
+ }
153
+ /** The version this install's channel can actually move to: npm → the registry; the rest → the GitHub release. */
154
+ export function targetFor(kind, r) {
155
+ const primary = kind === 'npm' ? r.npm : r.github;
156
+ return primary.ok ? primary.version : newestOf(r);
157
+ }
158
+ export function updateStatePath(env) {
159
+ return join(dextermHome(env), UPDATE_FILE);
160
+ }
161
+ export function readUpdateState(env) {
162
+ try {
163
+ const j = JSON.parse(readFileSync(updateStatePath(env), 'utf8'));
164
+ return {
165
+ enabled: j.enabled !== false,
166
+ checkedAt: typeof j.checkedAt === 'number' && Number.isFinite(j.checkedAt) ? j.checkedAt : null,
167
+ latest: typeof j.latest === 'string' && parseVersion(j.latest) ? bare(j.latest) : null,
168
+ };
169
+ }
170
+ catch {
171
+ return { enabled: true, checkedAt: null, latest: null };
172
+ }
173
+ }
174
+ export function writeUpdateState(env, s) {
175
+ const p = updateStatePath(env);
176
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
177
+ const tmp = `${p}.${process.pid}.tmp`;
178
+ writeFileSync(tmp, JSON.stringify(s, null, 2) + '\n', { mode: 0o600 });
179
+ renameSync(tmp, p);
180
+ }
181
+ const truthy = (v) => !!v && v !== '0' && v.toLowerCase() !== 'false';
182
+ /** Why the startup check is off, or null when it may run. `dexterm update` itself ignores all but DEXTERM_OFFLINE. */
183
+ export function startupCheckOff(env, s) {
184
+ if (truthy(env.DEXTERM_NO_UPDATE_CHECK))
185
+ return 'DEXTERM_NO_UPDATE_CHECK is set';
186
+ if (truthy(env.NO_UPDATE_NOTIFIER))
187
+ return 'NO_UPDATE_NOTIFIER is set';
188
+ if (truthy(env.DEXTERM_OFFLINE))
189
+ return 'DEXTERM_OFFLINE is set';
190
+ if (truthy(env.CI))
191
+ return 'CI is set';
192
+ if (!s.enabled)
193
+ return 'turned off with dexterm update --off';
194
+ return null;
195
+ }
196
+ /** The one-line notice for a cached result, or null. */
197
+ export function noticeFor(current, latest) {
198
+ if (!latest || parseVersion(latest)?.pre.length || compareVersions(latest, current) <= 0)
199
+ return null;
200
+ return `dexterm ${bare(latest)} is available (you have ${bare(current)}) · run dexterm update`;
201
+ }
202
+ /**
203
+ * Startup: the cached notice now, plus a background refresh when the last check is older than a day.
204
+ * The caller decides where the notice goes (the interactive loop shows it as a toast after the boot).
205
+ */
206
+ export function startupCheck(env, opts = {}) {
207
+ const s = readUpdateState(env);
208
+ const none = () => { };
209
+ if (startupCheckOff(env, s))
210
+ return { notice: null, refresh: null, cancel: none };
211
+ const current = opts.current ?? VERSION;
212
+ const now = opts.now ?? Date.now();
213
+ const notice = noticeFor(current, s.latest);
214
+ if (s.checkedAt !== null && now - s.checkedAt >= 0 && now - s.checkedAt < CHECK_EVERY_MS)
215
+ return { notice, refresh: null, cancel: none };
216
+ const ctl = new AbortController();
217
+ const refresh = fetchLatest(opts.fetchFn, REQUEST_TIMEOUT_MS, ctl.signal).then((r) => {
218
+ if (ctl.signal.aborted || (!r.npm.ok && !r.github.ok))
219
+ return null; // quit / offline / blocked: try again next launch
220
+ const latest = newestOf(r);
221
+ try {
222
+ writeUpdateState(env, { ...s, checkedAt: now, latest });
223
+ }
224
+ catch { /* read-only home: no cache */ }
225
+ // only a notice the cache did not already produce
226
+ const n = noticeFor(current, latest);
227
+ return n && n !== notice ? n : null;
228
+ }).catch(() => null);
229
+ return { notice, refresh, cancel: () => ctl.abort() };
230
+ }
231
+ export async function updateCommand(argv, io, deps = {}) {
232
+ const [arg, ...rest] = argv;
233
+ const env = io.env;
234
+ if (arg === '-h' || arg === '--help' || arg === 'help') {
235
+ io.stdout.write(UPDATE_USAGE + '\n');
236
+ return 0;
237
+ }
238
+ if (rest.length || (arg !== undefined && arg !== '--off' && arg !== '--on')) {
239
+ io.stderr.write(`dexterm update: unknown argument ${JSON.stringify(String(rest[0] ?? arg).slice(0, 20))}\n${UPDATE_USAGE}\n`);
240
+ return 2;
241
+ }
242
+ const state = readUpdateState(env);
243
+ if (arg === '--off' || arg === '--on') {
244
+ try {
245
+ writeUpdateState(env, { ...state, enabled: arg === '--on' });
246
+ }
247
+ catch (e) {
248
+ io.stderr.write(`dexterm update: ${e.message}\n`);
249
+ return 1;
250
+ }
251
+ io.stdout.write(arg === '--off'
252
+ ? `startup update check: off (saved to ${updateStatePath(env)}). dexterm update still checks when you run it.\n`
253
+ : `startup update check: on (at most once a day; saved to ${updateStatePath(env)})\n`);
254
+ return 0;
255
+ }
256
+ if (truthy(env.DEXTERM_OFFLINE)) {
257
+ io.stderr.write('dexterm update: DEXTERM_OFFLINE is set; the check needs the network\n');
258
+ return 2;
259
+ }
260
+ const current = deps.current ?? VERSION;
261
+ const kind = deps.install ?? detectInstall();
262
+ const r = await fetchLatest(deps.fetchFn);
263
+ const line = (label, s) => ` ${label.padEnd(20)} ${s.ok ? `${s.version.padEnd(10)} ${s.url}` : `unavailable (${s.error})`}\n`;
264
+ io.stdout.write(`dexterm ${current} · installed via ${INSTALL_LABEL[kind]}\n`);
265
+ io.stdout.write(line(`npm ${NPM_PACKAGE}`, r.npm));
266
+ io.stdout.write(line('GitHub release', r.github));
267
+ if (!r.npm.ok && !r.github.ok) {
268
+ io.stderr.write(`dexterm update: could not reach npm or GitHub; check your connection, or see ${RELEASES_URL}\n`);
269
+ return 1;
270
+ }
271
+ const latest = newestOf(r);
272
+ try {
273
+ writeUpdateState(env, { ...state, checkedAt: deps.now ?? Date.now(), latest });
274
+ }
275
+ catch { /* read-only home */ }
276
+ const target = targetFor(kind, r);
277
+ if (!target || compareVersions(target, current) <= 0) {
278
+ const ahead = latest && compareVersions(latest, current) > 0;
279
+ io.stdout.write(ahead
280
+ ? `You are on the newest version ${kind === 'npm' ? 'npm has' : 'the GitHub release has'} (${current}); ${latest} is out on the other channel and reaches yours shortly.\n`
281
+ : `You are up to date (${current}).\n`);
282
+ }
283
+ else {
284
+ io.stdout.write(`\ndexterm ${target} is available. Upgrade with:\n\n`);
285
+ for (const c of upgradeCommands(kind, target, { platform: deps.platform, root: deps.root }))
286
+ io.stdout.write(` ${c}\n`);
287
+ io.stdout.write('\n');
288
+ if (kind === 'tarball')
289
+ io.stdout.write(`Verify the download first: ${RELEASES_URL}/tag/v${target} (docs/verify-your-download.md). Release notes: CHANGELOG.md.\n`);
290
+ }
291
+ const off = startupCheckOff(env, readUpdateState(env));
292
+ io.stdout.write(`startup check: ${off ? `off (${off})` : 'on, at most once a day; dexterm update --off or DEXTERM_NO_UPDATE_CHECK=1 turns it off'}. No telemetry: two anonymous GETs (registry.npmjs.org, api.github.com).\n`);
293
+ return 0;
294
+ }
@@ -0,0 +1,3 @@
1
+ // The one place the app's version lives (task-1397). --version, the help banner and `dexterm update` read it;
2
+ // cli.test.ts keeps it equal to packages/cli/package.json (release.yml refuses a tag that differs).
3
+ export const VERSION = '0.1.2';
@@ -0,0 +1,119 @@
1
+ // `dexterm voice …` (task-1408): inspect the voice engines on this machine and download the two
2
+ // optional voice models — whisper.cpp base.en (speech-in) and the Piper en_GB-alan-medium voice
3
+ // (neural speech-out). Downloads are OFF by default: this is the ONLY place dexterm fetches a model,
4
+ // it asks before it does, and it verifies the download against a pinned checksum from the official
5
+ // source. Nothing here runs the signer, touches keys, or changes trading behaviour.
6
+ //
7
+ // No new dependencies: the confirm prompt is node:readline, the download uses global fetch, and the
8
+ // checksum/stream logic lives in @dexterm/voice (unit-tested with an injected fetch).
9
+ import { createInterface } from 'node:readline';
10
+ import { existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { modelRegistry, downloadModel, modelStatus, resolvePiperBin, pickPlayer, PIPER_MODEL, DEFAULT_PIPER_VOICE } from '../../voice/src/index.js';
13
+ import { dextermHome } from '../../custom/src/index.js';
14
+ export const VOICE_USAGE = [
15
+ 'usage:',
16
+ ' dexterm voice [status] show the voice engines + which models are installed',
17
+ ' dexterm voice download <model> [--yes] download a voice model (asks first; checksum-verified)',
18
+ '',
19
+ ' models: whisper whisper.cpp base.en (speech-to-text, off macOS this is the default)',
20
+ ' piper-voice Piper en_GB-alan-medium (neural speech-out, preferred over espeak-ng)',
21
+ ' all both of the above',
22
+ ].join('\n');
23
+ function onPath(bin, env) {
24
+ const sep = process.platform === 'win32' ? ';' : ':';
25
+ for (const p of (env.PATH ?? '').split(sep)) {
26
+ if (p && existsSync(join(p, bin)))
27
+ return true;
28
+ }
29
+ return false;
30
+ }
31
+ /** y/N prompt on a TTY. Without a TTY (piped/CI) it returns false — the caller tells the user to pass --yes. */
32
+ function askYesNo(io, question) {
33
+ if (!io.stdin.isTTY)
34
+ return Promise.resolve(false);
35
+ const rl = createInterface({ input: io.stdin, output: io.stdout });
36
+ return new Promise((resolve) => {
37
+ rl.question(`${question} [y/N] `, (ans) => { rl.close(); resolve(/^y(es)?$/i.test(ans.trim())); });
38
+ });
39
+ }
40
+ async function printStatus(io) {
41
+ const home = dextermHome(io.env);
42
+ const reg = modelRegistry(home);
43
+ const lines = ['dexterm voice', ''];
44
+ if (process.platform === 'darwin') {
45
+ lines.push(' speech-out macOS `say` (built-in) · see `dexterm doctor` for the resolved voice');
46
+ lines.push(' speech-in Apple Speech (swiftc) preferred, else whisper.cpp');
47
+ }
48
+ else {
49
+ const piperBin = resolvePiperBin();
50
+ const player = pickPlayer();
51
+ const piperReady = piperBin !== null && existsSync(PIPER_MODEL) && player !== null;
52
+ lines.push(` speech-out Piper ${piperReady ? `ready (${DEFAULT_PIPER_VOICE}, plays via ${player})` : 'not ready'}${piperReady ? '' : ` · espeak-ng ${onPath('espeak-ng', io.env) || onPath('espeak', io.env) ? 'ready (baseline)' : 'not installed'}`}`);
53
+ lines.push(` speech-in whisper.cpp (default off macOS) · mic tool ${['arecord', 'sox', 'rec', 'ffmpeg'].find((b) => onPath(b, io.env)) ?? 'none found'}`);
54
+ }
55
+ lines.push('', ' models:');
56
+ for (const spec of Object.values(reg)) {
57
+ const st = await modelStatus(spec);
58
+ lines.push(` ${st.installed ? '✓' : '·'} ${spec.id.padEnd(12)} ${spec.label}${st.installed ? ' (installed)' : ''}`);
59
+ }
60
+ lines.push('', ` download a model: dexterm voice download <whisper|piper-voice|all>`);
61
+ io.stdout.write(lines.join('\n') + '\n');
62
+ return 0;
63
+ }
64
+ async function runDownload(io, which, yes) {
65
+ const home = dextermHome(io.env);
66
+ const reg = modelRegistry(home);
67
+ const ids = which === 'all' ? Object.keys(reg) : [which];
68
+ const specs = [];
69
+ for (const id of ids) {
70
+ const spec = reg[id];
71
+ if (!spec) {
72
+ io.stderr.write(`dexterm voice: unknown model ${JSON.stringify(id)}\n${VOICE_USAGE}\n`);
73
+ return 2;
74
+ }
75
+ specs.push(spec);
76
+ }
77
+ if (!yes && !io.stdin.isTTY) {
78
+ io.stderr.write('dexterm voice download: no interactive terminal to confirm; re-run with --yes to approve the download.\n');
79
+ return 2;
80
+ }
81
+ let anyFailed = false;
82
+ for (const spec of specs) {
83
+ try {
84
+ const res = await downloadModel(spec, {
85
+ confirm: (prompt) => (yes ? Promise.resolve(true) : askYesNo(io, `\n${prompt}`)),
86
+ log: (s) => io.stdout.write(s + '\n'),
87
+ });
88
+ if (res.status === 'cancelled')
89
+ return 1; // the user said no — stop here
90
+ }
91
+ catch (e) {
92
+ io.stderr.write(`dexterm voice download ${spec.id}: ${e.message}\n`);
93
+ anyFailed = true;
94
+ }
95
+ }
96
+ return anyFailed ? 1 : 0;
97
+ }
98
+ /** `dexterm voice …`. Returns the process exit code. */
99
+ export async function voiceCommand(argv, io) {
100
+ const yes = argv.includes('--yes') || argv.includes('-y');
101
+ const rest = argv.filter((a) => a !== '--yes' && a !== '-y');
102
+ const sub = rest[0];
103
+ if (sub === undefined || sub === 'status')
104
+ return printStatus(io);
105
+ if (sub === 'download') {
106
+ const which = rest[1];
107
+ if (!which) {
108
+ io.stderr.write(`dexterm voice download: name a model.\n${VOICE_USAGE}\n`);
109
+ return 2;
110
+ }
111
+ return runDownload(io, which, yes);
112
+ }
113
+ if (sub === '--help' || sub === '-h' || sub === 'help') {
114
+ io.stdout.write(VOICE_USAGE + '\n');
115
+ return 0;
116
+ }
117
+ io.stderr.write(`dexterm voice: unknown subcommand ${JSON.stringify(sub)}\n${VOICE_USAGE}\n`);
118
+ return 2;
119
+ }
@@ -0,0 +1,190 @@
1
+ // dexterm — `dexterm watch` (task-1398): run the rules engine against the live feed with NO TUI and PUSH
2
+ // every fired alert (and, when alerts.proposals is on, a one-line proposal summary) through the delivery
3
+ // layer — so a price / holder / LARP / new-pair alert reaches you when the terminal is closed.
4
+ //
5
+ // dexterm watch [--interval S] [--ticks N | --once] [--replay FILE] [--db PATH] [--verbose]
6
+ //
7
+ // SAFETY: this builds NO signer and passes no `trade` option; a rule can only alert / annotate / open an
8
+ // inert PROPOSAL (RulesEngine's money-safety invariant). The notifier only reads an AlertDelivery. There is
9
+ // no arm/sign/confirm/cap path anywhere under here (asserted by alerts-1398.test.ts).
10
+ import { dextermHome, loadConfig, RulesEngine } from '../../custom/src/index.js';
11
+ import { openHistoryReadOnly, LARP_PACK } from '../../recorder/src/index.js';
12
+ import { buildNotifier } from "./alerts/notifier.js";
13
+ import { alertToDelivery, proposalToDelivery } from "./alerts/format.js";
14
+ import { redactCreds } from "./alerts/creds.js";
15
+ import { larpGrade } from "./rules.js";
16
+ export const WATCH_USAGE = `usage: dexterm watch [--interval S] [--ticks N | --once] [--replay FILE] [--db PATH] [--verbose]
17
+
18
+ Runs your rules headless and pushes alerts to the channels you enabled with \`dexterm alerts\`
19
+ (terminal line + desktop notification + your Telegram bot / Discord webhook). Ctrl-C to stop.
20
+ `;
21
+ function parseArgs(argv) {
22
+ const a = { intervalMs: 15_000, ticks: Infinity, replay: null, db: null, verbose: false, help: false };
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const t = argv[i];
25
+ if (t === '--help' || t === '-h')
26
+ a.help = true;
27
+ else if (t === '--verbose' || t === '-v')
28
+ a.verbose = true;
29
+ else if (t === '--once')
30
+ a.ticks = 1;
31
+ else if (t === '--interval') {
32
+ const n = Number(argv[++i]);
33
+ if (!Number.isFinite(n) || n < 1)
34
+ return { error: '--interval needs seconds ≥ 1' };
35
+ a.intervalMs = Math.round(n * 1000);
36
+ }
37
+ else if (t === '--ticks') {
38
+ const n = Number(argv[++i]);
39
+ if (!Number.isInteger(n) || n < 1)
40
+ return { error: '--ticks needs an integer ≥ 1' };
41
+ a.ticks = n;
42
+ }
43
+ else if (t === '--replay') {
44
+ a.replay = argv[++i] ?? null;
45
+ if (!a.replay)
46
+ return { error: '--replay needs a FILE' };
47
+ }
48
+ else if (t === '--db') {
49
+ a.db = argv[++i] ?? null;
50
+ if (!a.db)
51
+ return { error: '--db needs a PATH' };
52
+ }
53
+ else
54
+ return { error: `unknown option "${t}"` };
55
+ }
56
+ return a;
57
+ }
58
+ /** Latest LARP verdict per mint from a read-only recorder DB (best effort; empty map on any problem). */
59
+ function larpMapFromDb(path) {
60
+ const map = new Map();
61
+ if (!path || path === ':memory:')
62
+ return map;
63
+ const db = openHistoryReadOnly(path);
64
+ if (!db)
65
+ return map;
66
+ try {
67
+ const rows = db.prepare('SELECT mint, verdict FROM verdicts WHERE pack = ? AND verdict IS NOT NULL ORDER BY t').all(LARP_PACK);
68
+ for (const r of rows)
69
+ map.set(r.mint, r.verdict); // ORDER BY t → last write per mint wins
70
+ }
71
+ catch { /* table may not exist yet */ }
72
+ finally {
73
+ try {
74
+ db.close();
75
+ }
76
+ catch { /* ignore */ }
77
+ }
78
+ return map;
79
+ }
80
+ /** Map a screener row to a rule row (cluster is only known with the wallet-intel scan, absent headless; larp from the DB). */
81
+ export function toRuleRow(r, larp) {
82
+ return Object.assign(Object.create(null), r, { cluster: null, larp: larpGrade(larp.get(r.mint)) });
83
+ }
84
+ /** The production source: refresh the live (or replay) feed, map rows, yield a snapshot. Replay yields once. */
85
+ function createFeedSource(opts) {
86
+ let feed = null;
87
+ let yielded = 0;
88
+ const larp = larpMapFromDb(opts.db);
89
+ return {
90
+ async next() {
91
+ if (opts.stop.aborted)
92
+ return null;
93
+ const { createLiveFeed, createReplayFeed } = await import("./feed.js");
94
+ if (!feed)
95
+ feed = opts.replay ? createReplayFeed(opts.replay, {}) : createLiveFeed({ dbPath: opts.db ?? undefined });
96
+ else {
97
+ if (opts.replay)
98
+ return null; // a recording is a single snapshot: one evaluate, then stop
99
+ await opts.stop.wait(opts.intervalMs);
100
+ if (opts.stop.aborted)
101
+ return null;
102
+ }
103
+ const rows = await feed.refresh();
104
+ yielded++;
105
+ if (opts.verbose)
106
+ opts.io.stderr.write(`watch: tick ${yielded}, ${rows.length} rows\n`);
107
+ return { rows: rows.map((r) => toRuleRow(r, larp)), now: opts.now() };
108
+ },
109
+ close() { try {
110
+ feed?.close();
111
+ }
112
+ catch { /* ignore */ } },
113
+ };
114
+ }
115
+ export async function watchCommand(argv, io, deps = {}) {
116
+ const parsed = parseArgs(argv);
117
+ if ('error' in parsed) {
118
+ io.stderr.write(`dexterm watch: ${parsed.error}\n\n${WATCH_USAGE}`);
119
+ return 2;
120
+ }
121
+ if (parsed.help) {
122
+ io.stdout.write(WATCH_USAGE);
123
+ return 0;
124
+ }
125
+ const now = deps.now ?? (() => Date.now());
126
+ const home = dextermHome(io.env);
127
+ const loaded = loadConfig(home);
128
+ for (const e of loaded.errors)
129
+ io.stderr.write(`dexterm watch: config: ${e}\n`);
130
+ const rules = loaded.config.rules ?? [];
131
+ const engine = new RulesEngine({ rules, now: now() });
132
+ for (const e of engine.errors)
133
+ io.stderr.write(`dexterm watch: rule ${e.id}: ${e.msg}\n`);
134
+ const enabled = engine.rules.filter((r) => r.enabled).length;
135
+ if (enabled === 0)
136
+ io.stderr.write('dexterm watch: no enabled rules (add some with `dexterm rules add`); nothing will fire\n');
137
+ // SIGINT-aware sleeper for the production loop.
138
+ const stop = (() => {
139
+ let aborted = false;
140
+ let wake = () => { };
141
+ return {
142
+ get aborted() { return aborted; },
143
+ abort() { aborted = true; wake(); },
144
+ wake() { wake(); },
145
+ wait(ms) { return new Promise((res) => { const t = setTimeout(res, ms); wake = () => { clearTimeout(t); res(); }; }); },
146
+ };
147
+ })();
148
+ const notifier = deps.notifier ?? buildNotifier(home, loaded.config.alerts, {
149
+ toastSink: (line) => io.stdout.write(`${new Date(now()).toISOString().slice(11, 19)} ${line}\n`),
150
+ });
151
+ const source = deps.source ?? createFeedSource({ replay: parsed.replay, db: parsed.db, intervalMs: parsed.intervalMs, verbose: parsed.verbose, io, now, stop });
152
+ const ready = notifier.readyChannels();
153
+ if (parsed.verbose || !deps.notifier)
154
+ io.stderr.write(`dexterm watch: ${enabled} rule(s), channels: ${ready.length ? ready.join(', ') : 'toast'}\n`);
155
+ const onSigint = () => { io.stderr.write('\ndexterm watch: stopping…\n'); stop.abort(); };
156
+ const addSig = !deps.source; // only own SIGINT for the real loop (tests drive a finite source)
157
+ if (addSig)
158
+ process.on('SIGINT', onSigint);
159
+ let delivered = 0;
160
+ try {
161
+ for (let tick = 0; tick < parsed.ticks; tick++) {
162
+ const snap = await source.next();
163
+ if (!snap)
164
+ break;
165
+ engine.observe(snap.rows, snap.now);
166
+ const res = engine.evaluate(snap.rows, snap.now, { watchlist: snap.watchlist, positions: snap.positions });
167
+ for (const a of res.alerts) {
168
+ await notifier.deliver(alertToDelivery(a));
169
+ delivered++;
170
+ }
171
+ if (loaded.config.alerts?.proposals)
172
+ for (const p of res.proposals) {
173
+ await notifier.deliver(proposalToDelivery(p));
174
+ delivered++;
175
+ }
176
+ }
177
+ }
178
+ catch (e) {
179
+ io.stderr.write(`dexterm watch: ${redactCreds(e.message)}\n`);
180
+ return 1;
181
+ }
182
+ finally {
183
+ if (addSig)
184
+ process.removeListener('SIGINT', onSigint);
185
+ await source.close?.();
186
+ }
187
+ if (parsed.verbose || !deps.notifier)
188
+ io.stderr.write(`dexterm watch: delivered ${delivered} alert(s)\n`);
189
+ return 0;
190
+ }
@@ -3,7 +3,7 @@ export const FIELDS = {
3
3
  vol5m: (r) => r.vol5m, vol1h: (r) => r.vol1h, vol24: (r) => r.vol24,
4
4
  holders: (r) => r.holders, org: (r) => r.org, top10: (r) => r.top10, dev: (r) => r.dev,
5
5
  lplocked: (r) => r.lpLocked, bundled: (r) => r.bundled, snipers: (r) => r.snipers,
6
- cluster: (r) => r.cluster,
6
+ cluster: (r) => r.cluster, larp: (r) => r.larp,
7
7
  age: (r) => r.ageMin, m5: (r) => r.chg?.m5, h1: (r) => r.chg?.h1, h6: (r) => r.chg?.h6, h24: (r) => r.chg?.h24,
8
8
  buys: (r) => r.buys, sells: (r) => r.sells, bonding: (r) => r.bonding,
9
9
  };
@@ -199,6 +199,9 @@ export const SCHEMAS = {
199
199
  token: { type: 'object', additional: false, props: { tape: { type: 'boolean' }, social: { type: 'boolean' }, analyst: { type: 'boolean' } } },
200
200
  } },
201
201
  screenLayout: { oneOf: ['layoutNode', { type: 'object', additional: false, props: { wide: 'layoutNode', standard: 'layoutNode', compact: 'layoutNode' } }] },
202
+ /** task-1398: one alert-delivery channel. A `levels` filter (subset of up|down|info|warn); absent = every level. */
203
+ alertLevels: { type: 'array', max: 4, items: { type: 'string', enum: ['up', 'down', 'info', 'warn'] } },
204
+ alertChannel: { type: 'object', additional: false, props: { enabled: { type: 'boolean' }, levels: 'alertLevels' } },
202
205
  ruleJson: { type: 'object', additional: false, required: ['id', 'when', 'then'], props: {
203
206
  id: { type: 'string', pattern: NAME },
204
207
  name: { type: 'string', max: 80 },
@@ -258,6 +261,26 @@ export const SCHEMAS = {
258
261
  /** Reserved: the trading gate is NOT here (signer policy owns it); listed so a proposal that tries gets a precise refusal. */
259
262
  gate: { type: 'string', enum: ['keypress'] },
260
263
  } },
264
+ /** task-1393: voice-out preferences. `/voice` is user-only (DENIED_FOR_MODELS), so only the keyboard sets it. */
265
+ voice: { type: 'object', additional: false, props: {
266
+ name: { type: 'string', max: 64 },
267
+ rate: { type: 'number', int: true, min: 80, max: 400 },
268
+ pitch: { type: 'number', int: true, min: 0, max: 100 },
269
+ } },
270
+ /**
271
+ * task-1398: alert delivery routing. Enable flags + a per-channel `levels` filter ONLY; NO credentials here
272
+ * (bot tokens / webhook URLs live in ~/.dexterm/telegram.json and discord.json, 0600, like every other key).
273
+ * `/alerts` is user-only (DENIED_FOR_MODELS) so a model can never redirect your alerts to its own channel.
274
+ */
275
+ alerts: { type: 'object', additional: false, props: {
276
+ channels: { type: 'object', additional: false, props: {
277
+ toast: 'alertChannel', desktop: 'alertChannel',
278
+ telegram: { type: 'object', additional: false, props: { enabled: { type: 'boolean' }, levels: 'alertLevels', chatId: { type: 'string', pattern: '^-?[0-9]{1,19}$' } } },
279
+ discord: 'alertChannel',
280
+ } },
281
+ /** Deliver a one-line summary of ticket/ask PROPOSALS too (never the ticket itself; off by default). */
282
+ proposals: { type: 'boolean' },
283
+ } },
261
284
  scan: { type: 'object', additional: true },
262
285
  popups: { type: 'object', additional: false, props: { rain: { type: 'object', additional: false, props: { enabled: { type: 'boolean' }, speedScale: { type: 'number', min: 0, max: 4 }, density: { type: 'number', min: 0, max: 1 } } } } },
263
286
  /** Forward-compat bag: unknown sections from a newer dexterm survive a downgrade here. */
@@ -403,7 +426,7 @@ export function applyOps(doc, ops) {
403
426
  export const DENIED_FOR_MODELS = [
404
427
  /^\/signer(\/|$)/, /^\/trading(\/|$)/, /^\/keys(\/|$)/, /^\/caps(\/|$)/, /^\/mandates?(\/|$)/, /^\/wallet(\/|$)/,
405
428
  /^\/packs(\/|$)/, /^\/plugins\/[^/]+\/(capabilities|enabled)(\/|$)/, /^\/plugins\/[^/]+$/, /^\/plugins$/,
406
- /^\/jarvis\/gate(\/|$)/, /^\/jarvis\/configAssistant$/, /^\/voice(\/|$)/, /^\/agents(\/|$)/, /^\/version$/,
429
+ /^\/jarvis\/gate(\/|$)/, /^\/jarvis\/configAssistant$/, /^\/voice(\/|$)/, /^\/alerts(\/|$)/, /^\/agents(\/|$)/, /^\/version$/,
407
430
  /^\/rules\/(\d+|-)\/then\/(\d+|-)\/args\/(arm|armed|auto|autoArm|confirm|execute|sign|send)(\/|$)/,
408
431
  ];
409
432
  /** Keys a model-authored VALUE may never contain at any depth (money/secret-shaped), whatever the path. */