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
@@ -17,6 +17,9 @@
17
17
  import { writeFileSync, existsSync } from 'node:fs';
18
18
  import { spawn } from 'node:child_process';
19
19
  import { dirname, join } from 'node:path';
20
+ import { homedir } from 'node:os';
21
+ import { writeExport, exportViewFor } from "./export.js";
22
+ import { parseMintTarget, parseCoinTarget, resolveCoinTarget, isBareOpen } from "./coin-target.js";
20
23
  import { THEMES, isThemeName, DEFAULT_THEME, THEME_NAMES, Flusher, ENTER_SCREEN, LEAVE_SCREEN, DEFAULT_KEYMAP, Keymap, nextTheme, parseKeys, keyId, detectColorDepth, parseColorDepth, blinkFrame, blinkFrames, stepVfx, pulse, isCalm, fxBusy, blendActivity, ImageLayer, queryTerminal, resolveProtocol, fireMoment, fireInflight, clearInflight, skipMoment, momentKindFor, playMoment, soundOn, breakpoint, fmtUsd as fmtUsdTui, } from '../../tui/src/index.js';
21
24
  import { resolveRpcUrl, redactSecrets, defaultDbPath, extraRpcUrls, wsUrlFor, SOLANA_PUBLIC_RPC_WS, parseHttpUrl, validateAnalystAction, ACTION_RATE, ImageService, encodePng, SummaryLimiter, buildSummaryWindow, KolStore, Cache, PackError, normalizeHandle, terminalSafe } from '../../data/src/index.js';
22
25
  import { Recorder, recorderEnabled, MaintenanceSchedule } from '../../recorder/src/index.js';
@@ -35,6 +38,7 @@ import { TokenPopups, tokenDetailVMFromView } from "./popup.js";
35
38
  import { widgetPage, widgetEnabled, syncWidgetPopups } from "./widgets.js";
36
39
  import { recordLarpVerdict } from "./larp-record.js";
37
40
  import { rulesUiOf, rulesTick, rulesMove, beginRuleInput, saveRuleInput, dryRunNow, removeSelectedRule, toggleSelectedRule, dismissSelectedProposal, actOnSelectedProposal, RULE_INPUT_MAX } from "./rules.js";
41
+ import { buildNotifier, hasPushChannel } from "./alerts/notifier.js";
38
42
  import { launchIntelCells, launchRetryable } from "./live/intel.js";
39
43
  import { AnalystSession } from "./analyst/session.js";
40
44
  import { resolveAnalystResults } from "./analyst/results.js";
@@ -46,13 +50,14 @@ import { buildObservations, observeSig } from "./scan/observe.js";
46
50
  import { cleanText } from "./screener/rows.js";
47
51
  import { resolveImagesConfig } from "./images-config.js";
48
52
  import { popupImagePath } from "./screens/coin.js";
49
- import { VoiceController, selectStt, selectTts } from '../../voice/src/index.js';
53
+ import { VoiceController, selectStt, selectTts, SayTts, voiceStyleDefaults } from '../../voice/src/index.js';
50
54
  import { LivePlanExecutor, defaultPlansJournalPath } from '../../data/src/trade/plans/index.js';
51
55
  import { isWalletAddress, shortAddr, buildMirror, leaderSoldSince, autoSizeCap } from '../../data/src/trade/copy/index.js';
52
56
  import { FOLLOW_INPUT_MAX } from "./screens/follows.js";
53
57
  import { isPriceMode, configPriceMode, togglePriceMode, priceModeOf, planLevels } from "./display.js";
58
+ import { VERSION } from "./version.js";
54
59
  import { initCustom, planWorkspace, planPopupOpens, findWorkspace, nextWorkspaceName, workspaceSlots, saveWorkspace, themeFor, themeByName, cycleTheme, themeNames, CHORD_TTL_MS, MAX_WORKSPACE_NAME } from "./custom.js";
