dexterm-cli 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/packages/agents/src/bus.js +37 -1
  2. package/dist/packages/agents/src/follow.js +105 -0
  3. package/dist/packages/agents/src/index.js +5 -0
  4. package/dist/packages/agents/src/spectator-server.js +153 -0
  5. package/dist/packages/agents/src/spectator.js +212 -0
  6. package/dist/packages/cli/assets/claude/analyst-system.md +6 -1
  7. package/dist/packages/cli/src/agent-cmd.js +71 -7
  8. package/dist/packages/cli/src/agent-spectator.js +37 -0
  9. package/dist/packages/cli/src/alerts/format.js +25 -0
  10. package/dist/packages/cli/src/app-help-gen.js +365 -0
  11. package/dist/packages/cli/src/doctor.js +6 -5
  12. package/dist/packages/cli/src/follow-cmd.js +107 -1
  13. package/dist/packages/cli/src/help-cmd.js +48 -0
  14. package/dist/packages/cli/src/live/index.js +64 -3
  15. package/dist/packages/cli/src/live/screens/token-live.js +24 -0
  16. package/dist/packages/cli/src/live/signals.js +109 -0
  17. package/dist/packages/cli/src/live/trade.js +24 -1
  18. package/dist/packages/cli/src/main.js +355 -10
  19. package/dist/packages/cli/src/mcp-cmd.js +2 -7
  20. package/dist/packages/cli/src/pnl-app.js +221 -0
  21. package/dist/packages/cli/src/pnl-card.js +615 -0
  22. package/dist/packages/cli/src/record-cmd.js +455 -0
  23. package/dist/packages/cli/src/rules.js +50 -2
  24. package/dist/packages/cli/src/screens/follows.js +91 -2
  25. package/dist/packages/cli/src/screens/overlay.js +3 -0
  26. package/dist/packages/cli/src/screens/positions.js +1 -1
  27. package/dist/packages/cli/src/screens/rules.js +1 -1
  28. package/dist/packages/cli/src/spectate-cmd.js +104 -0
  29. package/dist/packages/cli/src/state.js +1 -1
  30. package/dist/packages/cli/src/version.js +1 -1
  31. package/dist/packages/cli/src/voice-cmd.js +86 -11
  32. package/dist/packages/cli/src/voice-hud.js +1 -1
  33. package/dist/packages/custom/src/rules.js +2 -1
  34. package/dist/packages/data/src/analyst-actions.js +130 -3
  35. package/dist/packages/data/src/fast-lane.js +16 -2
  36. package/dist/packages/data/src/index.js +1 -0
  37. package/dist/packages/data/src/intel/live-signals.js +185 -0
  38. package/dist/packages/data/src/trade/copy/index.js +1 -1
  39. package/dist/packages/data/src/trade/copy/stats.js +36 -2
  40. package/dist/packages/data/src/trade/copy/tracker.js +3 -2
  41. package/dist/packages/mcp/assets/kb/app-help/app-help-command.md +35 -0
  42. package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +43 -0
  43. package/dist/packages/mcp/assets/kb/app-help/backtest.md +33 -0
  44. package/dist/packages/mcp/assets/kb/app-help/bubble-map.md +35 -0
  45. package/dist/packages/mcp/assets/kb/app-help/calm-mode.md +31 -0
  46. package/dist/packages/mcp/assets/kb/app-help/change-voice-speed.md +31 -0
  47. package/dist/packages/mcp/assets/kb/app-help/change-voice.md +30 -0
  48. package/dist/packages/mcp/assets/kb/app-help/chart-popup-selftest.md +32 -0
  49. package/dist/packages/mcp/assets/kb/app-help/coin-images.md +29 -0
  50. package/dist/packages/mcp/assets/kb/app-help/configure-rpc-key.md +28 -0
  51. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent-registry.md +35 -0
  52. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +39 -0
  53. package/dist/packages/mcp/assets/kb/app-help/custom-keymap.md +32 -0
  54. package/dist/packages/mcp/assets/kb/app-help/edit-kb-or-skill.md +36 -0
  55. package/dist/packages/mcp/assets/kb/app-help/edit-rules.md +34 -0
  56. package/dist/packages/mcp/assets/kb/app-help/export-a-view.md +36 -0
  57. package/dist/packages/mcp/assets/kb/app-help/follow-a-wallet.md +33 -0
  58. package/dist/packages/mcp/assets/kb/app-help/getting-started.md +37 -0
  59. package/dist/packages/mcp/assets/kb/app-help/kol-lists.md +34 -0
  60. package/dist/packages/mcp/assets/kb/app-help/larp-check.md +36 -0
  61. package/dist/packages/mcp/assets/kb/app-help/link-a-wallet.md +41 -0
  62. package/dist/packages/mcp/assets/kb/app-help/login-with-x.md +33 -0
  63. package/dist/packages/mcp/assets/kb/app-help/narrative-board.md +32 -0
  64. package/dist/packages/mcp/assets/kb/app-help/open-a-coin.md +43 -0
  65. package/dist/packages/mcp/assets/kb/app-help/open-chart-popup.md +33 -0
  66. package/dist/packages/mcp/assets/kb/app-help/order-plans.md +33 -0
  67. package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +36 -0
  68. package/dist/packages/mcp/assets/kb/app-help/price-or-marketcap.md +38 -0
  69. package/dist/packages/mcp/assets/kb/app-help/record-headless.md +34 -0
  70. package/dist/packages/mcp/assets/kb/app-help/recorder-history.md +39 -0
  71. package/dist/packages/mcp/assets/kb/app-help/rug-check.md +39 -0
  72. package/dist/packages/mcp/assets/kb/app-help/run-doctor.md +32 -0
  73. package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +37 -0
  74. package/dist/packages/mcp/assets/kb/app-help/screen-analyst.md +181 -0
  75. package/dist/packages/mcp/assets/kb/app-help/screen-kol.md +190 -0
  76. package/dist/packages/mcp/assets/kb/app-help/screen-newpairs.md +181 -0
  77. package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +180 -0
  78. package/dist/packages/mcp/assets/kb/app-help/screen-screener.md +199 -0
  79. package/dist/packages/mcp/assets/kb/app-help/screen-token.md +240 -0
  80. package/dist/packages/mcp/assets/kb/app-help/screen-trending.md +192 -0
  81. package/dist/packages/mcp/assets/kb/app-help/screen-watchlist.md +181 -0
  82. package/dist/packages/mcp/assets/kb/app-help/set-a-filter.md +36 -0
  83. package/dist/packages/mcp/assets/kb/app-help/setup-neural-voice.md +36 -0
  84. package/dist/packages/mcp/assets/kb/app-help/share-packs.md +34 -0
  85. package/dist/packages/mcp/assets/kb/app-help/sort-the-screener.md +34 -0
  86. package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +36 -0
  87. package/dist/packages/mcp/assets/kb/app-help/standing-mandates.md +33 -0
  88. package/dist/packages/mcp/assets/kb/app-help/switch-theme.md +42 -0
  89. package/dist/packages/mcp/assets/kb/app-help/turn-on-alerts.md +37 -0
  90. package/dist/packages/mcp/assets/kb/app-help/update-dexterm.md +30 -0
  91. package/dist/packages/mcp/assets/kb/app-help/use-presets.md +42 -0
  92. package/dist/packages/mcp/assets/kb/app-help/use-voice-mode.md +36 -0
  93. package/dist/packages/mcp/assets/kb/app-help/watch-a-coin.md +34 -0
  94. package/dist/packages/mcp/assets/kb/app-help/watch-headless.md +35 -0
  95. package/dist/packages/mcp/assets/kb/app-help/what-dexterm-shows-agents.md +31 -0
  96. package/dist/packages/mcp/assets/kb/app-help/workspaces.md +38 -0
  97. package/dist/packages/mcp/src/app-help.js +135 -0
  98. package/dist/packages/mcp/src/index.js +3 -0
  99. package/dist/packages/mcp/src/server.js +90 -3
  100. package/dist/packages/mcp/src/spectator-sink.js +72 -0
  101. package/dist/packages/packs/src/index.js +2 -0
  102. package/dist/packages/packs/src/skill-care.js +206 -0
  103. package/dist/packages/popup/src/pages/spectator.css +45 -0
  104. package/dist/packages/popup/src/pages/spectator.html +25 -0
  105. package/dist/packages/popup/src/pages/spectator.js +74 -0
  106. package/dist/packages/popup/src/selftest.js +19 -0
  107. package/dist/packages/popup/src/server.js +7 -1
  108. package/dist/packages/tui/src/keymap.js +10 -0
  109. package/dist/packages/voice/src/controller.js +42 -1
  110. package/dist/packages/voice/src/doctor.js +25 -8
  111. package/dist/packages/voice/src/engines.js +8 -1
  112. package/dist/packages/voice/src/index.js +3 -1
  113. package/dist/packages/voice/src/speakable.js +111 -0
  114. package/dist/packages/voice/src/tts/piper-setup.js +229 -0
  115. package/dist/packages/voice/src/tts/piper.js +309 -66
  116. package/dist/packages/voice/src/tts/say.js +3 -2
  117. package/dist/packages/voice/src/tts/voices.js +8 -5
  118. package/package.json +1 -1
