dexterm-cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dist/packages/agents/src/bus.js +57 -6
  2. package/dist/packages/agents/src/executor.js +25 -0
  3. package/dist/packages/agents/src/follow.js +105 -0
  4. package/dist/packages/agents/src/gate.js +3 -0
  5. package/dist/packages/agents/src/identity.js +41 -2
  6. package/dist/packages/agents/src/index.js +5 -0
  7. package/dist/packages/agents/src/spectator-server.js +153 -0
  8. package/dist/packages/agents/src/spectator.js +212 -0
  9. package/dist/packages/agents/src/tiers.js +5 -0
  10. package/dist/packages/agents/src/tools.js +3 -0
  11. package/dist/packages/cli/assets/claude/analyst-system.md +6 -1
  12. package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +11 -3
  13. package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +13 -3
  14. package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +3 -3
  15. package/dist/packages/cli/src/agent-cmd.js +107 -10
  16. package/dist/packages/cli/src/agent-spectator.js +37 -0
  17. package/dist/packages/cli/src/alerts/adapters/desktop.js +40 -0
  18. package/dist/packages/cli/src/alerts/adapters/discord.js +36 -0
  19. package/dist/packages/cli/src/alerts/adapters/telegram.js +43 -0
  20. package/dist/packages/cli/src/alerts/adapters/toast.js +16 -0
  21. package/dist/packages/cli/src/alerts/creds.js +83 -0
  22. package/dist/packages/cli/src/alerts/format.js +66 -0
  23. package/dist/packages/cli/src/alerts/index.js +10 -0
  24. package/dist/packages/cli/src/alerts/notifier.js +53 -0
  25. package/dist/packages/cli/src/alerts/route.js +19 -0
  26. package/dist/packages/cli/src/alerts/types.js +1 -0
  27. package/dist/packages/cli/src/alerts-cmd.js +212 -0
  28. package/dist/packages/cli/src/app-help-gen.js +365 -0
  29. package/dist/packages/cli/src/backtest-cmd.js +62 -3
  30. package/dist/packages/cli/src/coin-target.js +2 -0
  31. package/dist/packages/cli/src/doctor.js +21 -6
  32. package/dist/packages/cli/src/export.js +235 -0
  33. package/dist/packages/cli/src/follow-cmd.js +107 -1
  34. package/dist/packages/cli/src/help-cmd.js +48 -0
  35. package/dist/packages/cli/src/live/index.js +66 -3
  36. package/dist/packages/cli/src/live/screens/token-live.js +24 -0
  37. package/dist/packages/cli/src/live/signals.js +109 -0
  38. package/dist/packages/cli/src/live/trade.js +24 -1
  39. package/dist/packages/cli/src/main.js +596 -49
  40. package/dist/packages/cli/src/mcp-cmd.js +321 -0
  41. package/dist/packages/cli/src/pnl-app.js +221 -0
  42. package/dist/packages/cli/src/pnl-card.js +615 -0
  43. package/dist/packages/cli/src/record-cmd.js +455 -0
  44. package/dist/packages/cli/src/rules.js +69 -2
  45. package/dist/packages/cli/src/screens/follows.js +91 -2
  46. package/dist/packages/cli/src/screens/overlay.js +3 -0
  47. package/dist/packages/cli/src/screens/positions.js +1 -1
  48. package/dist/packages/cli/src/screens/rules.js +1 -1
  49. package/dist/packages/cli/src/skill-eval-build.js +109 -0
  50. package/dist/packages/cli/src/spectate-cmd.js +104 -0
  51. package/dist/packages/cli/src/state.js +1 -1
  52. package/dist/packages/cli/src/update.js +294 -0
  53. package/dist/packages/cli/src/version.js +3 -0
  54. package/dist/packages/cli/src/voice-cmd.js +194 -0
  55. package/dist/packages/cli/src/voice-hud.js +1 -1
  56. package/dist/packages/cli/src/watch-cmd.js +190 -0
  57. package/dist/packages/custom/src/rules.js +3 -2
  58. package/dist/packages/custom/src/schema.js +24 -1
  59. package/dist/packages/data/src/analyst-actions.js +130 -3
  60. package/dist/packages/data/src/coin-target.js +82 -0
  61. package/dist/packages/data/src/fast-lane.js +16 -2
  62. package/dist/packages/data/src/index.js +4 -0
  63. package/dist/packages/data/src/intel/helius-history.js +12 -1
  64. package/dist/packages/data/src/intel/live-signals.js +185 -0
  65. package/dist/packages/data/src/larp/link-baseline.js +196 -0
  66. package/dist/packages/data/src/sources/dexscreener.js +14 -0
  67. package/dist/packages/data/src/sources/pumpportal.js +6 -1
  68. package/dist/packages/data/src/trade/copy/index.js +1 -1
  69. package/dist/packages/data/src/trade/copy/stats.js +36 -2
  70. package/dist/packages/data/src/trade/copy/tracker.js +3 -2
  71. package/dist/packages/mcp/assets/kb/app-help/app-help-command.md +35 -0
  72. package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +43 -0
  73. package/dist/packages/mcp/assets/kb/app-help/backtest.md +33 -0
  74. package/dist/packages/mcp/assets/kb/app-help/bubble-map.md +35 -0
  75. package/dist/packages/mcp/assets/kb/app-help/calm-mode.md +31 -0
  76. package/dist/packages/mcp/assets/kb/app-help/change-voice-speed.md +31 -0
  77. package/dist/packages/mcp/assets/kb/app-help/change-voice.md +30 -0
  78. package/dist/packages/mcp/assets/kb/app-help/chart-popup-selftest.md +32 -0
  79. package/dist/packages/mcp/assets/kb/app-help/coin-images.md +29 -0
  80. package/dist/packages/mcp/assets/kb/app-help/configure-rpc-key.md +28 -0
  81. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent-registry.md +35 -0
  82. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +39 -0
  83. package/dist/packages/mcp/assets/kb/app-help/custom-keymap.md +32 -0
  84. package/dist/packages/mcp/assets/kb/app-help/edit-kb-or-skill.md +36 -0
  85. package/dist/packages/mcp/assets/kb/app-help/edit-rules.md +34 -0
  86. package/dist/packages/mcp/assets/kb/app-help/export-a-view.md +36 -0
  87. package/dist/packages/mcp/assets/kb/app-help/follow-a-wallet.md +33 -0
  88. package/dist/packages/mcp/assets/kb/app-help/getting-started.md +37 -0
  89. package/dist/packages/mcp/assets/kb/app-help/kol-lists.md +34 -0
  90. package/dist/packages/mcp/assets/kb/app-help/larp-check.md +36 -0
  91. package/dist/packages/mcp/assets/kb/app-help/link-a-wallet.md +41 -0
  92. package/dist/packages/mcp/assets/kb/app-help/login-with-x.md +33 -0
  93. package/dist/packages/mcp/assets/kb/app-help/narrative-board.md +32 -0
  94. package/dist/packages/mcp/assets/kb/app-help/open-a-coin.md +43 -0
  95. package/dist/packages/mcp/assets/kb/app-help/open-chart-popup.md +33 -0
  96. package/dist/packages/mcp/assets/kb/app-help/order-plans.md +33 -0
  97. package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +36 -0
  98. package/dist/packages/mcp/assets/kb/app-help/price-or-marketcap.md +38 -0
  99. package/dist/packages/mcp/assets/kb/app-help/record-headless.md +34 -0
  100. package/dist/packages/mcp/assets/kb/app-help/recorder-history.md +39 -0
  101. package/dist/packages/mcp/assets/kb/app-help/rug-check.md +39 -0
  102. package/dist/packages/mcp/assets/kb/app-help/run-doctor.md +32 -0
  103. package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +37 -0
  104. package/dist/packages/mcp/assets/kb/app-help/screen-analyst.md +181 -0
  105. package/dist/packages/mcp/assets/kb/app-help/screen-kol.md +190 -0
  106. package/dist/packages/mcp/assets/kb/app-help/screen-newpairs.md +181 -0
  107. package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +180 -0
  108. package/dist/packages/mcp/assets/kb/app-help/screen-screener.md +199 -0
  109. package/dist/packages/mcp/assets/kb/app-help/screen-token.md +240 -0
  110. package/dist/packages/mcp/assets/kb/app-help/screen-trending.md +192 -0
  111. package/dist/packages/mcp/assets/kb/app-help/screen-watchlist.md +181 -0
  112. package/dist/packages/mcp/assets/kb/app-help/set-a-filter.md +36 -0
  113. package/dist/packages/mcp/assets/kb/app-help/setup-neural-voice.md +36 -0
  114. package/dist/packages/mcp/assets/kb/app-help/share-packs.md +34 -0
  115. package/dist/packages/mcp/assets/kb/app-help/sort-the-screener.md +34 -0
  116. package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +36 -0
  117. package/dist/packages/mcp/assets/kb/app-help/standing-mandates.md +33 -0
  118. package/dist/packages/mcp/assets/kb/app-help/switch-theme.md +42 -0
  119. package/dist/packages/mcp/assets/kb/app-help/turn-on-alerts.md +37 -0
  120. package/dist/packages/mcp/assets/kb/app-help/update-dexterm.md +30 -0
  121. package/dist/packages/mcp/assets/kb/app-help/use-presets.md +42 -0
  122. package/dist/packages/mcp/assets/kb/app-help/use-voice-mode.md +36 -0
  123. package/dist/packages/mcp/assets/kb/app-help/watch-a-coin.md +34 -0
  124. package/dist/packages/mcp/assets/kb/app-help/watch-headless.md +35 -0
  125. package/dist/packages/mcp/assets/kb/app-help/what-dexterm-shows-agents.md +31 -0
  126. package/dist/packages/mcp/assets/kb/app-help/workspaces.md +38 -0
  127. package/dist/packages/mcp/src/app-help.js +135 -0
  128. package/dist/packages/mcp/src/index.js +3 -0
  129. package/dist/packages/mcp/src/server.js +179 -5
  130. package/dist/packages/mcp/src/spectator-sink.js +72 -0
  131. package/dist/packages/packs/src/index.js +3 -0
  132. package/dist/packages/packs/src/skill-backtest.js +300 -0
  133. package/dist/packages/packs/src/skill-care.js +206 -0
  134. package/dist/packages/popup/src/pages/spectator.css +45 -0
  135. package/dist/packages/popup/src/pages/spectator.html +25 -0
  136. package/dist/packages/popup/src/pages/spectator.js +74 -0
  137. package/dist/packages/popup/src/selftest.js +19 -0
  138. package/dist/packages/popup/src/server.js +7 -1
  139. package/dist/packages/tui/src/index.js +3 -0
  140. package/dist/packages/tui/src/keymap.js +12 -0
  141. package/dist/packages/tui/src/raster-font.js +195 -0
  142. package/dist/packages/tui/src/raster.js +213 -0
  143. package/dist/packages/voice/src/controller.js +57 -4
  144. package/dist/packages/voice/src/doctor.js +96 -11
  145. package/dist/packages/voice/src/download.js +184 -0
  146. package/dist/packages/voice/src/engines.js +55 -8
  147. package/dist/packages/voice/src/index.js +9 -3
  148. package/dist/packages/voice/src/speakable.js +111 -0
  149. package/dist/packages/voice/src/stt/whisper.js +14 -4
  150. package/dist/packages/voice/src/tts/espeak.js +131 -0
  151. package/dist/packages/voice/src/tts/piper-setup.js +229 -0
  152. package/dist/packages/voice/src/tts/piper.js +449 -0
  153. package/dist/packages/voice/src/tts/say.js +72 -35
  154. package/dist/packages/voice/src/tts/voices.js +209 -0
  155. package/package.json +5 -9
