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,10 @@
1
+ // dexterm — alert delivery subsystem (task-1398). See DESIGN.md.
2
+ export * from "./types.js";
3
+ export * from "./format.js";
4
+ export * from "./route.js";
5
+ export * from "./creds.js";
6
+ export * from "./notifier.js";
7
+ export { createToastAdapter } from "./adapters/toast.js";
8
+ export { createDesktopAdapter } from "./adapters/desktop.js";
9
+ export { createTelegramAdapter } from "./adapters/telegram.js";
10
+ export { createDiscordAdapter } from "./adapters/discord.js";
@@ -0,0 +1,53 @@
1
+ import { CHANNELS } from "./types.js";
2
+ import { routeChannels } from "./route.js";
3
+ import { redactCreds, readTelegramCreds, readDiscordCreds } from "./creds.js";
4
+ import { createToastAdapter } from "./adapters/toast.js";
5
+ import { createDesktopAdapter } from "./adapters/desktop.js";
6
+ import { createTelegramAdapter } from "./adapters/telegram.js";
7
+ import { createDiscordAdapter } from "./adapters/discord.js";
8
+ export function createNotifier(cfg, adapters) {
9
+ return {
10
+ async deliver(d) {
11
+ const results = [];
12
+ for (const name of routeChannels(cfg, d)) {
13
+ const ad = adapters[name];
14
+ if (!ad) {
15
+ results.push({ channel: name, ok: false, skipped: true, reason: 'no adapter' });
16
+ continue;
17
+ }
18
+ if (!ad.available()) {
19
+ results.push({ channel: name, ok: false, skipped: true, reason: `${name} not configured` });
20
+ continue;
21
+ }
22
+ try {
23
+ results.push({ channel: name, ...(await ad.deliver(d)) });
24
+ }
25
+ catch (e) {
26
+ results.push({ channel: name, ok: false, reason: redactCreds(e.message) });
27
+ }
28
+ }
29
+ return results;
30
+ },
31
+ readyChannels(level) {
32
+ const probe = { kind: 'alert', level: level ?? 'info' };
33
+ return routeChannels(cfg, probe).filter((n) => adapters[n]?.available());
34
+ },
35
+ };
36
+ }
37
+ /** Build a Notifier with the real adapters: toast + desktop + telegram/discord read from ~/.dexterm. */
38
+ export function buildNotifier(home, cfg, deps) {
39
+ const adapters = {
40
+ toast: createToastAdapter(deps.toastSink),
41
+ desktop: createDesktopAdapter(deps.desktop),
42
+ telegram: createTelegramAdapter({ creds: readTelegramCreds(home), fetch: deps.fetch }),
43
+ discord: createDiscordAdapter({ creds: readDiscordCreds(home), fetch: deps.fetch }),
44
+ };
45
+ return createNotifier(cfg, adapters);
46
+ }
47
+ /** True when config enables at least one PUSH channel (desktop/telegram/discord) — i.e. a Notifier is worth building. */
48
+ export function hasPushChannel(cfg) {
49
+ const ch = cfg?.channels;
50
+ if (!ch)
51
+ return false;
52
+ return CHANNELS.filter((c) => c !== 'toast').some((c) => ch[c]?.enabled === true);
53
+ }
@@ -0,0 +1,19 @@
1
+ import { CHANNELS } from "./types.js";
2
+ /** The channels a delivery is routed to, given config. Pure; order is CHANNELS order. */
3
+ export function routeChannels(cfg, d) {
4
+ if (d.kind === 'proposal' && !cfg?.proposals)
5
+ return [];
6
+ const channels = cfg?.channels ?? {};
7
+ const out = [];
8
+ for (const name of CHANNELS) {
9
+ const c = channels[name];
10
+ const enabled = name === 'toast' ? c?.enabled !== false : c?.enabled === true;
11
+ if (!enabled)
12
+ continue;
13
+ const levels = c?.levels;
14
+ if (levels && levels.length > 0 && !levels.includes(d.level))
15
+ continue;
16
+ out.push(name);
17
+ }
18
+ return out;
19
+ }
@@ -0,0 +1 @@
1
+ export const CHANNELS = ['toast', 'desktop', 'telegram', 'discord'];
@@ -0,0 +1,212 @@
1
+ // dexterm — `dexterm alerts …` (task-1398): configure WHERE rule alerts are delivered. No model, and the
2
+ // only network is an explicit `dexterm alerts test`. Enable flags + the per-channel `levels` filter live in
3
+ // config.json (user-only path); the Telegram bot token / Discord webhook URL are SECRETS and never touch
4
+ // config.json, argv or a log — they come from an env var or stdin and are written to ~/.dexterm/<channel>.json
5
+ // (0600). The command echoes only how many characters were stored, never the value.
6
+ //
7
+ // dexterm alerts [status] | enable|disable <toast|desktop|telegram|discord> | levels <channel> <list|all>
8
+ // | proposals <on|off> | set-telegram --chat <id> | set-discord | unset-telegram|unset-discord | test [channel]
9
+ import { dextermHome, loadConfig, applyAndJournal } from '../../custom/src/index.js';
10
+ import { readTelegramCreds, writeTelegramCreds, readDiscordCreds, writeDiscordCreds, telegramCredsPath, discordCredsPath, credsMode, } from "./alerts/creds.js";
11
+ import { buildNotifier } from "./alerts/notifier.js";
12
+ import { CHANNELS } from "./alerts/types.js";
13
+ import { unlinkSync } from 'node:fs';
14
+ export const ALERTS_USAGE = `usage: dexterm alerts [status]
15
+ dexterm alerts enable|disable <toast|desktop|telegram|discord>
16
+ dexterm alerts levels <channel> <up,down,info,warn | all>
17
+ dexterm alerts proposals <on|off>
18
+ dexterm alerts set-telegram --chat <id> (token from $DEXTERM_TELEGRAM_TOKEN or stdin; never on argv)
19
+ dexterm alerts set-discord (webhook from $DEXTERM_DISCORD_WEBHOOK or stdin; never on argv)
20
+ dexterm alerts unset-telegram | unset-discord
21
+ dexterm alerts test [channel] (send a sample alert through the enabled channels)
22
+
23
+ Credentials are stored in ~/.dexterm/telegram.json / discord.json (0600), never in config.json or a log.
24
+ `;
25
+ const LEVELS = ['up', 'down', 'info', 'warn'];
26
+ const isChannel = (s) => CHANNELS.includes(s);
27
+ /** Read a secret from an env var, else one line of stdin. Never printed. Returns null when neither is available. */
28
+ async function defaultReadSecret(io, envVar) {
29
+ const fromEnv = io.env[envVar];
30
+ if (typeof fromEnv === 'string' && fromEnv.trim())
31
+ return fromEnv.trim();
32
+ const stdin = io.stdin;
33
+ if (!stdin || stdin.isTTY) {
34
+ io.stderr.write(`dexterm alerts: set ${envVar} or pipe the value on stdin (never pass a secret on the command line)\n`);
35
+ return null;
36
+ }
37
+ return new Promise((resolve) => {
38
+ let buf = '';
39
+ stdin.setEncoding('utf8');
40
+ stdin.on('data', (c) => { buf += c; });
41
+ stdin.on('end', () => resolve(buf.split('\n')[0].trim() || null));
42
+ stdin.on('error', () => resolve(null));
43
+ });
44
+ }
45
+ /** Patch config.alerts as a whole (so a missing /alerts section is created, not a missing-parent error). */
46
+ function setAlerts(home, mutate, why) {
47
+ const loaded = loadConfig(home);
48
+ const cur = loaded.config.alerts ? structuredClone(loaded.config.alerts) : {};
49
+ const next = mutate(cur);
50
+ const op = loaded.config.alerts === undefined ? 'add' : 'replace';
51
+ const { config } = applyAndJournal(home, [{ op, path: '/alerts', value: next }], { actor: 'user', why });
52
+ return config;
53
+ }
54
+ function renderStatus(home, cfg, io) {
55
+ io.stdout.write('alerts — delivery channels\n');
56
+ const ch = cfg?.channels ?? {};
57
+ for (const name of CHANNELS) {
58
+ const c = ch[name];
59
+ const on = name === 'toast' ? c?.enabled !== false : c?.enabled === true;
60
+ const levels = c?.levels && c.levels.length ? ` [${c.levels.join(',')}]` : '';
61
+ let detail = '';
62
+ if (name === 'telegram') {
63
+ const creds = readTelegramCreds(home);
64
+ detail = creds ? ` chat ${creds.chatId}, token set` : ' (no token — set-telegram)';
65
+ }
66
+ else if (name === 'discord') {
67
+ const creds = readDiscordCreds(home);
68
+ detail = creds ? ' webhook set' : ' (no webhook — set-discord)';
69
+ }
70
+ else if (name === 'desktop')
71
+ detail = process.platform === 'darwin' ? ' (osascript)' : process.platform === 'linux' ? ' (notify-send)' : ' (unsupported OS)';
72
+ io.stdout.write(` ${on ? '●' : '○'} ${name.padEnd(9)}${on ? 'on ' : 'off'}${levels}${detail}\n`);
73
+ }
74
+ io.stdout.write(` proposals: ${cfg?.proposals ? 'delivered' : 'in-app only'}\n`);
75
+ for (const [label, p] of [['telegram.json', telegramCredsPath(home)], ['discord.json', discordCredsPath(home)]]) {
76
+ const mode = credsMode(p);
77
+ if (mode !== null && mode !== 0o600)
78
+ io.stdout.write(` ⚠ ${label} is mode 0${mode.toString(8)} (should be 0600; chmod 600 it)\n`);
79
+ }
80
+ }
81
+ export async function alertsCommand(argv, io, deps = {}) {
82
+ const home = dextermHome(io.env);
83
+ const cmd = argv[0] ?? 'status';
84
+ if (cmd === '--help' || cmd === '-h') {
85
+ io.stdout.write(ALERTS_USAGE);
86
+ return 0;
87
+ }
88
+ try {
89
+ switch (cmd) {
90
+ case 'status':
91
+ renderStatus(home, loadConfig(home).config.alerts, io);
92
+ return 0;
93
+ case 'enable':
94
+ case 'disable': {
95
+ const name = argv[1];
96
+ if (!name || !isChannel(name)) {
97
+ io.stderr.write(`dexterm alerts: ${cmd} <${CHANNELS.join('|')}>\n`);
98
+ return 2;
99
+ }
100
+ const on = cmd === 'enable';
101
+ if (on && name === 'telegram' && !readTelegramCreds(home))
102
+ io.stderr.write('note: telegram has no token yet — run `dexterm alerts set-telegram --chat <id>`\n');
103
+ if (on && name === 'discord' && !readDiscordCreds(home))
104
+ io.stderr.write('note: discord has no webhook yet — run `dexterm alerts set-discord`\n');
105
+ setAlerts(home, (a) => { const ch = { ...(a.channels ?? {}) }; ch[name] = { ...(ch[name] ?? {}), enabled: on }; return { ...a, channels: ch }; }, `${cmd} alerts ${name}`);
106
+ io.stdout.write(`${name} ${on ? 'enabled' : 'disabled'}\n`);
107
+ return 0;
108
+ }
109
+ case 'levels': {
110
+ const name = argv[1];
111
+ const spec = argv[2];
112
+ if (!name || !isChannel(name) || !spec) {
113
+ io.stderr.write('dexterm alerts: levels <channel> <up,down,info,warn | all>\n');
114
+ return 2;
115
+ }
116
+ let levels;
117
+ if (spec !== 'all') {
118
+ const parts = spec.split(',').map((s) => s.trim()).filter(Boolean);
119
+ const bad = parts.filter((p) => !LEVELS.includes(p));
120
+ if (bad.length || !parts.length) {
121
+ io.stderr.write(`dexterm alerts: bad levels "${spec}" (use ${LEVELS.join(',')} or all)\n`);
122
+ return 2;
123
+ }
124
+ levels = parts;
125
+ }
126
+ setAlerts(home, (a) => { const ch = { ...(a.channels ?? {}) }; const c = { ...(ch[name] ?? {}) }; if (levels)
127
+ c.levels = levels;
128
+ else
129
+ delete c.levels; ch[name] = c; return { ...a, channels: ch }; }, `alerts levels ${name}`);
130
+ io.stdout.write(`${name} levels: ${levels ? levels.join(',') : 'all'}\n`);
131
+ return 0;
132
+ }
133
+ case 'proposals': {
134
+ const v = argv[1];
135
+ if (v !== 'on' && v !== 'off') {
136
+ io.stderr.write('dexterm alerts: proposals <on|off>\n');
137
+ return 2;
138
+ }
139
+ setAlerts(home, (a) => ({ ...a, proposals: v === 'on' }), `alerts proposals ${v}`);
140
+ io.stdout.write(`proposal delivery ${v === 'on' ? 'on' : 'off'}\n`);
141
+ return 0;
142
+ }
143
+ case 'set-telegram': {
144
+ const ci = argv.indexOf('--chat');
145
+ const chatId = ci >= 0 ? argv[ci + 1] : undefined;
146
+ if (!chatId) {
147
+ io.stderr.write('dexterm alerts: set-telegram --chat <id>\n');
148
+ return 2;
149
+ }
150
+ const token = await (deps.readSecret ? deps.readSecret('telegram bot token') : defaultReadSecret(io, 'DEXTERM_TELEGRAM_TOKEN'));
151
+ if (!token)
152
+ return 2;
153
+ writeTelegramCreds(home, { token, chatId });
154
+ io.stdout.write(`telegram token stored (${token.length} chars) for chat ${chatId} in ~/.dexterm/telegram.json (0600)\n`);
155
+ return 0;
156
+ }
157
+ case 'set-discord': {
158
+ const webhook = await (deps.readSecret ? deps.readSecret('discord webhook url') : defaultReadSecret(io, 'DEXTERM_DISCORD_WEBHOOK'));
159
+ if (!webhook)
160
+ return 2;
161
+ writeDiscordCreds(home, { webhook });
162
+ io.stdout.write(`discord webhook stored (${webhook.length} chars) in ~/.dexterm/discord.json (0600)\n`);
163
+ return 0;
164
+ }
165
+ case 'unset-telegram': {
166
+ try {
167
+ unlinkSync(telegramCredsPath(home));
168
+ }
169
+ catch { /* ignore */ }
170
+ io.stdout.write('telegram credentials removed\n');
171
+ return 0;
172
+ }
173
+ case 'unset-discord': {
174
+ try {
175
+ unlinkSync(discordCredsPath(home));
176
+ }
177
+ catch { /* ignore */ }
178
+ io.stdout.write('discord credentials removed\n');
179
+ return 0;
180
+ }
181
+ case 'test': {
182
+ const only = argv[1];
183
+ if (only && !isChannel(only)) {
184
+ io.stderr.write(`dexterm alerts: test [<${CHANNELS.join('|')}>]\n`);
185
+ return 2;
186
+ }
187
+ const cfg = loadConfig(home).config.alerts;
188
+ const notifier = deps.notifier ?? buildNotifier(home, cfg, { toastSink: (line) => io.stdout.write(`toast: ${line}\n`) });
189
+ const sample = {
190
+ kind: 'alert', level: 'info', title: 'dexterm ⚑ test', body: 'alert delivery test — if you see this, the channel works.',
191
+ spoken: 'dexterm test alert', symbol: 'dexterm', mint: '', source: 'user', rule: 'alerts-test', at: Date.now(),
192
+ };
193
+ const results = await notifier.deliver(sample);
194
+ const shown = only ? results.filter((r) => r.channel === only) : results;
195
+ if (!shown.length) {
196
+ io.stdout.write('no channel routed this alert (enable one first)\n');
197
+ return 0;
198
+ }
199
+ for (const r of shown)
200
+ io.stdout.write(` ${r.ok ? '✓' : r.skipped ? '–' : '✗'} ${r.channel}${r.reason ? `: ${r.reason}` : ''}\n`);
201
+ return shown.every((r) => r.ok || r.skipped) ? 0 : 1;
202
+ }
203
+ default:
204
+ io.stderr.write(`dexterm alerts: unknown subcommand "${cmd}"\n\n${ALERTS_USAGE}`);
205
+ return 2;
206
+ }
207
+ }
208
+ catch (e) {
209
+ io.stderr.write(`dexterm alerts: ${e.message}\n`);
210
+ return 1;
211
+ }
212
+ }
@@ -1,14 +1,26 @@
1
- import { defaultDbPath } from '../../data/src/index.js';
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { defaultDbPath, LINK_RUBRIC_V0, DEFAULT_LINK_RUBRIC } from '../../data/src/index.js';
2
4
  import { exportRecorded, openHistoryReadOnly, readLarpJoin } from '../../recorder/src/index.js';