@@ -15,8 +15,11 @@
15
15
  // Helius key (keyless prints the fix) and reads public history. Secrets never print: the RPC URL stays inside the
16
16
  // data layer.
17
17
  import { createDataLayer, resolveRpcUrl, HeliusHistory } from '../../data/src/index.js';
18
- import { FollowStore, WalletTracker, defaultFollowsPath, isWalletAddress, shortAddr, CopyAuto, defaultCopyJournalPath, AUTO_HARD, autoSizeCap } from '../../data/src/trade/copy/index.js';
18
+ import { FollowStore, WalletTracker, defaultFollowsPath, isWalletAddress, shortAddr, fmtHoldMs, CopyAuto, defaultCopyJournalPath, AUTO_HARD, autoSizeCap, buildMirror, leaderSoldSince } from '../../data/src/trade/copy/index.js';
19
19
  import { PlanService, defaultPlansJournalPath } from '../../data/src/trade/plans/index.js';
20
+ import { CopyLive } from "./live/copy.js";
21
+ /** Default per-mirror cap for the headless path (same as the TUI's MIRROR_DEFAULT_CAP_USD); overridable with --cap. */
22
+ export const FOLLOW_MIRROR_DEFAULT_CAP_USD = 20;
20
23
  function flag(rest, name) { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; }
21
24
  function has(rest, name) { return rest.includes(name); }
22
25
  const USAGE = `dexterm follow <command>
@@ -25,6 +28,9 @@ const USAGE = `dexterm follow <command>
25
28
  list [--json]
26
29
  stats <addr> [--json] one Helius history fetch: trades, FIFO realised PnL, win rate (hidden under n=10)