55
- import { DEFAULT_WORKSPACE, MORNING_HUD } from '../../custom/src/index.js';
60
+ import { DEFAULT_WORKSPACE, MORNING_HUD, applyAndJournal } from '../../custom/src/index.js';
56
61
  /**
57
62
  * Context the `screen_state` MCP tool returns AND the per-turn context block is rendered from
58
63
  * (task-1313): what the user is looking at right now — selected/hovered coin, screen, the visible
@@ -205,10 +210,13 @@ export function shouldDrawFrame(g) {
205
210
  return true;
206
211
  return g.frame % g.idleEvery === 0;
207
212
  }
208
- export const HELP = `dexterm 0.1.1 (live: screener, token, new pairs, watchlist · analyst = your local claude CLI · cyberspace look)
213
+ export const HELP = `dexterm ${VERSION} (live: screener, token, new pairs, watchlist · analyst = your local claude CLI · cyberspace look)
209
214
 
210
215
  dexterm interactive, live data (needs a TTY)
211
216
  dexterm doctor check this machine is ready (Node, colour, claude, network, Helius key)
217
+ dexterm voice [status|download <model>] voice engines on this machine; download whisper/piper models (asks first, checksum-verified)
218
+ dexterm update [--off|--on] check npm + GitHub for a newer version and print the upgrade command;
219
+ --off/--on toggles the once-a-day startup notice (no telemetry)
212
220
  dexterm recorder stats|label|prune|export the history recorder's tables (no network; 'dexterm recorder help')
213
221
  dexterm kb list|show|new|edit|check|reset|path your knowledge-base entries (~/.dexterm/kb/entries) over the bundled 17
214
222
  dexterm skill list|show|new|edit|check|reset|path your analyst skills (~/.dexterm/skills) over the bundled ones (read tools only)
@@ -218,6 +226,9 @@ export const HELP = `dexterm 0.1.1 (live: screener, token, new pairs, watchlist
218
226
  dexterm plan propose|list|arm|cancel|journal conditional order plans (entry/ladder/take-profit/moon bag/stop); arming needs a physical keypress
219
227
  dexterm mandate draft|list|arm|revoke standing capped mandates (fast wallet); arming needs a physical keypress — voice/agents can never arm
220
228
  dexterm follow add|remove|list|stats|trades|purge copy trading tier 1: follow a wallet (your list, local labels), its decoded swaps + FIFO PnL; alerts in the app
229
+ dexterm mcp [install <client>|doctor] read-only MCP server for your own agent; 'install' writes the config for claude-code|claude-desktop|cursor|codex|openclaw|generic (asks first, idempotent, backs up); 'doctor' verifies the connection
230
+ dexterm alerts status|enable|disable|set-telegram|set-discord|test where rule alerts are delivered (desktop / your Telegram / Discord; creds stay local, never logged)
231
+ dexterm watch [--interval S] [--once] [--replay FILE] run your rules headless and push alerts when the terminal is closed (no signer, proposals only)
221
232
  dexterm --fixtures interactive demo on seeded fixtures
222
233
  dexterm --screen <name> one-shot frame to stdout (live)
223
234
  dexterm --screen <name> --fixtures one-shot frame from fixtures
@@ -232,7 +243,7 @@ export const HELP = `dexterm 0.1.1 (live: screener, token, new pairs, watchlist
232
243
  --record FILE save every live response (and the PumpPortal session) to FILE (replay it with --replay)
233
244
  --max-requests N hard cap on live requests for this run (default: none)
234
245
  --max-credits N hard cap on Helius credits for this run (default: 5000)
235
- --mint MINT token to open on the token screen
246
+ --mint MINT|LINK token to open on the token screen (a CA or a dexscreener / pump.fun / solscan / birdeye link)
236
247
  --interval IV candle interval for the token screen: ${INTERVALS.join(' | ')} (default 1h)
237
248
  --listen SECONDS one-shot new pairs: how long to listen to PumpPortal (default 20)
238
249
  --db PATH SQLite path for cache, watchlist, credit meter and history (default: ~/.local/share/dexterm/dexterm.db; ':memory:' for none)
@@ -296,6 +307,7 @@ ${X_HELP}
296
307
  --plain monochrome text (snapshot surface)
297
308
  --ansi ANSI output even when stdout is not a TTY (24-bit unless --color / NO_COLOR)
298
309
  --html FILE write a standalone HTML page (for screenshots); --fit WxH --font PX for video frames
310
+ --export [DIR] write the frame as a PNG + a text report with data ages (default ~/.dexterm/exports; local only)
299
311
  --color DEPTH truecolor | 256 | none (default: detected; NO_COLOR honoured)
300
312
  --tz ZONE header clock zone (default America/New_York, the prototype's; 'local' = machine zone)
301
313
  --title TEXT HTML page title
@@ -321,12 +333,14 @@ ${X_HELP}
321
333
  priced from live Jupiter quotes. LIVE (L on a ticket, /wallet link first): a separate signer process
322
334
  quotes, builds, decodes, simulates and enforces the caps; Phantom signs in your browser; dexterm never
323
335
  holds a key. The analyst, voice and scan mode can never open, confirm or sign a live trade.
324
- RPC: DEXTERM_RPC_URL, else ~/.dexterm/helius.key (Helius, metered with a credit meter), else the public RPC.`;
336
+ RPC: DEXTERM_RPC_URL, else ~/.dexterm/helius.key (Helius, metered with a credit meter), else the public RPC.
337
+ Updates: at most once a day a live run asks npm + GitHub for the newest version (two anonymous GETs, no telemetry)
338
+ and shows one toast if there is one; DEXTERM_NO_UPDATE_CHECK=1 or 'dexterm update --off' turns that off.`;
325
339
  export function parseArgs(argv) {
326
340
  const a = {
327
341
  screen: null, fixtures: false, replay: null, record: null, filter: null, sort: null, preset: null, maxRequests: 0, maxCredits: 0, db: null,
328
342
  mint: null, interval: '1h', listen: 20, verbose: false,
329
- width: null, height: null, html: null, theme: DEFAULT_THEME, selected: 0, seed: 7,
343
+ width: null, height: null, html: null, export: null, theme: DEFAULT_THEME, selected: 0, seed: 7,
330
344
  noAi: false, title: null, plain: false, ansi: false, color: null, tz: null,
331
345
  calm: false, intensity: null, fps: null, frame: 0, t: 1, event: null, ai: null, mood: null, activity: null, noBoot: false, fit: null, fontPx: null, asciiRain: false, noImages: false,
332
346
  scan: false, scanConfig: {},
@@ -375,10 +389,13 @@ export function parseArgs(argv) {
375
389
  else if (k === '--max-credits')
376
390
  a.maxCredits = num();
377
391
  else if (k === '--mint') {
392
+ // task-1401: a bare mint, or a dexscreener / pump.fun / solscan / birdeye link (a dexscreener pool is resolved to its coin at open)
378
393
  const m = str();
379
- if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(m))
380
- throw new Error(`--mint expects a base58 Solana address, got ${JSON.stringify(m.slice(0, 20))}`);
381
- a.mint = m;
394
+ const t = parseCoinTarget(m);
395
+ if (!t || (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(m.trim()) && t.via === 'address'))
396
+ throw new Error(`--mint expects a base58 Solana address or a coin link, got ${JSON.stringify(m.slice(0, 20))}`);
397
+ a.mint = t.address;
398
+ a.mintVia = t.via;
382
399
  }
383
400
  else if (k === '--interval') {
384
401
  const iv = str();
@@ -398,6 +415,8 @@ export function parseArgs(argv) {
398
415
  a.height = num();
399
416
  else if (k === '--html')
400
417
  a.html = str();
418
+ else if (k === '--export')
419
+ a.export = argv[i + 1] && !argv[i + 1].startsWith('--') ? str() : '';
401
420
  else if (k === '--theme')
402
421
  a.theme = str();
403
422
  else if (k === '--selected')
@@ -694,11 +713,11 @@ export function fastLaneEndpoints(rpc) {
694
713
  return out;
695
714
  }
696
715
  export async function main(argv, io = process, proc = process) {
697
- // `dexterm mcp` → the standalone read-only MCP server on stdio (SPEC §5.1; `claude mcp add dexterm -- dexterm mcp`).
716
+ // `dexterm mcp` → the read-only MCP server on stdio (SPEC §5.1; `claude mcp add dexterm -- dexterm mcp`),
717
+ // plus `dexterm mcp install <client>` (one-command setup) and `dexterm mcp doctor` (verify the connection).
698
718
  if (argv[0] === 'mcp') {
699
- const { runStdioServer } = await import('../../mcp/src/index.js');
700
- await runStdioServer({ argv: argv.slice(1) });
701
- return 0;
719
+ const { runMcp } = await import("./mcp-cmd.js");
720
+ return runMcp(argv.slice(1), io);
702
721
  }
703
722
  // `dexterm agent <cmd>` → bring-your-own-agent registry + loopback bus (task-1345): add/list/rotate/
704
723
  // remove/tier/pause/resume/kick/audit/packs, `serve` (a standalone bus on fixtures) and `connect`.
@@ -761,6 +780,18 @@ export async function main(argv, io = process, proc = process) {
761
780
  const { rulesCommand } = await import("./rules-cmd.js");
762
781
  return rulesCommand(argv.slice(1), { stdout: io.stdout, stderr: io.stderr, env: io.env });
763
782
  }
783
+ // `dexterm alerts status|enable|disable|levels|proposals|set-telegram|set-discord|test` → where rule alerts
784
+ // are delivered (task-1398). Credentials live in ~/.dexterm/*.json (0600), never config.json/argv/logs.
785
+ if (argv[0] === 'alerts') {
786
+ const { alertsCommand } = await import("./alerts-cmd.js");
787
+ return alertsCommand(argv.slice(1), { stdout: io.stdout, stderr: io.stderr, env: io.env, stdin: io.stdin });
788
+ }
789
+ // `dexterm watch [--interval S] [--once|--ticks N] [--replay FILE]` → run the rules engine headless and
790
+ // PUSH alerts to the enabled channels so they reach you when the terminal is closed (task-1398). No signer.
791
+ if (argv[0] === 'watch') {
792
+ const { watchCommand } = await import("./watch-cmd.js");
793
+ return watchCommand(argv.slice(1), { stdout: io.stdout, stderr: io.stderr, env: io.env });
794
+ }
764
795
  // `dexterm pack list|verify|add|remove|export|lock|sign|trust|signers|keymap|widget` → shareable packs (task-1358 Tier 4):
765
796
  // plain files + sha256 lock + optional ssh signature. No network, no model; a pack never runs code (widgets land
766
797
  // disabled, unsigned rules load disabled, a keymap merges only on the explicit command).
@@ -789,6 +820,18 @@ export async function main(argv, io = process, proc = process) {
789
820
  const { runFollow } = await import("./follow-cmd.js");
790
821
  return runFollow(argv.slice(1), io);
791
822
  }
823
+ // `dexterm update [--off|--on]` → is there a newer version, and the exact upgrade command for this install (task-1397).
824
+ // Two anonymous GETs (npm registry, GitHub releases); never upgrades anything itself.
825
+ if (argv[0] === 'update') {
826
+ const { updateCommand } = await import("./update.js");
827
+ return updateCommand(argv.slice(1), io);
828
+ }
829
+ // `dexterm voice [status|download <model>]` → inspect the voice engines and download the optional
830
+ // voice models (whisper base.en, Piper voice). Downloads ask first and are checksum-verified (task-1408).
831
+ if (argv[0] === 'voice') {
832
+ const { voiceCommand } = await import("./voice-cmd.js");
833
+ return voiceCommand(argv.slice(1), { stdin: io.stdin, stdout: io.stdout, stderr: io.stderr, env: io.env });
834
+ }
792
835
  // `dexterm doctor` → a pre-flight that says whether dexterm will run here and how to fix what won't (task-1305).
793
836
  if (argv[0] === 'doctor') {
794
837
  const { defaultDeps, runDoctor, renderDoctor } = await import("./doctor.js");
@@ -838,7 +881,7 @@ export async function main(argv, io = process, proc = process) {
838
881
  return 0;
839
882
  }
840
883
  if (args.version) {
841
- io.stdout.write('0.1.1\n');
884
+ io.stdout.write(`${VERSION}\n`);
842
885
  return 0;
843
886
  }
844
887
  if (args.unknown.length) {
@@ -932,6 +975,8 @@ export async function main(argv, io = process, proc = process) {
932
975
  feed.extraMints = () => [...liveCtx.newpairs.rows.keys()];
933
976
  applyScreenerArgs(state, args);
934
977
  if (args.mint) {
978
+ if (args.mintVia === 'dexscreener')
979
+ args.mint = (await resolveCoinTarget({ address: args.mint, via: 'dexscreener', rest: '' }, ctx.dl)).mint;
935
980
  ctx.openToken({ mint: args.mint, symbol: args.mint.slice(0, 4) + '…', name: '', launchpad: 'unknown' });
936
981
  if (args.screen === null)
937
982
  state.screen = 'token';
@@ -1063,7 +1108,13 @@ export async function main(argv, io = process, proc = process) {
1063
1108
  }
1064
1109
  const c = renderFrame(state, w, h);
1065
1110
  ctx?.newpairs.markDrawn();
1066
- if (args.html) {
1111
+ if (args.export !== null) {
1112
+ // task-1401: `--export [DIR]` writes the same PNG + text report the `E` key does (default ~/.dexterm/exports).
1113
+ const now = Date.now();
1114
+ const r = writeExport(exportViewFor(state, w, h, now), { now, env: io.env, dir: args.export || undefined });
1115
+ io.stderr.write(`wrote ${r.png} (${r.bytes} bytes) and ${r.txt}\n`);
1116
+ }
1117
+ else if (args.html) {
1067
1118
  // task-1326: `--html … --color 256` renders through the xterm palette (a Terminal.app-faithful proof)
1068
1119
  writeFileSync(args.html, c.toHtml({ title: args.title || `dexterm — ${args.screen} — ${state.theme.name}`, fit: args.fit, fontPx: args.fontPx ?? 15, depth: args.color ?? 'truecolor' }));
1069
1120
  io.stderr.write(`wrote ${args.html} (${w}×${h})\n`);
@@ -1087,7 +1138,20 @@ export async function main(argv, io = process, proc = process) {
1087
1138
  feed?.close();
1088
1139
  return 0;
1089
1140
  }
1090
- return interactive(state, io, depth, args, proc);
1141
+ // task-1397: live interactive runs only: the cached "new version" notice (no network) plus, at most once a day,
1142
+ // a background refresh that never blocks the start. Off with DEXTERM_NO_UPDATE_CHECK=1 / `dexterm update --off`.
1143
+ const update = live ? (await import("./update.js")).startupCheck(io.env) : null;
1144
+ if (update) {
1145
+ state.updateNotice = update.notice;
1146
+ void update.refresh?.then((n) => { if (n)
1147
+ state.updateNotice = n; });
1148
+ }
1149
+ try {
1150
+ return await interactive(state, io, depth, args, proc);
1151
+ }
1152
+ finally {
1153
+ update?.cancel();
1154
+ }
1091
1155
  }
1092
1156
  const SCREEN_ORDER = ['screener', 'newpairs', 'watchlist', 'analyst'];
1093
1157
  /** Rows PgUp/PgDn jump through the list (task-1303). */