3
- import { runBacktest, h1Model, larpEval, renderLarpEvalText } from '../../packs/src/index.js';
5
+ import { runBacktest, h1Model, larpEval, renderLarpEvalText, skillBacktest, renderSkillBacktestText, tuneLinkRubric, tuneRugRules, RUG_RULES_V0, RUG_RULES_V1, SKILL_EVAL_SCHEMA, } from '../../packs/src/index.js';
6
+ import { buildSkillEvalSet } from "./skill-eval-build.js";
7
+ /** task-1401: the committed eval set the skill backtest reads by default. */
8
+ export const DEFAULT_SKILL_EVAL = fileURLToPath(new URL('../../packs/eval/skills-eval.json', import.meta.url));
4
9
  const HORIZONS = [3600, 7200, 21600, 86400, 259200];
5
10
  export const BACKTEST_HELP = `dexterm backtest: score the rug model against recorded outcomes (task-1356). No network, no model.
6
11
 
7
12
  dexterm backtest [--subject heuristic|pack_verdicts|larp] [--horizon SEC] [--db PATH] [--json]
13
+ dexterm backtest --subject skills [--eval FILE] [--tune] [--report FILE] [--json]
14
+ dexterm backtest --build-eval FILE [--db PATH] [--horizon SEC] [--max-lag SEC]
8
15
 
9
16
  --subject heuristic (default, the pre-registered H1 rug model), pack_verdicts (recorded pack rug_p), or
10
17
  larp (task-1359: recorded LARP-detector verdicts vs the outcome labels; counts only below
11
18
  50 matched verdicts, 95% Wilson intervals above; agreement with bad outcomes, not ground truth).
19
+ skills task-1401: the default analyst skills' replayable rules over the recorded eval set (the LARP
20
+ check's links-only baseline, the rug-check verdict rules), per-grade precision/recall on a train
21
+ and a held-out TEST split. Default eval: packages/packs/eval/skills-eval.json. --tune re-tunes on
22
+ the train split only and prints before (v0) vs tuned; --report writes the JSON report.
23
+ --build-eval build that eval set from a recorder DB (read-only; no network; no URLs or names written).
12
24
  --horizon outcome horizon in seconds: ${HORIZONS.join(', ')} (default 3600 = 1h).
13
25
  --json print the full result object instead of the human table.
14
26
  --db defaults to DEXTERM_DB_PATH, else ~/.local/share/dexterm/dexterm.db.
@@ -25,7 +37,7 @@ function parse(argv) {
25
37
  continue;
26
38
  const k = a.slice(2);
27
39
  const v = argv[i + 1];
28
- if (v !== undefined && !v.startsWith('--') && k !== 'json') {
40
+ if (v !== undefined && !v.startsWith('--') && k !== 'json' && k !== 'tune') {
29
41
  opts[k] = v;
30
42
  i++;
31
43
  }
@@ -86,6 +98,8 @@ export function backtestCommand(argv, io) {
86
98
  io.stdout.write(BACKTEST_HELP + '\n');
87
99
  return 0;
88
100
  }
101
+ if (opts.subject === 'skills')
102
+ return skillsCommand(opts, io);
89
103
  const path = typeof opts.db === 'string' ? opts.db : (io.env.DEXTERM_DB_PATH || defaultDbPath(io.env));
90
104
  if (path === ':memory:') {
91
105
  io.stderr.write('dexterm backtest: an in-memory DB has no history\n');
@@ -100,6 +114,13 @@ export function backtestCommand(argv, io) {
100
114
  io.stderr.write(`dexterm backtest: no recorded history in ${path} (run the app with the recorder on first)\n`);
101
115
  return 2;
102
116
  }
117
+ if (typeof opts['build-eval'] === 'string') {
118
+ const maxLag = typeof opts['max-lag'] === 'string' ? Number(opts['max-lag']) : undefined;
119
+ const set = buildSkillEvalSet(db, { horizonSec: typeof opts.horizon === 'string' ? horizon : 259_200, maxLagSec: Number.isFinite(maxLag) ? maxLag : undefined });
120
+ writeFileSync(opts['build-eval'], JSON.stringify(set, null, 1) + '\n');
121
+ io.stdout.write(`eval set: ${set.counts.rows} rows (train ${set.counts.train}, bad ${set.counts.bad_train} · test ${set.counts.test}, bad ${set.counts.bad_test}) → ${opts['build-eval']}\nexcluded: ${Object.entries(set.counts.excluded).map(([k, n]) => `${k} ${n}`).join(' · ')}\n`);
122
+ return 0;
123
+ }
103
124
  if (opts.subject === 'larp') {
104
125
  const r = larpEval(readLarpJoin(db, horizon));
105
126
  if (opts.json)
@@ -147,3 +168,41 @@ export function backtestCommand(argv, io) {
147
168
  catch { /* ignore */ }
148
169
  }
149
170
  }
171
+ /**
172
+ * task-1401: `--subject skills`. Before = the v0 rules written from the skill text before any backtest; after =
173
+ * the shipped tuned rules, or (with --tune) a fresh train-split tune. Reads only the eval JSON.
174
+ */
175
+ function skillsCommand(opts, io) {
176
+ const file = typeof opts.eval === 'string' ? opts.eval : DEFAULT_SKILL_EVAL;
177
+ let set;
178
+ if (file === DEFAULT_SKILL_EVAL && !existsSync(file)) {
179
+ // the npm build ships no eval data (packages/packs/eval is repo-only): build one from your own history
180
+ io.stderr.write('dexterm backtest: the bundled eval set is not part of this install. Build one from your own recorder history:\n dexterm backtest --build-eval ./skills-eval.json && dexterm backtest --subject skills --eval ./skills-eval.json\n');
181
+ return 2;
182
+ }
183
+ try {
184
+ set = JSON.parse(readFileSync(file, 'utf8'));
185
+ }
186
+ catch (e) {
187
+ io.stderr.write(`dexterm backtest: cannot read eval set ${file}: ${e instanceof Error ? e.message : String(e)}\n`);
188
+ return 2;
189
+ }
190
+ if (set.schema !== SKILL_EVAL_SCHEMA || !Array.isArray(set.rows)) {
191
+ io.stderr.write(`dexterm backtest: ${file} is not a ${SKILL_EVAL_SCHEMA} file\n`);
192
+ return 2;
193
+ }
194
+ let larpAfter = DEFAULT_LINK_RUBRIC, rugAfter = RUG_RULES_V1, tuned = null;
195
+ if (opts.tune) {
196
+ const train = set.rows.filter((r) => r.split === 'train');
197
+ const l = tuneLinkRubric(train);
198
+ const r = tuneRugRules(train);
199
+ larpAfter = l.rubric;
200
+ rugAfter = r.rules;
201
+ tuned = { larp: { train_mcc: l.train_mcc, candidates: l.candidates }, rug: { train_mcc: r.train_mcc, candidates: r.candidates } };
202
+ }
203
+ const report = skillBacktest(set, { larpBefore: LINK_RUBRIC_V0, larpAfter, rugBefore: RUG_RULES_V0, rugAfter, tuned });
204
+ if (typeof opts.report === 'string')
205
+ writeFileSync(opts.report, JSON.stringify(report, null, 1) + '\n');
206
+ io.stdout.write(opts.json ? JSON.stringify(report, null, 2) + '\n' : renderSkillBacktestText(report));
207
+ return 0;
208
+ }
@@ -0,0 +1,2 @@
1
+ // task-1401: the coin-target parser lives in @dexterm/data (shared with the MCP server); re-exported for the CLI.
2
+ export { parseMintTarget, parseCoinTarget, resolveCoinTarget, isBareOpen, MINT_RE } from '../../data/src/index.js';
@@ -14,7 +14,8 @@ import { shellBinaryPath } from '../../popup/src/index.js';
14
14
  import { detectColorDepth, protocolFromEnv } from '../../tui/src/index.js';
15
15
  import { resolveRpcUrl, defaultDbPath } from '../../data/src/index.js';
16
16
  import { XTokenStore } from '../../data/src/index.js';
17
- import { SayTts, voiceDoctor, WHISPER_MODEL } from '../../voice/src/index.js';
17
+ import { SayTts, voiceDoctor, resolveVoice, WHISPER_MODEL, resolvePiperBin, pickPlayer, PIPER_MODEL } from '../../voice/src/index.js';
18
+ import { loadConfig, dextermHome } from '../../custom/src/index.js';
18
19
  import { kbStore, skillStore } from "./kb-skill-cmd.js";
19
20
  /** The minimum Node that runs dexterm (type-stripping of the .ts entry needs 22.18). */
20
21
  export const MIN_NODE = [22, 18, 0];
@@ -29,16 +30,29 @@ function onPath(bin) {
29
30
  * Default voice probe. Deliberately cheap and non-blocking: it never compiles the Swift helper (that
30
31
  * is left to the first real `v` press), so resolvedStt is inferred from swiftc / whisper presence.
31
32
  */
32
- export async function defaultVoiceProbe(platform = process.platform) {
33
+ export async function defaultVoiceProbe(platform = process.platform, configuredVoice) {
33
34
  const say = new SayTts();
34
35
  const sayAvailable = platform === 'darwin' ? await say.available() : false;
35
- const voices = sayAvailable ? await say.listVoices() : [];
36
+ const voices = sayAvailable ? await say.listVoicesDetailed() : [];
36
37
  const swiftcPresent = platform === 'darwin' && onPath('swiftc');
37
38
  const whisperBinPresent = onPath('whisper-cli');
38
39
  const whisperModelPresent = existsSync(WHISPER_MODEL);
39
- const micToolPresent = onPath('sox') || onPath('rec') || onPath('ffmpeg');
40
+ const micToolPresent = onPath('sox') || onPath('rec') || onPath('ffmpeg') || onPath('arecord');
41
+ // Apple Speech is macOS-only; everywhere else whisper.cpp is the default voice-in (task-1393 Part 2).
40
42
  const resolvedStt = swiftcPresent ? 'apple' : (whisperBinPresent && whisperModelPresent && micToolPresent) ? 'whisper' : null;
41
- return { platform, sayAvailable, voiceCount: voices.length, swiftcPresent, whisperBinPresent, whisperModelPresent, micToolPresent, resolvedStt };
43
+ // espeak-ng is the baseline portable speech-out engine off macOS.
44
+ const espeakPresent = platform !== 'darwin' && (onPath('espeak-ng') || onPath('espeak'));
45
+ // Piper is the preferred neural speech-out engine off macOS (task-1408): binary + voice model + a player.
46
+ const piperPresent = platform !== 'darwin' && resolvePiperBin() !== null;
47
+ const piperVoicePresent = platform !== 'darwin' && existsSync(PIPER_MODEL);
48
+ const playerName = platform !== 'darwin' ? pickPlayer() : null;
49
+ // task-1393: show which voice will actually speak, and why — never a silent system/novelty voice.
50
+ const r = sayAvailable ? resolveVoice({ configured: configuredVoice, installed: voices }) : { voice: null, reason: '' };
51
+ return {
52
+ platform, sayAvailable, voiceCount: voices.length, swiftcPresent, whisperBinPresent, whisperModelPresent, micToolPresent, resolvedStt,
53
+ espeakPresent, piperPresent, piperVoicePresent, playerName,
54
+ resolvedVoice: sayAvailable ? r.voice : undefined, resolvedVoiceReason: r.reason,
55
+ };
42
56
  }
43
57
  /** The keyless data sources the live app depends on, with a cheap reachability URL for each. */
44
58
  export const DATA_SOURCES = [
@@ -142,7 +156,7 @@ export function defaultDeps(env = process.env, isTTY = process.stdout.isTTY ===
142
156
  const cargo = spawnSync('cargo', ['--version'], { stdio: 'ignore', timeout: 3000 }).status === 0;
143
157
  return { path, built: existsSync(path), cargo };
144
158
  },
145
- voiceProbe: () => defaultVoiceProbe(process.platform),
159
+ voiceProbe: () => defaultVoiceProbe(process.platform, loadConfig(dextermHome(env)).config.voice?.name ?? null),
146
160
  content: () => ({ kb: kbStore(env).get(), skills: skillStore(env).get() }),
147
161
  };
148
162
  }