27
30
  trades <addr> [--limit N] the decoded swaps behind the stats
31
+ mirror <addr> [--cap USD] [--k X] [--propose] [--json]
32
+ preview a MIRROR TICKET from this wallet's latest BUY (checks + size);
33
+ --propose writes it as an INERT order plan (arm only in the app, keypress/Phantom gate)
28
34
  purge --yes delete every follow and label
29
35
  auto <addr> [--per-trade USD] [--daily USD] [--max-open N] [--cooldown SEC] [--k X] [--disarm N] [--off]
30
36
  draft the auto-mirror caps (always OFF here: enabling is a keypress in the app, U then A,
@@ -35,7 +41,9 @@ function fmtStats(s) {
35
41
  `${shortAddr(s.wallet)} ${s.trades} trades (${s.buys} buys · ${s.sells} sells · ${s.transfers} transfers) over ${s.mints} mints · ${s.sample.note}`,
36
42
  `realised ${s.realisedSol >= 0 ? '+' : ''}${s.realisedSol.toFixed(4)} SOL${s.realisedUsd !== null ? ` (${s.realisedUsd >= 0 ? '+' : ''}$${s.realisedUsd.toFixed(2)} at $${s.solUsdAt}/SOL now)` : ' (no SOL price: USD not shown)'} · FIFO over fetched history, fees and tips included`,
37
43
  `win rate ${s.winRate === null ? '—' : `${(s.winRate * 100).toFixed(0)}%`} · ${s.winRateNote} · open positions ${s.open}`,
44
+ `hold time ${fmtHoldMs(s.holdMedianMs)} · ${s.holdNote}`,
38
45
  `early-entry score ${s.earlyEntry.score === null ? '—' : s.earlyEntry.score.toFixed(2)} · ${s.earlyEntry.note}`,
46
+ `source ${s.sample.source}${s.sample.asOf ? ` · as of ${new Date(s.sample.asOf).toISOString().replace('T', ' ').slice(0, 19)} (${Math.max(0, Math.round((Date.now() - s.sample.asOf) / 1000))}s ago)` : ' · not fetched yet'}`,
39
47
  ];
40
48
  for (const p of s.positions.slice(0, 12))
41
49
  out.push(` ${shortAddr(p.mint)} ${p.closed ? 'closed' : 'open '} realised ${p.realisedSol >= 0 ? '+' : ''}${p.realisedSol.toFixed(4)} SOL ${p.buys}b/${p.sells}s${p.flagged ? ' (cost-0 lot: airdrop/transfer/unpriced leg)' : ''}`);
@@ -209,6 +217,104 @@ export async function runFollow(rest, io, over = {}) {
209
217
  dl?.close();
210
218
  }
211
219
  }