@@ -2269,6 +2333,10 @@ export function applyAction(state, action, stack) {
2269
2333
  case 'goto.open':
2270
2334
  state.goto = { input: '', notice: null };
2271
2335
  break;
2336
+ // task-1401 EXPORT: the loop renders the current view calm and writes the PNG + report (local files only).
2337
+ case 'export.view':
2338
+ state.pendingExport = true;
2339
+ break;
2272
2340
  case 'display.priceMode':
2273
2341
  state.pendingToast = togglePriceMode(state);
2274
2342
  break; // task-1366: display only, persisted
@@ -2518,31 +2586,81 @@ function seedForMint(state, live, mint, symbol) {
2518
2586
  // task-1388 GO-TO prompt: the longest input we keep (a bare base58 mint is ≤ 44; the longest coin URL we
2519
2587
  // accept is well under this), so a huge paste can never balloon the buffer.
2520
2588
  const GOTO_INPUT_MAX = 120;
2521
- /** A full base58 Solana mint: 32–44 chars, no 0/O/I/l. Same shape `--mint` validates (main.ts parseArgs). */
2522
- const GOTO_MINT_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
2589
+ // task-1388's parser now lives in coin-target.ts (shared with the analyst chat, the filter line and --mint).
2590
+ export { parseMintTarget };
2523
2591
  /**
2524
- * task-1388: turn a pasted/typed go-to target into a mint, or null when there is none. Accepts a bare base58
2525
- * mint, or a dexscreener / pump.fun / solscan URL (or any text) by pulling the first base58 run of mint length.
2526
- * Base58 excludes 0, O, I and l, so splitting on every non-base58 character also splits on them and on the URL
2527
- * punctuation (/, :, ., ?, =, &), leaving each path/query segment as its own token; a real mint is never cut.
2528
- * Pure and total: it only ever returns a validated mint string or null, and never throws. Exported for tests.
2592
+ * task-1401: open a pasted coin target (a CA, or a dexscreener / pump.fun / solscan / birdeye link) on the LIVE token
2593
+ * screen — the same read-only path `--screen token --mint` and the analyst's open_token use. A mint opens at once; a
2594
+ * dexscreener link is first resolved to its coin (its address is usually the pool) and opens when that answers.
2595
+ * `onOpened` runs after the open either way (the chat uses it to ask its question about the coin). Returns a notice
2596
+ * when it cannot open (fixtures mode), else null. Never trades, never signs.
2529
2597
  */
2530
- export function parseMintTarget(raw) {
2531
- const s = raw.trim();
2532
- if (!s)
2598
+ export function openCoinTarget(state, stack, t, onOpened) {
2599
+ const live = state.live && state.screener.source !== 'fixtures' ? state.live : null;
2600
+ if (!live)
2601
+ return 'opening a coin by address needs live mode (not --fixtures)';
2602
+ const open = (mint) => {
2603
+ if (!(state.screen === 'token' && live.token?.view.mint === mint))
2604
+ live.openToken(seedForMint(state, live, mint)); // already open: keep its loaded view
2605
+ if (state.screen !== 'token') {
2606
+ stack.push(state.screen);
2607
+ state.screen = 'token';
2608
+ }
2609
+ onOpened?.(mint);
2610
+ };
2611
+ if (t.via !== 'dexscreener') {
2612
+ open(t.address);
2533
2613
  return null;
2534
- if (GOTO_MINT_RE.test(s))
2535
- return s;
2536
- for (const tok of s.split(/[^1-9A-HJ-NP-Za-km-z]+/))
2537
- if (GOTO_MINT_RE.test(tok))
2538
- return tok;
2614
+ }
2615
+ live.notice = 'resolving the dexscreener link…';
2616
+ void resolveCoinTarget(t, live.dl).then(({ mint, fromPair }) => {
2617
+ if (live.isClosed)
2618
+ return;
2619
+ live.notice = fromPair ? `dexscreener pool → coin ${mint.slice(0, 4)}…${mint.slice(-4)}` : null;
2620
+ open(mint);
2621
+ state.aiFocused = false;
2622
+ syncAnalyst(state);
2623
+ live.onChange('goto');
2624
+ });
2539
2625
  return null;
2540
2626
  }
2627
+ /**
2628
+ * task-1401: the analyst chat's ⏎. When the typed text names a coin (a CA or a coin link), open that coin's token
2629
+ * view and, if a question came with it ("is <CA> a rug?"), ask it about THAT coin; returns true when it handled
2630
+ * the input. Plain questions return false and go to the analyst as before.
2631
+ */
2632
+ export function chatOpenCoin(state, stack, q, hooks) {
2633
+ const ct = q ? parseCoinTarget(q) : null;
2634
+ if (!ct)
2635
+ return false;
2636
+ state.aiInput = '';
2637
+ const question = isBareOpen(ct.rest) ? '' : ct.rest;
2638
+ const notice = openCoinTarget(state, stack, ct, (mint) => {
2639
+ syncAnalyst(state);
2640
+ if (question) {
2641
+ state.aiOpen = true;
2642
+ try {
2643
+ hooks.ask(question);
2644
+ }
2645
+ catch (e) {
2646
+ state.screener.notice = `⚠ ask failed: ${describeError(e)}`;
2647
+ }
2648
+ }
2649
+ else {
2650
+ state.aiFocused = false;
2651
+ hooks.say(`opened ${mint.slice(0, 4)}…${mint.slice(-4)} from the chat`, 'info', 4);
2652
+ }
2653
+ });
2654
+ if (notice)
2655
+ hooks.say(notice, 'warn', 5);
2656
+ return true;
2657
+ }
2541
2658
  /**
2542
2659
  * task-1388: resolve the GO-TO prompt's input to a coin and open its live token screen (read-only — the same
2543
2660
  * path `--screen token --mint` and the analyst's open_token use: live.openToken(seedForMint(...)), which seeds
2544
2661
  * an unknown-launchpad stub when the mint is not already on a screen). Bad input keeps the prompt open with a
2545
2662
  * one-line notice and changes nothing; a good open closes the prompt and pushes the current screen on the stack.
2663
+ * task-1401: a dexscreener link is resolved pool → coin first (openCoinTarget).
2546
2664
  */
2547
2665
  function openGotoTarget(state, stack) {
2548
2666
  const g = state.goto;
@@ -2558,17 +2676,13 @@ function openGotoTarget(state, stack) {
2558
2676
  g.notice = 'not a contract address or a dexscreener / pump.fun / solscan link';
2559
2677
  return;
2560
2678
  }
2561
- const live = state.live && state.screener.source !== 'fixtures' ? state.live : null;
2562
- if (!live) {
2563
- g.notice = 'opening a coin by address needs live mode (not --fixtures)';
2679
+ const t = parseCoinTarget(raw) ?? { address: mint, via: 'address', rest: '' };
2680
+ const notice = openCoinTarget(state, stack, t);
2681
+ if (notice) {
2682
+ g.notice = notice;
2564
2683
  return;
2565
2684
  }
2566
- live.openToken(seedForMint(state, live, mint));
2567
2685
  state.goto = null;
2568
- if (state.screen !== 'token') {
2569
- stack.push(state.screen);
2570
- state.screen = 'token';
2571
- }
2572
2686
  }
2573
2687
  /**
2574
2688
  * Apply one analyst-requested UI action to the state (task-1313). This is the trust boundary: every
@@ -2918,6 +3032,18 @@ export function applyKey(state, key, stack) {
2918
3032
  state.pendingToast = typeof cmd === 'object' ? cmd.error : scanCommand(state, cmd, stack);
2919
3033
  return;
2920
3034
  }
3035
+ // task-1401: a pasted CA or coin link on the `/` line opens that coin (no filter field is a 32-44 char base58
3036
+ // run); the editor closes and the previous filter stays applied.
3037
+ const ct = parseCoinTarget(sc.edit.text);
3038
+ if (ct && isBareOpen(ct.rest)) {
3039
+ sc.mode = 'table';
3040
+ sc.edit.text = sc.query ?? '';
3041
+ sc.edit.error = null;
3042
+ const notice = openCoinTarget(state, stack, ct);
3043
+ if (notice)
3044
+ state.pendingToast = notice;
3045
+ return;
3046
+ }
2921
3047
  // task-1328: `/wallet …` is a command too (the loop performs it: link opens a browser, probe opens a ticket).
2922
3048
  const wc = parseWalletCommand(sc.edit.text);
2923
3049
  if (wc) {
@@ -4099,6 +4225,24 @@ export function interactive(state, io, depth, args, proc = process) {
4099
4225
  say(`skipped ${plan.skipped.map((s) => `${s.kind} (${s.why})`).join(', ')}`, 'info', 5);
4100
4226
  scheduleDraw();
4101
4227
  };
4228
+ // task-1398: PUSH delivery while the TUI is open. Built only when the user enabled a push channel
4229
+ // (desktop/telegram/discord); otherwise null and there is no behaviour change. The in-app toast is
4230
+ // still state.pendingToast, so the Notifier's toast sink is a no-op here (routing may still include it,
4231
+ // harmlessly). Delivery is fire-and-forget: a slow channel never blocks the frame.
4232
+ const alertNotifier = state.custom && hasPushChannel(state.custom.config.alerts)
4233
+ ? buildNotifier(state.custom.home, state.custom.config.alerts, { toastSink: () => { } })
4234
+ : null;
4235
+ const drainDeliveries = () => {
4236
+ const ui = state.rulesUi;
4237
+ if (!ui || !ui.pendingDeliveries.length)
4238
+ return;
4239
+ const ds = ui.pendingDeliveries;
4240
+ ui.pendingDeliveries = [];
4241
+ if (!alertNotifier)
4242
+ return;
4243
+ for (const d of ds)
4244
+ void alertNotifier.deliver(d).catch(() => { });
4245
+ };
4102
4246
  // task-1358 Tier 3: perform what the rules engine / an accepted proposal queued. A ticket proposal opens the
4103
4247
  // PAPER ticket in its editing phase (the user sizes + confirms there; signer caps apply) — never a live ticket,
4104
4248
  // never a confirm. An ask proposal is a question to the analyst. Popups are the same read-only windows the keys
@@ -4456,26 +4600,49 @@ export function interactive(state, io, depth, args, proc = process) {
4456
4600
  else
4457
4601
  voice.abortTurn(session.status === 'error' ? "Sorry — the analyst hit an error. Try again." : "Okay, stopped.");
4458
4602
  };
4603
+ // task-1393: persist the `,` voice choice to config.voice.name (actor 'user', journaled → undo works).
4604
+ // Best-effort: a persistence failure keeps the voice for this run and never breaks the UI.
4605
+ const persistVoiceChoice = (voiceName) => {
4606
+ const ui = state.custom;
4607
+ if (!ui)
4608
+ return;
4609
+ try {
4610
+ const op = ui.config.voice
4611
+ ? { op: 'add', path: '/voice/name', value: voiceName }
4612
+ : { op: 'add', path: '/voice', value: { name: voiceName } };
4613
+ const { config } = applyAndJournal(ui.home, [op], { actor: 'user', why: `, voice ${voiceName}`, now: state.now.getTime() });
4614
+ ui.config = config;
4615
+ }
4616
+ catch { /* keep the voice for this run; nothing to surface */ }
4617
+ };
4459
4618
  const initVoice = async () => {
4460
4619
  if (voiceInitStarted)
4461
4620
  return;
4462
4621
  voiceInitStarted = true;
4463
4622
  state.voice = { enabled: true, mode: 'idle', muted: false, voice: null, transcript: null, say: null, message: 'preparing voice…' };
4464
4623
  scheduleDraw();
4624
+ // task-1393: the user's saved voice/rate (if any). A missing/banned name is resolved to a safe
4625
+ // deep British male by selectTts — it never falls back to the machine's system voice.
4626
+ const cfgVoice = state.custom?.config.voice;
4465
4627
  let stt = null, tts = null;
4466
4628
  try {
4467
- [stt, tts] = await Promise.all([selectStt(), selectTts()]);
4629
+ [stt, tts] = await Promise.all([selectStt(), selectTts(undefined, cfgVoice?.name ?? null)]);
4468
4630
  }
4469
4631
  catch { /* both null → degrade to text */ }
4470
4632
  if (done)
4471
4633
  return;
4634
+ // Seed the controller with the ENGINE's resolved voice so its cycle/status start on the British male,
4635
+ // not on voices[0] (which could be a novelty voice like "Albert").
4636
+ const resolvedVoice = tts instanceof SayTts ? tts.voiceName : undefined;
4637
+ const style = voiceStyleDefaults(resolvedVoice); // the picked voice's own rate/pitch, unless the user saved theirs
4472
4638
  voice = new VoiceController({
4473
- stt, tts, getScreenState: voiceScreenState,
4639
+ stt, tts, voice: resolvedVoice, rate: cfgVoice?.rate ?? style?.rate, pitch: cfgVoice?.pitch ?? style?.pitch, getScreenState: voiceScreenState,
4474
4640
  onState: () => { if (!done && voice) {
4475
4641
  state.voice = toHud();
4476
4642
  scheduleDraw();
4477
4643
  } },
4478
4644
  onDecision: handleVoiceDecision,
4645
+ onVoiceChange: persistVoiceChoice,
4479
4646
  });
4480
4647
  voice.enable();
4481
4648
  await voice.toggleTalk(); // the first `v` press both enables and starts listening
@@ -5016,6 +5183,21 @@ export function interactive(state, io, depth, args, proc = process) {
5016
5183
  draw();
5017
5184
  continue;
5018
5185
  }
5186
+ // task-1401: a CA or a dexscreener / pump.fun / solscan / birdeye link in the chat opens that coin's token
5187
+ // view (read-only, the go-to path); any question typed with it is then asked about THAT coin.
5188
+ const before = state.screen;
5189
+ if (chatOpenCoin(state, stack, q, { ask: sendChat, say })) {
5190
+ if (state.screen !== before) {
5191
+ try {
5192
+ startTransition();
5193
+ }
5194
+ catch {
5195
+ transition = null;
5196
+ }
5197
+ }
5198
+ draw();
5199
+ continue;
5200
+ }
5019
5201
  if (q) {
5020
5202
  state.aiInput = '';
5021
5203
  try {
@@ -5254,6 +5436,19 @@ export function interactive(state, io, depth, args, proc = process) {
5254
5436
  copyToClipboard(m);
5255
5437
  say(`copied CA ${m.slice(0, 4)}…${m.slice(-4)}`, 'info');
5256
5438
  }
5439
+ if (state.pendingExport) {
5440
+ // task-1401: one key → PNG + text report of the current view in ~/.dexterm/exports. Nothing is uploaded.
5441
+ state.pendingExport = false;
5442
+ try {
5443
+ const [ew, eh] = size();
5444
+ const now = Date.now();
5445
+ const r = writeExport(exportViewFor(state, ew, eh, now), { now, env: io.env });
5446
+ say(`exported PNG + report → ${r.png.replace(homedir(), '~')} · local only, nothing uploaded`, 'info', 8);
5447
+ }
5448
+ catch (e) {
5449
+ say(`export failed: ${describeError(e)}`, 'warn', 6);
5450
+ }
5451
+ }
5257
5452
  if (state.pendingToast) {
5258
5453
  // task-1315: surface the link list / "no link" reply as a durable toast. Drained BEFORE
5259
5454
  // pendingOpen so that on a successful open the "opened <url>" toast overwrites the "open <url>" one.
@@ -5317,6 +5512,11 @@ export function interactive(state, io, depth, args, proc = process) {
5317
5512
  endBoot();
5318
5513
  }
5319
5514
  if (v.frame % tickEvery === 0) {
5515
+ // task-1397: the "new version available" toast, once, after the boot and never over the welcome card.
5516
+ if (state.updateNotice && state.screen !== 'boot' && !state.overlay) {
5517
+ say(state.updateNotice, 'info', 8);
5518
+ state.updateNotice = null;
5519
+ }
5320
5520
  if (feed) {
5321
5521
  if (!state.paused && Date.now() - lastRefresh >= LIVE_REFRESH_MS)
5322
5522
  refresh();
@@ -5361,6 +5561,7 @@ export function interactive(state, io, depth, args, proc = process) {
5361
5561
  if (rulesTick(state, Date.now()))
5362
5562
  flusher.invalidate();
5363
5563
  drainRulesPending();
5564
+ drainDeliveries();
5364
5565
  }
5365
5566
  catch (e) {
5366
5567
  state.screener.notice = `⚠ rules tick failed: ${describeError(e)}`;
@@ -5391,6 +5592,7 @@ export function interactive(state, io, depth, args, proc = process) {
5391
5592
  if (rulesTick(state, Date.now()))
5392
5593
  flusher.invalidate();
5393
5594
  drainRulesPending();
5595
+ drainDeliveries();
5394
5596
  }
5395
5597
  catch (e) {
5396
5598
  state.screener.notice = `⚠ rules tick failed: ${describeError(e)}`;