@@ -0,0 +1,36 @@
1
+ import { deliveryLine } from "../format.js";
2
+ const nodeFetch = (url, init) => globalThis.fetch(url, init);
3
+ export function createDiscordAdapter(deps) {
4
+ const creds = deps.creds;
5
+ const doFetch = deps.fetch ?? nodeFetch;
6
+ const timeoutMs = deps.timeoutMs ?? 8000;
7
+ return {
8
+ name: 'discord',
9
+ available: () => !!creds?.webhook,
10
+ deliver: async (d) => {
11
+ if (!creds)
12
+ return { ok: false, skipped: true, reason: 'discord not configured (dexterm alerts set-discord)' };
13
+ const ac = new AbortController();
14
+ const t = setTimeout(() => ac.abort(), timeoutMs);
15
+ try {
16
+ const res = await doFetch(creds.webhook, {
17
+ method: 'POST',
18
+ headers: { 'content-type': 'application/json' },
19
+ // Content is already firewalled; cap to Discord's 2000-char limit for safety.
20
+ body: JSON.stringify({ content: deliveryLine(d).slice(0, 1900) }),
21
+ signal: ac.signal,
22
+ });
23
+ if (!res.ok)
24
+ return { ok: false, reason: `discord ${res.status}` };
25
+ return { ok: true };
26
+ }
27
+ catch (e) {
28
+ const name = e.name;
29
+ return { ok: false, reason: name === 'AbortError' ? 'discord timed out' : 'discord unreachable' };
30
+ }
31
+ finally {
32
+ clearTimeout(t);
33
+ }
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,43 @@
1
+ import { deliveryLine } from "../format.js";
2
+ const nodeFetch = (url, init) => globalThis.fetch(url, init);
3
+ export function createTelegramAdapter(deps) {
4
+ const creds = deps.creds;
5
+ const doFetch = deps.fetch ?? nodeFetch;
6
+ const timeoutMs = deps.timeoutMs ?? 8000;
7
+ return {
8
+ name: 'telegram',
9
+ available: () => !!creds?.token && !!creds?.chatId,
10
+ deliver: async (d) => {
11
+ if (!creds)
12
+ return { ok: false, skipped: true, reason: 'telegram not configured (dexterm alerts set-telegram)' };
13
+ const ac = new AbortController();
14
+ const t = setTimeout(() => ac.abort(), timeoutMs);
15
+ try {
16
+ const res = await doFetch(`https://api.telegram.org/bot${creds.token}/sendMessage`, {
17
+ method: 'POST',
18
+ headers: { 'content-type': 'application/json' },
19
+ body: JSON.stringify({ chat_id: creds.chatId, text: deliveryLine(d), disable_web_page_preview: true }),
20
+ signal: ac.signal,
21
+ });
22
+ // Never echo the token/url: only the status code and Telegram's own error description.
23
+ if (!res.ok) {
24
+ let desc = '';
25
+ try {
26
+ const j = JSON.parse(await res.text());
27
+ desc = j.description ? `: ${j.description}` : '';
28
+ }
29
+ catch { /* ignore body */ }
30
+ return { ok: false, reason: `telegram ${res.status}${desc}`.slice(0, 160) };
31
+ }
32
+ return { ok: true };
33
+ }
34
+ catch (e) {
35
+ const name = e.name;
36
+ return { ok: false, reason: name === 'AbortError' ? 'telegram timed out' : 'telegram unreachable' };
37
+ }
38
+ finally {
39
+ clearTimeout(t);
40
+ }
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,16 @@
1
+ import { deliveryLine } from "../format.js";
2
+ export function createToastAdapter(sink) {
3
+ return {
4
+ name: 'toast',
5
+ available: () => true,
6
+ deliver: async (d) => {
7
+ try {
8
+ sink(deliveryLine(d), d);
9
+ return { ok: true };
10
+ }
11
+ catch (e) {
12
+ return { ok: false, reason: e.message };
13
+ }
14
+ },
15
+ };
16
+ }
@@ -0,0 +1,83 @@
1
+ // dexterm — alert-channel credentials (task-1398). Bot tokens and webhook URLs are NEVER in config.json
2
+ // (the config schema has no key-shaped fields, by rule). They live in ~/.dexterm/<channel>.json, mode 0600,
3
+ // outside every repo, read only by the adapters. Nothing here ever prints a secret; `redactCreds()` scrubs
4
+ // a token / webhook out of any string before it could reach a log, a toast, a test fixture or an error.
5
+ import { readFileSync, writeFileSync, mkdirSync, statSync, chmodSync, renameSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ export const telegramCredsPath = (home) => join(home, 'telegram.json');
8
+ export const discordCredsPath = (home) => join(home, 'discord.json');
9
+ function readJson(path) {
10
+ let text;
11
+ try {
12
+ text = readFileSync(path, 'utf8');
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ try {
18
+ const v = JSON.parse(text);
19
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function writeJson(path, home, value) {
26
+ mkdirSync(home, { recursive: true, mode: 0o700 });
27
+ const tmp = `${path}.tmp-${process.pid}`;
28
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
29
+ // rename is atomic; chmod again in case an old file had looser bits.
30
+ try {
31
+ chmodSync(tmp, 0o600);
32
+ }
33
+ catch { /* best effort */ }
34
+ renameSync(tmp, path);
35
+ }
36
+ export function readTelegramCreds(home) {
37
+ const j = readJson(telegramCredsPath(home));
38
+ if (!j)
39
+ return null;
40
+ const token = typeof j.token === 'string' ? j.token.trim() : '';
41
+ const chatId = typeof j.chatId === 'string' ? j.chatId.trim() : (typeof j.chatId === 'number' ? String(j.chatId) : '');
42
+ if (!token || !chatId)
43
+ return null;
44
+ return { token, chatId };
45
+ }
46
+ export function writeTelegramCreds(home, creds) {
47
+ if (!creds.token.trim())
48
+ throw new Error('telegram token is empty');
49
+ if (!/^-?\d{1,19}$/.test(creds.chatId.trim()))
50
+ throw new Error('telegram chatId must be a numeric id (e.g. 123456789 or -1001234567890)');
51
+ writeJson(telegramCredsPath(home), home, { token: creds.token.trim(), chatId: creds.chatId.trim() });
52
+ }
53
+ export function readDiscordCreds(home) {
54
+ const j = readJson(discordCredsPath(home));
55
+ if (!j)
56
+ return null;
57
+ const webhook = typeof j.webhook === 'string' ? j.webhook.trim() : '';
58
+ if (!webhook)
59
+ return null;
60
+ return { webhook };
61
+ }
62
+ export function writeDiscordCreds(home, creds) {
63
+ const w = creds.webhook.trim();
64
+ if (!/^https:\/\/(?:[a-z0-9.-]+\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+$/i.test(w)) {
65
+ throw new Error('discord webhook must be a https://discord.com/api/webhooks/<id>/<token> URL');
66
+ }
67
+ writeJson(discordCredsPath(home), home, { webhook: w });
68
+ }
69
+ /** True when the file exists with 0600-ish perms; a looser mode is reported so the user can tighten it. */
70
+ export function credsMode(path) {
71
+ try {
72
+ return statSync(path).mode & 0o777;
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ const TELEGRAM_TOKEN_RE = /\b\d{6,}:[A-Za-z0-9_-]{20,}\b/g;
79
+ const DISCORD_WEBHOOK_RE = /https:\/\/(?:[a-z0-9.-]+\.)?discord(?:app)?\.com\/api\/webhooks\/\S+/gi;
80
+ /** Scrub any bot token / webhook URL out of a string before it could be logged or shown. Total; never throws. */
81
+ export function redactCreds(s) {
82
+ return String(s ?? '').replace(TELEGRAM_TOKEN_RE, 'bot[redacted]').replace(DISCORD_WEBHOOK_RE, '[redacted-webhook]');
83
+ }
@@ -0,0 +1,66 @@
1
+ // dexterm — turn a rule-engine alert / proposal into a firewalled AlertDelivery (task-1398). Pure.
2
+ //
3
+ // Untrusted text (a token symbol, and whatever the user put in the rule's alert text) is run through the
4
+ // prompt firewall (control/bidi/ANSI stripped, URLs → [url], foreign base58 → [addr], wide glyphs → ▯,
5
+ // injection-like content neutralised) and the spoken form through the `say` sanitiser (defuses `[[…]]`,
6
+ // shortens addresses). Nothing reaches a channel raw.
7
+ import { firewallText } from '../../../data/src/index.js';
8
+ import { sanitizeSpeech } from '../../../voice/src/index.js';
9
+ const TITLE_MAX = 64;
10
+ const BODY_MAX = 180;
11
+ /** Local short address (the copy module's own, inlined so the delivery layer imports no copy runtime). */
12
+ function shortAddr(a) { return a.length <= 10 ? a : `${a.slice(0, 4)}…${a.slice(-4)}`; }
13
+ /** Firewall a string; keep the row's own mint readable, drop URLs, cap length. */
14
+ function fw(s, max, mint) {
15
+ return firewallText(s, { keepMint: mint ?? null, keepUrls: false, max }).text;
16
+ }
17
+ export function alertToDelivery(a) {
18
+ const symbol = fw(a.symbol, 24) || '?';
19
+ const body = fw(a.text, BODY_MAX, a.mint);
20
+ const title = `${symbol} ⚑`.slice(0, TITLE_MAX);
21
+ return {
22
+ kind: 'alert', level: a.level, title, body,
23
+ spoken: sanitizeSpeech(`${symbol}. ${body}`),
24
+ symbol, mint: a.mint, source: a.source, rule: a.rule, at: a.at,
25
+ };
26
+ }
27
+ export function proposalToDelivery(p) {
28
+ const symbol = fw(p.symbol, 24) || '?';
29
+ const what = p.type === 'ticket'
30
+ ? `rule proposes ${p.side} ${p.size} ${p.unit} — confirm in the app (nothing armed)`
31
+ : `rule asks the analyst: ${p.skill}`;
32
+ const body = fw(what, BODY_MAX, p.mint);
33
+ const title = `${symbol} ⚑ proposal`.slice(0, TITLE_MAX);
34
+ return {
35
+ kind: 'proposal', level: 'info', title, body,
36
+ spoken: sanitizeSpeech(`${symbol}. ${body}`),
37
+ symbol, mint: p.mint, source: p.source, rule: p.rule, at: p.at,
38
+ };
39
+ }
40
+ /**
41
+ * task-1399 — a WALLET TRACKER event (a followed wallet's decoded buy/sell) as a firewalled AlertDelivery, so a
42
+ * follow alert reaches the same push channels (desktop / Telegram / Discord) as a rule alert. The delivery layer
43
+ * stays read-only: this only READS the CopyAlert. `source` is `follow`; `rule` names the followed wallet (short,
44
+ * never a real-person label beyond the user's own words). A buy is `up`, a sell `down`. No performance claim: the
45
+ * body states the fill and carries "not a recommendation".
46
+ */
47
+ export function copyAlertToDelivery(a) {
48
+ const tr = a.trade;
49
+ const did = tr.side === 'buy' ? 'bought' : 'sold';
50
+ const who = fw(a.name ?? shortAddr(a.wallet), 24) || shortAddr(a.wallet);
51
+ const mintShort = shortAddr(tr.mint);
52
+ const usd = tr.usd !== null ? ` ≈ $${tr.usd.toFixed(0)}` : '';
53
+ const sol = tr.solDelta !== null ? ` (${Math.abs(tr.solDelta).toFixed(3)} SOL)` : '';
54
+ const symbol = mintShort;
55
+ const title = `follow: ${who} ${did}`.slice(0, TITLE_MAX);
56
+ const body = fw(`${who} ${did} ${mintShort}${usd}${sol} · not a recommendation`, BODY_MAX, tr.mint);
57
+ return {
58
+ kind: 'alert', level: tr.side === 'buy' ? 'up' : 'down', title, body,
59
+ spoken: sanitizeSpeech(`${who} ${did} ${mintShort}${usd}`),
60
+ symbol, mint: tr.mint, source: 'follow', rule: shortAddr(a.wallet), at: a.t,
61
+ };
62
+ }
63
+ /** The one-line form a text channel sends (Telegram / Discord / stdout toast). */
64
+ export function deliveryLine(d) {
65
+ return d.title === `${d.symbol} ⚑` ? `${d.title} ${d.body}` : `${d.title} — ${d.body}`;
66
+ }
@@ -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
+ }