220
+ // task-1400 Tier 2 (headless): the one-key MIRROR TICKET from a shell. Reuses the exact TUI path — poll the
221
+ // tracker, take the wallet's latest BUY alert, resolve its market, run buildMirror. --propose writes the result
222
+ // as an INERT `proposed` order plan through PlanService; it is armable ONLY through the app's keypress / Phantom
223
+ // gate. There is no arm/sign/execute path in this command (the plan engine refuses to arm without a gesture and
224
+ // an armed mandate), so a pipe or an agent calling `follow mirror --propose` can never trade.
225
+ case 'mirror': {
226
+ const addr = rest[1];
227
+ if (!isWalletAddress(addr)) {
228
+ errOut('usage: dexterm follow mirror <addr> [--cap USD] [--k X] [--propose] [--json]');
229
+ return 2;
230
+ }
231
+ if (!store.has(addr)) {
232
+ errOut(`dexterm follow mirror: ${shortAddr(addr)} is not followed (dexterm follow add <addr> first)`);
233
+ return 1;
234
+ }
235
+ const capUsd = flag(rest, '--cap') ? Number(flag(rest, '--cap')) : FOLLOW_MIRROR_DEFAULT_CAP_USD;
236
+ if (!(capUsd > 0)) {
237
+ errOut('dexterm follow mirror: --cap needs a USD number > 0');
238
+ return 2;
239
+ }
240
+ const kRaw = flag(rest, '--k');
241
+ const k = kRaw ? Number(kRaw) : undefined;
242
+ if (kRaw !== undefined && !(k > 0)) {
243
+ errOut('dexterm follow mirror: --k needs a number > 0');
244
+ return 2;
245
+ }
246
+ let dl = null;
247
+ let history;
248
+ if (over.history !== undefined)
249
+ history = over.history;
250
+ else {
251
+ const rpc = resolveRpcUrl({ env: io.env });
252
+ if (rpc.provider !== 'helius')
253
+ history = null;
254
+ else {
255
+ dl = createDataLayer({ rpcUrl: rpc.url, rpcFallbackUrl: rpc.fallbackUrl });
256
+ history = dl.rpcPool.heliusUrl ? new HeliusHistory(dl.http, dl.rpcPool.heliusUrl) : null;
257
+ }
258
+ }
259
+ try {
260
+ const copy = new CopyLive(dl, { followsPath: null, history, solUsd: () => over.solUsd ?? null, ...(over.clock ? { clock: over.clock } : {}) });
261
+ copy.store.add(addr);
262
+ if (copy.status().state === 'unavailable') {
263
+ errOut(`dexterm follow mirror: ${copy.status().why}`);
264
+ return 1;
265
+ }
266
+ await copy.tracker.pollOnce();
267
+ const after = copy.status();
268
+ if (after.state === 'degraded') {
269
+ errOut(`dexterm follow mirror: ${after.why}`);
270
+ return 1;
271
+ }
272
+ // Prefer a live "news" buy alert (a buy the tracker saw AFTER priming). A one-shot shell call primes and
273
+ // sees the history in a single poll, so there is usually no news alert yet; fall back to the latest BUY in
274
+ // the fetched history, synthesising the same CopyAlert shape and captioning it as history, not live news.
275
+ let alert = copy.alerts(200).find((a) => a.wallet === addr && a.trade.side === 'buy');
276
+ let fromHistory = false;
277
+ if (!alert) {
278
+ const lastBuy = copy.trades(addr).filter((t) => t.side === 'buy').sort((a, b) => b.slot - a.slot || b.sig.localeCompare(a.sig))[0];
279
+ if (!lastBuy) {
280
+ out(`no BUY from ${shortAddr(addr)} in the fetched history (nothing to mirror)`);
281
+ return 0;
282
+ }
283
+ alert = { id: `copy_hist_${lastBuy.sig.slice(0, 12)}`, t: copy.now(), wallet: addr, name: store.get(addr)?.name ?? null, trade: lastBuy, lagMs: lastBuy.tKnown ? Math.max(0, copy.now() - lastBuy.t) : null };
284
+ fromHistory = true;
285
+ }
286
+ const market = over.mirrorMarket ? await over.mirrorMarket(alert.trade.mint) : await copy.mirrorMarket(alert.trade.mint);
287
+ const soldSince = leaderSoldSince(alert, copy.trades(addr));
288
+ // No wallet-intel scan from a shell: the leader's dev-cluster / holdings stay unknown (shown as such).
289
+ const result = buildMirror(alert, { now: copy.now(), capUsd, k, market, leaderSoldSince: soldSince, leaderIntel: null });
290
+ if (has(rest, '--json')) {
291
+ out(JSON.stringify(result));
292
+ return result.ok ? 0 : 1;
293
+ }
294
+ out(`mirror of ${shortAddr(addr)}'s latest BUY — ${shortAddr(alert.trade.mint)}${market.symbol ? ` (${market.symbol})` : ''}${fromHistory ? ' · from fetched history (not a live alert)' : ' · live alert'}`);
295
+ for (const c of result.checks)
296
+ out(` ${c.ok === true ? 'ok ' : c.ok === false ? 'NO ' : '? '} ${c.name.padEnd(12)} ${c.detail}`);
297
+ if (!result.ok) {
298
+ errOut(`refused: ${result.reason} — ${result.message}`);
299
+ return 1;
300
+ }
301
+ out(` size $${result.sizeUsd.toFixed(2)} = ${result.sizeWhy}`);
302
+ if (!has(rest, '--propose')) {
303
+ out('preview only · add --propose to write the inert plan (then arm it in the app: O screen, hold a — keypress/Phantom gate)');
304
+ out('not a recommendation · a wallet\'s past fills say nothing about its next one');
305
+ return 0;
306
+ }
307
+ const plans = new PlanService({ journalPath: over.plansJournalPath === undefined ? defaultPlansJournalPath(io.env) : over.plansJournalPath });
308
+ const plan = plans.propose(result.input, { supply: result.supply, by: 'copy', ref: result.ref });
309
+ out(`proposed ${plan.id} · ${plan.status.toUpperCase()} (${plan.side} ${plan.symbol} ≈ $${plan.sizeUsd.toFixed(2)}) · INERT — nothing is armed`);
310
+ out('arm it in the app: U → O (the plans screen), select it, hold a (keypress) or approve in Phantom · the gate stays on');
311
+ out('not a recommendation · a wallet\'s past fills say nothing about its next one');
312
+ return 0;
313
+ }
314
+ finally {
315
+ dl?.close();
316
+ }
317
+ }
212
318
  default:
213
319
  if (cmd) {
214
320
  errOut(`dexterm follow: unknown subcommand ${JSON.stringify(cmd)}`);
@@ -0,0 +1,48 @@
1
+ // `dexterm help [topic]` (task-1413) — the app-help knowledge base from the command line, backed by the
2
+ // same generated entries as the analyst's `app_help` MCP tool. No network, no model.
3
+ // dexterm help list the topics
4
+ // dexterm help export show the best-matching topic (exact key/command + how-to)
5
+ // dexterm help screen token multi-word query
6
+ import { loadAppHelp, searchAppHelp, getAppHelp } from '../../mcp/src/index.js';
7
+ function show(e, io) {
8
+ io.stdout.write(`\n${e.title} (${e.id})\n`);
9
+ io.stdout.write(`${'─'.repeat(Math.min(60, e.title.length + e.id.length + 4))}\n`);
10
+ if (e.keys.length)
11
+ io.stdout.write(`Keys: ${e.keys.join(' ')}\n`);
12
+ if (e.commands.length)
13
+ io.stdout.write(`Commands: ${e.commands.map((c) => `dexterm ${c}`).join(' ')}\n`);
14
+ if (e.flags.length)
15
+ io.stdout.write(`Flags: ${e.flags.join(' ')}\n`);
16
+ io.stdout.write(`\n${e.body}\n`);
17
+ }
18
+ export function helpCommand(argv, io) {
19
+ const entries = loadAppHelp();
20
+ if (!entries.length) {
21
+ io.stderr.write('no app-help entries found\n');
22
+ return 1;
23
+ }
24
+ const query = argv.join(' ').trim();
25
+ if (!query) {
26
+ io.stdout.write('dexterm help <topic> — how the app works. Topics:\n\n');
27
+ for (const e of entries)
28
+ io.stdout.write(` ${e.id.padEnd(26)} ${e.title}\n`);
29
+ io.stdout.write('\nExamples: dexterm help export · dexterm help voice speed · dexterm help screener\n');
30
+ return 0;
31
+ }
32
+ // Exact id wins; otherwise the best retrieval match, with near-misses listed.
33
+ const exact = getAppHelp(query.replace(/\s+/g, '-'), entries) ?? getAppHelp(query, entries);
34
+ if (exact) {
35
+ show(exact, io);
36
+ return 0;
37
+ }
38
+ const results = searchAppHelp(query, 5, entries);
39
+ if (!results.length) {
40
+ io.stdout.write(`No app-help topic matches "${query}". Run \`dexterm help\` to list topics.\n`);
41
+ return 1;
42
+ }
43
+ show(results[0], io);
44
+ if (results.length > 1) {
45
+ io.stdout.write(`\nRelated: ${results.slice(1).map((r) => r.id).join(', ')}\n`);
46
+ }
47
+ return 0;
48
+ }
@@ -10,15 +10,24 @@ import { NewPairsLive } from "./newpairs.js";
10
10
  import { WatchlistLive } from "./watchlist.js";
11
11
  import { TradeTicketLive, PositionsLive, LivePositions, CombinedPositions, POSITIONS_REFRESH_MS } from "./trade.js";
12
12
  import { IntelLive } from "./intel.js";
13
+ import { LiveSignalsLive } from "./signals.js";
13
14
  import { CopyLive } from "./copy.js";
14
15
  export { TokenLive, INTERVALS, panelCaption, emptyView } from "./token.js";
15
16
  export { NewPairsLive, latencyStats, percentile, RECENT_POLL_MS, NEW_TOKENS_POLL_MS, ENRICH_POLL_MS } from "./newpairs.js";
16
17
  export { WatchlistLive, WATCH_REFRESH_MS } from "./watchlist.js";
17
18
  export { TradeTicketLive, PositionsLive, LivePositions, CombinedPositions, POSITIONS_REFRESH_MS } from "./trade.js";
18
19
  export { IntelLive, popupBubbleVM, unavailableText, INTEL_AUTOSCAN_DELAY_MS } from "./intel.js";
20
+ export { LiveSignalsLive, buildObservation, SIGNALS_LOG_MAX } from "./signals.js";
19
21
  export { renderTokenLive } from "./screens/token-live.js";
20
22
  export { renderNewPairsLive } from "./screens/newpairs-live.js";
21
23
  export { renderWatchlistLive } from "./screens/watchlist-live.js";
24
+ /** task-1404: how often the open token's credit-metered wallet-intel leg (holders/bundle/sniper/fresh) is re-scanned. */
25
+ export const INTEL_RESCAN_MS = 45_000;
26
+ /** A transient one-liner for a fired live signal (⚑ SYMBOL-less; the token screen shows the full strip). */
27
+ function signalNotice(s) {
28
+ const age = s.dataAgeMs >= 1000 ? ` · ${Math.round(s.dataAgeMs / 1000)}s` : '';
29
+ return `⚑ ${s.title}: ${s.text} (${s.source}${age})`;
30
+ }
22
31
  export class LiveContext {
23
32
  dl;
24
33
  mode;
@@ -54,6 +63,8 @@ export class LiveContext {
54
63
  token = null;
55
64
  /** task-1340 wallet intel: the holder scan per mint (light on open, deep for watched coins) behind the bubble map. */
56
65
  intel;
66
+ /** task-1404 LIVE holder/rug signals: turns the open token's repeated holder/safety reads into signal events. */
67
+ signals;
57
68
  /** task-1360 copy trading Tier 1: the follow store + wallet tracker (alerts on a followed wallet's buys/sells). */
58
69
  copy;
59
70
  /**
@@ -86,6 +97,8 @@ export class LiveContext {
86
97
  onLog;
87
98
  lastFastLogAt = 0;
88
99
  lastFastLog = '';
100
+ /** task-1404: last time the open token's wallet-intel leg was re-scanned (slow cadence; see refreshOpenIntel). */
101
+ lastIntelRescanAt = 0;
89
102
  constructor(dl, opts) {
90
103
  this.dl = dl;
91
104
  this.mode = opts.mode;
@@ -124,7 +137,7 @@ export class LiveContext {
124
137
  endpoints: opts.fastEndpoints,
125
138
  solUsd: () => this.solUsd,
126
139
  ws: opts.ws,
127
- onPrice: (_p) => emit('fastprice'),
140
+ onPrice: (_p) => { this.observeSignals(); emit('fastprice'); },
128
141
  onStatus: () => { emit('fastlane:status'); this.logFast(); },
129
142
  })
130
143
  : null;
@@ -166,7 +179,10 @@ export class LiveContext {
166
179
  ? new LivePositions(this.trade.paths?.liveLedger ?? defaultLiveLedgerPath(), { now: this.now, mark: (m) => this.positions.markOf(m), solUsd: () => this.positions.solUsd() ?? this.solUsd })
167
180
  : null;
168
181
  this.positionSource = new CombinedPositions(this.positions, this.livePositions);
169
- this.intel = new IntelLive(dl, { now: this.now, onChange: () => { this.syncIntelRows(); emit('intel'); }, isWatched: (mint) => this.watch.has(mint) });
182
+ this.intel = new IntelLive(dl, { now: this.now, onChange: () => { this.syncIntelRows(); this.observeSignals(); emit('intel'); }, isWatched: (mint) => this.watch.has(mint) });
183
+ // task-1404: the live holder/rug signals engine. A fired signal sets a transient notice and redraws; the
184
+ // token screen shows the recent signals, and the rules engine reads `signals.fields()` so each is an alert trigger.
185
+ this.signals = new LiveSignalsLive({ now: this.now, onSignal: (s) => { this.notice = signalNotice(s); emit('signals'); } });
170
186
  // task-1360: followed wallets are polled only in a real live run (replay never spends credits); the store is always readable.
171
187
  this.copy = new CopyLive(opts.mode === 'live' ? dl : null, { onChange: (r) => emit(r), solUsd: () => this.solUsd, env: opts.copy?.env, followsPath: opts.copy?.followsPath, history: opts.copy?.history, pollMs: opts.copy?.pollMs });
172
188
  }
@@ -376,7 +392,7 @@ export class LiveContext {
376
392
  }
377
393
  /** Open a token: a fresh loader, first load kicked off; the view is readable immediately (row fields). */
378
394
  openToken(row) {
379
- const t = new TokenLive(this.dl, row, { interval: this.token?.view.interval ?? this.interval, now: this.now, onChange: () => { this.syncChainRows(t); this.onChange('token'); } });
395
+ const t = new TokenLive(this.dl, row, { interval: this.token?.view.interval ?? this.interval, now: this.now, onChange: () => { this.syncChainRows(t); this.observeSignals(); this.onChange('token'); } });
380
396
  this.token = t;
381
397
  this.syncFastSet(); // promote the opened token to the fast lane (slow→fast, no seam)
382
398
  this.x?.setFocus({ mint: row.mint, symbol: row.symbol }); // task-1357: the open token's X search (60 s, cached 15 min)
@@ -423,6 +439,51 @@ export class LiveContext {
423
439
  if (sigs.length)
424
440
  this.social.ingest(sigs.map((s) => chainSignalToRaw(s, this.now())));
425
441
  }
442
+ /** The current inputs for the live-signals engine: the open token's view + its settled intel scan + fast-lane curve. */
443
+ signalInputs() {
444
+ if (!this.token)
445
+ return null;
446
+ const mint = this.token.view.mint;
447
+ const result = this.intel.entry(mint)?.result;
448
+ const ok = result && result.state === 'ok' ? result : null;
449
+ return {
450
+ view: this.token.view,
451
+ intel: ok ? { analysis: ok.analysis, fetchedAt: ok.fetchedAt, via: ok.via.history ? 'helius' : ok.via.holders } : null,
452
+ curve: this.fastLane?.curve(mint) ?? null,
453
+ };
454
+ }
455
+ /**
456
+ * task-1404: feed one moment of the open token's holder/rug state to the live-signals engine. Called whenever the
457
+ * token, the intel scan or a fast-lane push changed something; the engine only emits on a real threshold-crossing
458
+ * change, so this is cheap to call on every redraw. Nothing fires until a second observation of the same field.
459
+ */
460
+ observeSignals() {
461
+ if (this.closed)
462
+ return;
463
+ const inputs = this.signalInputs();
464
+ if (inputs)
465
+ this.signals.observe(inputs);
466
+ }
467
+ /**
468
+ * task-1404: re-run the open token's wallet-intel scan on a slow cadence so holder/concentration/bundle/sniper
469
+ * signals stay LIVE, not computed once on open. Gated: live mode, the history leg can actually run (else a scan
470
+ * would only spend public-RPC calls to still print a dash), not already in flight, and at most once per
471
+ * INTEL_RESCAN_MS. The cheap on-chain reads (authorities, LP, liq, price) already refresh with token.load()
472
+ * every 10 s and bonding streams sub-second; this covers the credit-metered Helius leg.
473
+ */
474
+ refreshOpenIntel(now) {
475
+ if (this.mode !== 'live' || !this.token)
476
+ return;
477
+ if (now - this.lastIntelRescanAt < INTEL_RESCAN_MS)
478
+ return;
479
+ if (!this.intel.historyAvailable().ok)
480
+ return;
481
+ const v = this.token.view;
482
+ if (this.intel.entry(v.mint)?.state === 'loading')
483
+ return;
484
+ this.lastIntelRescanAt = now;
485
+ void this.intel.scan({ mint: v.mint, symbol: v.symbol, name: v.name, holderCount: v.holders ?? null }, { force: true }).catch(() => { });
486
+ }
426
487
  /** The token screen's `1`..`6` keys. */
427
488
  setTimeframe(n) {
428
489
  const iv = INTERVALS[n - 1];
@@ -214,6 +214,30 @@ export function renderTokenLive(c, state, v) {
214
214
  sy += 1;
215
215
  }
216
216
  }
217
+ // task-1404: LIVE holder/rug signals — the changes seen since the token was opened (dev sell, concentration
218
+ // shift, LP unlock, authority flip, new bundle/sniper, fresh inflow, bonding, migration). Each is a risk flag,
219
+ // not a verdict, and carries its SOURCE and the AGE of the data it read; the `stale` mark shows honestly.
220
+ const sigs = state.live?.signals?.recent(v.mint, 4) ?? [];
221
+ if (sigs.length && sy < sb.y + sb.h) {
222
+ sy += sy + 1 < sb.y + sb.h ? 1 : 0;
223
+ if (sy < sb.y + sb.h) {
224
+ c.text(sb.x + 1, sy, 'LIVE SIGNALS', { fg: t.dim, bg: sb.bg });
225
+ sy += 1;
226
+ }
227
+ const staleAfter = 60_000;
228
+ for (const s of sigs) {
229
+ if (sy >= sb.y + sb.h)
230
+ break;
231
+ const color = s.level === 'warn' ? t.warn : s.level === 'down' ? t.down : s.level === 'up' ? t.up : t.muted;
232
+ const ageMs = Math.max(0, now - s.at);
233
+ const stale = ageMs > staleAfter;
234
+ const meta = `${s.source} · ${ageMs >= 1000 ? `${Math.round(ageMs / 1000)}s` : 'now'}${stale ? ' · stale' : ''}`;
235
+ const metaClip = clip(meta, Math.max(8, Math.floor((sb.w - 2) / 2)));
236
+ c.text(sb.x + 1, sy, `⚑ ${s.title}`, { fg: color, bg: sb.bg, b: s.level === 'warn' || s.level === 'down' }, sb.w - 2 - metaClip.length - 1);
237
+ c.textR(sb.x + 1, sy, sb.w - 2, metaClip, { fg: stale ? t.warn : t.dim, bg: sb.bg, i: true });
238
+ sy += 1;
239
+ }
240
+ }
217
241
  if (bp !== 'compact') {
218
242
  const hx = statsW;
219
243
  const hw = mainW - statsW;
@@ -0,0 +1,109 @@
1
+ // dexterm — LIVE holder/rug SIGNALS binding (task-1404). Turns the open token's repeatedly-refreshed holder and
2
+ // safety reads (market/authorities/rugcheck panels + the wallet-intel scan + the fast-lane bonding curve) into
3
+ // SIGNAL events through the pure @dexterm/data LiveSignalsEngine, keeps a small per-mint log for the token screen,
4
+ // and exposes the latest numeric values as RULE FIELDS so every signal is an alert trigger (task-1398 pattern).
5
+ //
6
+ // It owns no timers and does no I/O: the UI loop / LiveContext calls observe() whenever the token, the intel scan
7
+ // or a fast-lane push changed something. The engine dedups (only a threshold-crossing change emits), so calling it
8
+ // on every redraw is cheap. Read-only: nothing here trades or signs.
9
+ import { LiveSignalsEngine, bondingProgress, } from '../../../data/src/index.js';
10
+ /** Max signals kept per mint for the token-screen strip (newest first). */
11
+ export const SIGNALS_LOG_MAX = 40;
12
+ function fromPanel(p, value, fallbackSource) {
13
+ if (!p || value == null || !Number.isFinite(value) || p.fetchedAt == null)
14
+ return null;
15
+ return { value, source: p.via ?? fallbackSource, fetchedAt: p.fetchedAt, stale: p.stale || p.state === 'error' };
16
+ }
17
+ function authFromPanel(p, present) {
18
+ if (!p || present == null || p.fetchedAt == null)
19
+ return null;
20
+ return { present, source: p.via ?? 'authorities', fetchedAt: p.fetchedAt, stale: p.stale || p.state === 'error' };
21
+ }
22
+ function fromIntel(value, intel) {
23
+ if (value == null || !Number.isFinite(value) || !intel || intel.fetchedAt == null)
24
+ return null;
25
+ return { value, source: intel.via ?? 'helius', fetchedAt: intel.fetchedAt, stale: false };
26
+ }
27
+ /**
28
+ * Build one observation from the token view + intel scan + fast-lane curve. Each field keeps its OWN source and
29
+ * fetch time so a signal is honest about where it came from and how old the data is. The richer wallet-intel
30
+ * figures (ex-pool top-10, creator %, bundled, snipers, fresh, cluster) are preferred when the scan has landed;
31
+ * otherwise the panel reads (Jupiter audit top-10/dev) stand in. Pure + exported for the tests.
32
+ */
33
+ export function buildObservation(inputs, now) {
34
+ const v = inputs.view;
35
+ const I = inputs.intel?.analysis?.insights ?? null;
36
+ const panels = v.panels ?? {};
37
+ const obs = { mint: v.mint, at: now };
38
+ // top-10 concentration (pools excluded): the scan's ex-pool figure first, else the holders panel's top-10.
39
+ obs.top10 = (I && typeof I.top10.exPool === 'number' ? fromIntel(I.top10.exPool, inputs.intel) : null) ?? fromPanel(panels.holders, v.top10, 'holders');
40
+ // creator / dev share: the scan's creatorPctNow first, else the market panel's Jupiter audit devPct.
41
+ obs.dev = (I ? fromIntel(I.dev.creatorPctNow, inputs.intel) : null) ?? fromPanel(panels.market, v.dev, 'jupiter');
42
+ // bundled / snipers / fresh / cluster only exist once the launch window / funders were read (Helius).
43
+ if (I && I.coverage.launchTxs > 0) {
44
+ obs.bundled = fromIntel(I.bundle.bundlePctOfTopSample, inputs.intel);
45
+ obs.snipers = fromIntel(I.snipers.count, inputs.intel);
46
+ }
47
+ if (I && I.coverage.fundingLookups > 0)
48
+ obs.fresh = fromIntel(I.fresh.freshAtBuyPctOfTopSample, inputs.intel);
49
+ const cluster = inputs.intel?.analysis?.clusters?.[0]?.pct ?? null;
50
+ obs.cluster = fromIntel(cluster, inputs.intel);
51
+ // LP locked (RugCheck), liquidity (market), mint/freeze authority (authorities panel).
52
+ obs.lpLocked = fromPanel(panels.rugcheck, v.lpLocked, 'rugcheck');
53
+ obs.liq = fromPanel(panels.market, v.liq, 'market');
54
+ obs.mintAuth = authFromPanel(panels.authorities, v.mintAuth);
55
+ obs.freezeAuth = authFromPanel(panels.authorities, v.freezeAuth);
56
+ // bonding-curve progress + migration from the on-chain fast-lane push (sub-second), when streaming.
57
+ if (inputs.curve) {
58
+ const bp = bondingProgress(inputs.curve.curve.vToken);
59
+ if (bp != null)
60
+ obs.bonding = { value: bp, source: 'onchain-curve', fetchedAt: inputs.curve.at, stale: false };
61
+ obs.migrated = { value: inputs.curve.curve.complete, source: 'onchain-curve', fetchedAt: inputs.curve.at, stale: false };
62
+ }
63
+ return obs;
64
+ }
65
+ export class LiveSignalsLive {
66
+ engine;
67
+ now;
68
+ onSignal;
69
+ logByMint = new Map();
70
+ /** Signals emitted this session (all mints), newest last — a cheap counter/feed for the smoke script + tests. */
71
+ emitted = [];
72
+ constructor(opts = {}) {
73
+ this.engine = new LiveSignalsEngine({ thresholds: opts.thresholds });
74
+ this.now = opts.now ?? (() => Date.now());
75
+ this.onSignal = opts.onSignal;
76
+ }
77
+ /** Observe one moment of a token's state; records and returns any new signals. */
78
+ observe(inputs) {
79
+ const obs = buildObservation(inputs, this.now());
80
+ const sigs = this.engine.observe(obs);
81
+ for (const s of sigs) {
82
+ const arr = this.logByMint.get(s.mint) ?? [];
83
+ arr.unshift(s);
84
+ if (arr.length > SIGNALS_LOG_MAX)
85
+ arr.length = SIGNALS_LOG_MAX;
86
+ this.logByMint.set(s.mint, arr);
87
+ this.emitted.push(s);
88
+ this.onSignal?.(s);
89
+ }
90
+ return sigs;
91
+ }
92
+ /** Recent signals for a mint, newest first (token-screen LIVE SIGNALS strip). */
93
+ recent(mint, limit = 8) {
94
+ return (this.logByMint.get(mint) ?? []).slice(0, limit);
95
+ }
96
+ /** Latest numeric fields for the rules engine (merged into the open-token RuleRow in ruleRowsOf). */
97
+ fields(mint) {
98
+ return this.engine.fields(mint);
99
+ }
100
+ /** Per-field snapshot (value + source + age + stale) for the strip header. */
101
+ snapshot(mint, now = this.now()) {
102
+ return this.engine.snapshot(mint, now);
103
+ }
104
+ /** Drop a mint the UI no longer tracks. */
105
+ forget(mint) {
106
+ this.engine.forget(mint);
107
+ this.logByMint.delete(mint);
108
+ }
109
+ }
@@ -680,6 +680,8 @@ export class PositionsLive {
680
680
  now;
681
681
  /** mint → current price in SOL per whole token. */
682
682
  marks = new Map();
683
+ /** task-1407: when each mark was taken (ms), so a PnL card can print the mark's age. */
684
+ markTimes = new Map();
683
685
  solPriceUsd = null;
684
686
  loading = false;
685
687
  heldMemo = new Map();
@@ -703,6 +705,10 @@ export class PositionsLive {
703
705
  markOf(mint) {
704
706
  return this.marks.get(mint) ?? null;
705
707
  }
708
+ /** task-1407: when the mark for a mint was taken (ms), or null when there is none. */
709
+ markAt(mint) {
710
+ return this.marks.has(mint) ? this.markTimes.get(mint) ?? null : null;
711
+ }
706
712
  /**
707
713
  * PositionSource: the open paper position for a mint, or null. One indexed SQLite read, reused for
708
714
  * HELD_MEMO_MS so a 30 fps render loop does not hit the ledger every frame. A closed wallet reads as flat.
@@ -758,8 +764,10 @@ export class PositionsLive {
758
764
  continue;
759
765
  }
760
766
  const usd = byMint.get(m)?.pair.priceUsd ?? null;
761
- if (usd && usd > 0)
767
+ if (usd && usd > 0) {
762
768
  this.marks.set(m, usd / this.solPriceUsd);
769
+ this.markTimes.set(m, this.now());
770
+ }
763
771
  }
764
772
  }
765
773
  }
@@ -865,6 +873,21 @@ export class LivePositions {
865
873
  this.rowsMemo = { at: now, rows };
866
874
  return rows;
867
875
  }
876
+ /** task-1407: the raw ledger rows (newest first, swaps only) with network + signature, for the LIVE PnL card. */
877
+ fills(mint, limit = 500) {
878
+ try {
879
+ const l = new LiveLedger({ path: this.path, readOnly: true });
880
+ try {
881
+ return l.trades(limit * 2).filter((r) => r.kind === 'swap' && r.mint === mint).slice(0, limit);
882
+ }
883
+ finally {
884
+ l.close();
885
+ }
886
+ }
887
+ catch {
888
+ return [];
889
+ }
890
+ }
868
891
  /** Confirmed live fills (newest first) for the popup markers / history; probes excluded. */
869
892
  records(limit = 100, mint) {
870
893
  try {