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.
- package/dist/packages/agents/src/bus.js +57 -6
- package/dist/packages/agents/src/executor.js +25 -0
- package/dist/packages/agents/src/follow.js +105 -0
- package/dist/packages/agents/src/gate.js +3 -0
- package/dist/packages/agents/src/identity.js +41 -2
- package/dist/packages/agents/src/index.js +5 -0
- package/dist/packages/agents/src/spectator-server.js +153 -0
- package/dist/packages/agents/src/spectator.js +212 -0
- package/dist/packages/agents/src/tiers.js +5 -0
- package/dist/packages/agents/src/tools.js +3 -0
- package/dist/packages/cli/assets/claude/analyst-system.md +6 -1
- package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +11 -3
- package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +13 -3
- package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +3 -3
- package/dist/packages/cli/src/agent-cmd.js +107 -10
- package/dist/packages/cli/src/agent-spectator.js +37 -0
- package/dist/packages/cli/src/alerts/adapters/desktop.js +40 -0
- package/dist/packages/cli/src/alerts/adapters/discord.js +36 -0
- package/dist/packages/cli/src/alerts/adapters/telegram.js +43 -0
- package/dist/packages/cli/src/alerts/adapters/toast.js +16 -0
- package/dist/packages/cli/src/alerts/creds.js +83 -0
- package/dist/packages/cli/src/alerts/format.js +66 -0
- package/dist/packages/cli/src/alerts/index.js +10 -0
- package/dist/packages/cli/src/alerts/notifier.js +53 -0
- package/dist/packages/cli/src/alerts/route.js +19 -0
- package/dist/packages/cli/src/alerts/types.js +1 -0
- package/dist/packages/cli/src/alerts-cmd.js +212 -0
- package/dist/packages/cli/src/app-help-gen.js +365 -0
- package/dist/packages/cli/src/backtest-cmd.js +62 -3
- package/dist/packages/cli/src/coin-target.js +2 -0
- package/dist/packages/cli/src/doctor.js +21 -6
- package/dist/packages/cli/src/export.js +235 -0
- package/dist/packages/cli/src/follow-cmd.js +107 -1
- package/dist/packages/cli/src/help-cmd.js +48 -0
- package/dist/packages/cli/src/live/index.js +66 -3
- package/dist/packages/cli/src/live/screens/token-live.js +24 -0
- package/dist/packages/cli/src/live/signals.js +109 -0
- package/dist/packages/cli/src/live/trade.js +24 -1
- package/dist/packages/cli/src/main.js +596 -49
- package/dist/packages/cli/src/mcp-cmd.js +321 -0
- package/dist/packages/cli/src/pnl-app.js +221 -0
- package/dist/packages/cli/src/pnl-card.js +615 -0
- package/dist/packages/cli/src/record-cmd.js +455 -0
- package/dist/packages/cli/src/rules.js +69 -2
- package/dist/packages/cli/src/screens/follows.js +91 -2
- package/dist/packages/cli/src/screens/overlay.js +3 -0
- package/dist/packages/cli/src/screens/positions.js +1 -1
- package/dist/packages/cli/src/screens/rules.js +1 -1
- package/dist/packages/cli/src/skill-eval-build.js +109 -0
- package/dist/packages/cli/src/spectate-cmd.js +104 -0
- package/dist/packages/cli/src/state.js +1 -1
- package/dist/packages/cli/src/update.js +294 -0
- package/dist/packages/cli/src/version.js +3 -0
- package/dist/packages/cli/src/voice-cmd.js +194 -0
- package/dist/packages/cli/src/voice-hud.js +1 -1
- package/dist/packages/cli/src/watch-cmd.js +190 -0
- package/dist/packages/custom/src/rules.js +3 -2
- package/dist/packages/custom/src/schema.js +24 -1
- package/dist/packages/data/src/analyst-actions.js +130 -3
- package/dist/packages/data/src/coin-target.js +82 -0
- package/dist/packages/data/src/fast-lane.js +16 -2
- package/dist/packages/data/src/index.js +4 -0
- package/dist/packages/data/src/intel/helius-history.js +12 -1
- package/dist/packages/data/src/intel/live-signals.js +185 -0
- package/dist/packages/data/src/larp/link-baseline.js +196 -0
- package/dist/packages/data/src/sources/dexscreener.js +14 -0
- package/dist/packages/data/src/sources/pumpportal.js +6 -1
- package/dist/packages/data/src/trade/copy/index.js +1 -1
- package/dist/packages/data/src/trade/copy/stats.js +36 -2
- package/dist/packages/data/src/trade/copy/tracker.js +3 -2
- package/dist/packages/mcp/assets/kb/app-help/app-help-command.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +43 -0
- package/dist/packages/mcp/assets/kb/app-help/backtest.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/bubble-map.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/calm-mode.md +31 -0
- package/dist/packages/mcp/assets/kb/app-help/change-voice-speed.md +31 -0
- package/dist/packages/mcp/assets/kb/app-help/change-voice.md +30 -0
- package/dist/packages/mcp/assets/kb/app-help/chart-popup-selftest.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/coin-images.md +29 -0
- package/dist/packages/mcp/assets/kb/app-help/configure-rpc-key.md +28 -0
- package/dist/packages/mcp/assets/kb/app-help/connect-an-agent-registry.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +39 -0
- package/dist/packages/mcp/assets/kb/app-help/custom-keymap.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/edit-kb-or-skill.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/edit-rules.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/export-a-view.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/follow-a-wallet.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/getting-started.md +37 -0
- package/dist/packages/mcp/assets/kb/app-help/kol-lists.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/larp-check.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/link-a-wallet.md +41 -0
- package/dist/packages/mcp/assets/kb/app-help/login-with-x.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/narrative-board.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/open-a-coin.md +43 -0
- package/dist/packages/mcp/assets/kb/app-help/open-chart-popup.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/order-plans.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/price-or-marketcap.md +38 -0
- package/dist/packages/mcp/assets/kb/app-help/record-headless.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/recorder-history.md +39 -0
- package/dist/packages/mcp/assets/kb/app-help/rug-check.md +39 -0
- package/dist/packages/mcp/assets/kb/app-help/run-doctor.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +37 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-analyst.md +181 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-kol.md +190 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-newpairs.md +181 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +180 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-screener.md +199 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-token.md +240 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-trending.md +192 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-watchlist.md +181 -0
- package/dist/packages/mcp/assets/kb/app-help/set-a-filter.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/setup-neural-voice.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/share-packs.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/sort-the-screener.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/standing-mandates.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/switch-theme.md +42 -0
- package/dist/packages/mcp/assets/kb/app-help/turn-on-alerts.md +37 -0
- package/dist/packages/mcp/assets/kb/app-help/update-dexterm.md +30 -0
- package/dist/packages/mcp/assets/kb/app-help/use-presets.md +42 -0
- package/dist/packages/mcp/assets/kb/app-help/use-voice-mode.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/watch-a-coin.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/watch-headless.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/what-dexterm-shows-agents.md +31 -0
- package/dist/packages/mcp/assets/kb/app-help/workspaces.md +38 -0
- package/dist/packages/mcp/src/app-help.js +135 -0
- package/dist/packages/mcp/src/index.js +3 -0
- package/dist/packages/mcp/src/server.js +179 -5
- package/dist/packages/mcp/src/spectator-sink.js +72 -0
- package/dist/packages/packs/src/index.js +3 -0
- package/dist/packages/packs/src/skill-backtest.js +300 -0
- package/dist/packages/packs/src/skill-care.js +206 -0
- package/dist/packages/popup/src/pages/spectator.css +45 -0
- package/dist/packages/popup/src/pages/spectator.html +25 -0
- package/dist/packages/popup/src/pages/spectator.js +74 -0
- package/dist/packages/popup/src/selftest.js +19 -0
- package/dist/packages/popup/src/server.js +7 -1
- package/dist/packages/tui/src/index.js +3 -0
- package/dist/packages/tui/src/keymap.js +12 -0
- package/dist/packages/tui/src/raster-font.js +195 -0
- package/dist/packages/tui/src/raster.js +213 -0
- package/dist/packages/voice/src/controller.js +57 -4
- package/dist/packages/voice/src/doctor.js +96 -11
- package/dist/packages/voice/src/download.js +184 -0
- package/dist/packages/voice/src/engines.js +55 -8
- package/dist/packages/voice/src/index.js +9 -3
- package/dist/packages/voice/src/speakable.js +111 -0
- package/dist/packages/voice/src/stt/whisper.js +14 -4
- package/dist/packages/voice/src/tts/espeak.js +131 -0
- package/dist/packages/voice/src/tts/piper-setup.js +229 -0
- package/dist/packages/voice/src/tts/piper.js +449 -0
- package/dist/packages/voice/src/tts/say.js +72 -35
- package/dist/packages/voice/src/tts/voices.js +209 -0
- package/package.json +5 -9
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// dexterm — EXPORT / SHARE (task-1401). One key (`E`) renders the current view to a PNG plus a plain-text report in
|
|
2
|
+
// ~/.dexterm/exports (DEXTERM_HOME overrides). Nothing is uploaded, posted or copied anywhere else.
|
|
3
|
+
//
|
|
4
|
+
// The renderer is reusable on purpose (task-1407 PNL cards build on it): give `renderExportPng` / `renderExportText`
|
|
5
|
+
// / `writeExport` an ExportView — any rendered Canvas plus its title, the data label (live / sample / replay) and the
|
|
6
|
+
// data ages — and it frames the view with a header band (title, the label, the export time) and a footer band (every
|
|
7
|
+
// data age, "nothing was uploaded"). The label is never optional: a sample-data frame always says SAMPLE DATA.
|
|
8
|
+
//
|
|
9
|
+
// The frame is rendered CALM (no rain, glitch or scanlines) with no toast, help card or prompt on top, so the PNG
|
|
10
|
+
// is the data, not an effect. PNGs are pure TypeScript (@dexterm/tui rasterize + @dexterm/data encodePng).
|
|
11
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { Canvas, rasterizeCanvas, fmtUsd, fmtPrice, fmtPct } from '../../tui/src/index.js';
|
|
14
|
+
import { encodePng } from '../../data/src/index.js';
|
|
15
|
+
import { dextermHome } from "./boot-state.js";
|
|
16
|
+
import { cleanText } from "./screener/rows.js";
|
|
17
|
+
import { rugP, fmtRugP, RUG_MODEL_LABEL } from "./screener/rug.js";
|
|
18
|
+
import { renderFrame } from "./state.js";
|
|
19
|
+
export const LABEL_TEXT = {
|
|
20
|
+
live: '● LIVE DATA',
|
|
21
|
+
sample: '◌ SAMPLE DATA · not real prices',
|
|
22
|
+
replay: '◌ REPLAY · recorded data',
|
|
23
|
+
};
|
|
24
|
+
/** `4s` / `3m` / `2h` / `5d`; `—` when unknown. */
|
|
25
|
+
export function fmtAge(sec) {
|
|
26
|
+
if (sec === null || !Number.isFinite(sec))
|
|
27
|
+
return '—';
|
|
28
|
+
const s = Math.max(0, Math.round(sec));
|
|
29
|
+
return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86_400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86_400)}d`;
|
|
30
|
+
}
|
|
31
|
+
const ageText = (a) => (a.ageSec !== null ? `${a.name} ${fmtAge(a.ageSec)}` : `${a.name} ${a.state || 'n/a'}`);
|
|
32
|
+
/** Local timestamp `2026-10-16 01:23:45` (the machine's own clock; the report also carries the ISO time). */
|
|
33
|
+
function localStamp(now) {
|
|
34
|
+
const d = new Date(now);
|
|
35
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
36
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
37
|
+
}
|
|
38
|
+
/** Wrap `parts` joined by ` · ` into lines of at most `w` columns. */
|
|
39
|
+
function wrapParts(prefix, parts, w) {
|
|
40
|
+
const out = [];
|
|
41
|
+
let line = prefix;
|
|
42
|
+
for (const p of parts) {
|
|
43
|
+
const add = line === prefix ? p : ` · ${p}`;
|
|
44
|
+
if (line.length + add.length > w && line !== prefix) {
|
|
45
|
+
out.push(line);
|
|
46
|
+
line = ' '.repeat(prefix.length) + p;
|
|
47
|
+
}
|
|
48
|
+
else
|
|
49
|
+
line += add;
|
|
50
|
+
}
|
|
51
|
+
out.push(line);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Frame a rendered view: a header band (title, label, export time), the view itself, and a footer band (every data
|
|
56
|
+
* age, local-only note). Returns a new canvas; the view canvas is not changed.
|
|
57
|
+
*/
|
|
58
|
+
export function composeExportCanvas(v, now) {
|
|
59
|
+
const src = v.canvas;
|
|
60
|
+
const w = Math.max(src.w, 60);
|
|
61
|
+
const t = src.theme;
|
|
62
|
+
const footer = [
|
|
63
|
+
...(v.mint ? [` contract ${v.mint}`] : []),
|
|
64
|
+
...wrapParts(v.label === 'replay' ? ' data ages (replay clock): ' : ' data ages: ', v.ages.length ? v.ages.map(ageText) : ['none reported for this view'], w - 1),
|
|
65
|
+
` exported locally by dexterm ${localStamp(now)} · nothing was uploaded · not financial advice`,
|
|
66
|
+
];
|
|
67
|
+
const c = new Canvas(w, src.h + 2 + footer.length, t);
|
|
68
|
+
c.fill(0, 0, w, c.h, ' ', { bg: t.bg });
|
|
69
|
+
// header band
|
|
70
|
+
c.fill(0, 0, w, 1, ' ', { bg: t.headBg });
|
|
71
|
+
const label = ` ${LABEL_TEXT[v.label]} `;
|
|
72
|
+
const labelFg = v.label === 'live' ? t.up : t.warn;
|
|
73
|
+
c.text(1, 0, `dexterm export · ${v.title}`, { fg: t.text, bg: t.headBg, b: true }, Math.max(0, w - label.length - 3));
|
|
74
|
+
c.textR(0, 0, w - 1, label, { fg: labelFg, bg: t.headBg, b: true });
|
|
75
|
+
// the view
|
|
76
|
+
for (let y = 0; y < src.h; y++)
|
|
77
|
+
for (let x = 0; x < src.w; x++) {
|
|
78
|
+
const i = src.idx(x, y), j = c.idx(x, y + 1);
|
|
79
|
+
c.ch[j] = src.ch[i];
|
|
80
|
+
c.fg[j] = src.fg[i];
|
|
81
|
+
c.bg[j] = src.bg[i];
|
|
82
|
+
c.at[j] = src.at[i];
|
|
83
|
+
c.hard[j] = src.hard[i];
|
|
84
|
+
}
|
|
85
|
+
for (const s of src.images)
|
|
86
|
+
c.images.push({ ...s, y: s.y + 1 });
|
|
87
|
+
c.imageMode = src.imageMode;
|
|
88
|
+
// footer band
|
|
89
|
+
const fy = src.h + 1;
|
|
90
|
+
c.fill(0, fy, w, c.h - fy, ' ', { bg: t.headBg });
|
|
91
|
+
footer.forEach((line, k) => c.text(0, fy + 1 + k, line, { fg: k === footer.length - 1 ? t.muted : t.text, bg: t.headBg }, w));
|
|
92
|
+
c.text(0, fy, ` ${LABEL_TEXT[v.label]}`, { fg: labelFg, bg: t.headBg, b: true }, w);
|
|
93
|
+
return c;
|
|
94
|
+
}
|
|
95
|
+
/** The PNG bytes of a framed view. */
|
|
96
|
+
export function renderExportPng(v, now, opts = {}) {
|
|
97
|
+
const px = rasterizeCanvas(composeExportCanvas(v, now), opts);
|
|
98
|
+
return encodePng({ w: px.w, h: px.h, rgba: px.rgba });
|
|
99
|
+
}
|
|
100
|
+
/** The plain-text report: header, label, ages, the view's own lines, the local-only note. */
|
|
101
|
+
export function renderExportText(v, now) {
|
|
102
|
+
const L = [];
|
|
103
|
+
L.push(`dexterm export · ${v.title}`);
|
|
104
|
+
if (v.mint)
|
|
105
|
+
L.push(`contract ${v.mint}`);
|
|
106
|
+
L.push(`data ${LABEL_TEXT[v.label]}`);
|
|
107
|
+
L.push(`exported ${localStamp(now)} (local time) · ${new Date(now).toISOString()}`);
|
|
108
|
+
L.push(`data ages${v.label === 'replay' ? ' (on the replay clock)' : ''}`);
|
|
109
|
+
if (!v.ages.length)
|
|
110
|
+
L.push(' none reported for this view');
|
|
111
|
+
for (const a of v.ages)
|
|
112
|
+
L.push(` ${a.name.padEnd(12)} ${(a.ageSec !== null ? fmtAge(a.ageSec) : a.state || 'n/a').padEnd(14)} ${a.source ?? ''}`.trimEnd());
|
|
113
|
+
L.push('');
|
|
114
|
+
L.push(...v.report);
|
|
115
|
+
L.push('');
|
|
116
|
+
L.push('Exported locally by dexterm. Nothing was uploaded. Model outputs are labelled as such. Not financial advice.');
|
|
117
|
+
return L.join('\n') + '\n';
|
|
118
|
+
}
|
|
119
|
+
/** ~/.dexterm/exports (DEXTERM_HOME overrides). */
|
|
120
|
+
export function exportsDir(env = process.env) {
|
|
121
|
+
return join(dextermHome(env), 'exports');
|
|
122
|
+
}
|
|
123
|
+
const slug = (s) => s.replace(/[^A-Za-z0-9_-]+/g, '').slice(0, 16) || 'view';
|
|
124
|
+
/** Write `<dir>/dexterm-<kind>-<subject>-<stamp>.png` and `.txt` (dir 0700, files 0600). */
|
|
125
|
+
export function writeExport(v, opts = {}) {
|
|
126
|
+
const now = opts.now ?? Date.now();
|
|
127
|
+
const dir = opts.dir ?? exportsDir(opts.env);
|
|
128
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
129
|
+
const stamp = localStamp(now).replace(/[-: ]/g, '').replace(/^(\d{8})(\d{6})$/, '$1-$2');
|
|
130
|
+
const base = join(dir, `dexterm-${slug(v.kind)}-${slug(opts.subject ?? v.mint?.slice(0, 6) ?? '')}-${stamp}`);
|
|
131
|
+
const png = renderExportPng(v, now, opts.raster);
|
|
132
|
+
writeFileSync(`${base}.png`, png, { mode: 0o600 });
|
|
133
|
+
writeFileSync(`${base}.txt`, renderExportText(v, now), { mode: 0o600 });
|
|
134
|
+
return { png: `${base}.png`, txt: `${base}.txt`, bytes: png.length };
|
|
135
|
+
}
|
|
136
|
+
// ------------------------------------------------------------------------------------------ view builders
|
|
137
|
+
const PANEL_NAMES = { market: 'market', candles: 'candles', authorities: 'authorities', holders: 'holders', rugcheck: 'rugcheck', goplus: 'goplus', trades: 'trades' };
|
|
138
|
+
/** Data ages of a live token view, one per panel, from each panel's own fetch time. */
|
|
139
|
+
export function tokenAges(v, now) {
|
|
140
|
+
return Object.keys(PANEL_NAMES).map((p) => {
|
|
141
|
+
const s = v.panels[p];
|
|
142
|
+
const ok = s.fetchedAt !== null && (s.state === 'ok' || s.stale);
|
|
143
|
+
return {
|
|
144
|
+
name: PANEL_NAMES[p], source: s.via,
|
|
145
|
+
ageSec: ok ? (now - s.fetchedAt) / 1000 : null,
|
|
146
|
+
state: ok ? (s.stale ? 'stale' : null) : s.state === 'needs-key' ? 'needs key' : s.state === 'error' ? `unavailable${s.reason ? ` (${s.reason})` : ''}` : s.state,
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const yesNo = (b) => (b === null ? 'unknown' : b ? 'ENABLED' : 'revoked');
|
|
151
|
+
/** The text report body for a live token view: the numbers on screen, each from its panel. */
|
|
152
|
+
export function tokenReport(state, v, now) {
|
|
153
|
+
const L = [];
|
|
154
|
+
L.push(`coin $${cleanText(v.symbol, 16)}${v.name ? ` · ${cleanText(v.name, 40)}` : ''} · ${v.launchpad}`);
|
|
155
|
+
L.push(`price ${fmtPrice(v.price)} 5m ${fmtPct(v.chg.m5)} 1h ${fmtPct(v.chg.h1)} 6h ${fmtPct(v.chg.h6)} 24h ${fmtPct(v.chg.h24)}`);
|
|
156
|
+
L.push(`market cap ${fmtUsd(v.mcap)}${v.mcapFlagged ? ' (flagged)' : ''} liquidity ${fmtUsd(v.liq)} vol 24h ${fmtUsd(v.vol24)}`);
|
|
157
|
+
L.push(`holders ${v.holders ?? '—'} top-10 ${v.top10 === null ? '—' : `${v.top10.toFixed(1)}%`} dev ${v.dev === null ? '—' : `${v.dev.toFixed(1)}%`}${v.holdersSample ? ` (${v.holdersSample})` : ''}`);
|
|
158
|
+
L.push(`age ${v.ageMin === null ? '—' : fmtAge(v.ageMin * 60)}`);
|
|
159
|
+
L.push(`authorities mint ${yesNo(v.mintAuth)} · freeze ${yesNo(v.freezeAuth)}${v.riskyExtensions.length ? ` · risky extensions: ${v.riskyExtensions.join(', ')}` : ''}`);
|
|
160
|
+
if (v.rug)
|
|
161
|
+
L.push(`rugcheck score ${v.rug.score ?? '—'} · ${v.rug.risks.length} risk${v.rug.risks.length === 1 ? '' : 's'}${v.rug.risks.length ? `: ${v.rug.risks.slice(0, 4).map((r) => cleanText(r.name, 40)).join('; ')}` : ''}${v.rug.lpLockedPct !== null ? ` · LP locked ${v.rug.lpLockedPct.toFixed(0)}%` : ''}`);
|
|
162
|
+
if (v.flags.length)
|
|
163
|
+
L.push(`flags ${v.flags.join(', ')}`);
|
|
164
|
+
const row = state.screener.rows.find((r) => r.mint === v.mint);
|
|
165
|
+
const p = row ? rugP(row) : null;
|
|
166
|
+
if (p !== null)
|
|
167
|
+
L.push(`p(rug) ${fmtRugP(p)} · model output: ${RUG_MODEL_LABEL} (a ranking, not a probability; see dexterm backtest)`);
|
|
168
|
+
const larp = state.larpByMint?.get(v.mint);
|
|
169
|
+
if (larp)
|
|
170
|
+
L.push(`LARP check ${larp.verdict}${larp.confidence !== null ? ` · confidence ${larp.confidence.toFixed(2)}` : ''} · model output, ${fmtAge((now - larp.at) / 1000)} old · a risk flag, not a certificate`);
|
|
171
|
+
const links = [v.socialLinks.web, v.socialLinks.x, v.socialLinks.tg].filter((x) => !!x).map((u) => cleanText(u, 120));
|
|
172
|
+
if (links.length)
|
|
173
|
+
L.push(`links ${links.join(' ')} (advertised by the coin; unverified)`);
|
|
174
|
+
return L;
|
|
175
|
+
}
|
|
176
|
+
/** Plain text of every row of a canvas (the generic report body for screens without a dedicated builder). */
|
|
177
|
+
export function canvasText(c) {
|
|
178
|
+
const out = [];
|
|
179
|
+
for (let y = 0; y < c.h; y++)
|
|
180
|
+
out.push(c.ch.slice(y * c.w, (y + 1) * c.w).join('').trimEnd());
|
|
181
|
+
while (out.length && out[out.length - 1] === '')
|
|
182
|
+
out.pop();
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Render the current screen for export: CALM (no rain / glitch / scanlines), and without the toast, help card or
|
|
187
|
+
* GO-TO prompt on top. The state is restored afterwards, so the live loop is unaffected.
|
|
188
|
+
*/
|
|
189
|
+
export function renderCalmFrame(state, w, h) {
|
|
190
|
+
const v = state.vfx;
|
|
191
|
+
const saved = { intensity: v.intensity, toast: v.toast, overlay: state.overlay, goto: state.goto };
|
|
192
|
+
try {
|
|
193
|
+
v.intensity = 'off';
|
|
194
|
+
v.toast = null;
|
|
195
|
+
state.overlay = null;
|
|
196
|
+
state.goto = null;
|
|
197
|
+
return renderFrame(state, w, h);
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
v.intensity = saved.intensity;
|
|
201
|
+
v.toast = saved.toast;
|
|
202
|
+
state.overlay = saved.overlay;
|
|
203
|
+
state.goto = saved.goto;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** The data label of what is on screen: seeded fixtures are SAMPLE, a `--replay` file is REPLAY, else LIVE. */
|
|
207
|
+
export function dataLabelOf(state) {
|
|
208
|
+
return state.screener.source === 'fixtures' ? 'sample' : state.screener.source === 'replay' ? 'replay' : 'live';
|
|
209
|
+
}
|
|
210
|
+
/** Build the ExportView for whatever screen is showing (the token screen gets the full per-panel report). */
|
|
211
|
+
export function exportViewFor(state, w, h, now) {
|
|
212
|
+
const canvas = renderCalmFrame(state, w, h);
|
|
213
|
+
const label = dataLabelOf(state);
|
|
214
|
+
const tv = state.screen === 'token' && label !== 'sample' ? state.live?.token?.view ?? null : null;
|
|
215
|
+
if (tv) {
|
|
216
|
+
return {
|
|
217
|
+
kind: 'token', title: `TOKEN · $${cleanText(tv.symbol, 16)}${tv.name ? ` · ${cleanText(tv.name, 32)}` : ''}`, mint: tv.mint,
|
|
218
|
+
label, canvas, ages: tokenAges(tv, now), report: tokenReport(state, tv, now),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const sample = label === 'sample' ? [{ name: 'all panels', source: 'seeded fixtures', ageSec: null, state: 'sample data' }] : [];
|
|
222
|
+
if (state.screen === 'token') {
|
|
223
|
+
const tk = state.token || state.fx.tokens[state.selected || 0];
|
|
224
|
+
return { kind: 'token', title: `TOKEN · $${cleanText(tk?.symbol ?? '?', 16)}`, mint: tk?.mint ?? null, label, canvas, ages: sample, report: canvasText(canvas) };
|
|
225
|
+
}
|
|
226
|
+
const ages = [...sample];
|
|
227
|
+
if (label !== 'sample' && state.screener.rows.length) {
|
|
228
|
+
const ts = state.screener.rows.map((r) => r.fetchedAt).filter((t) => typeof t === 'number' && Number.isFinite(t));
|
|
229
|
+
if (ts.length) {
|
|
230
|
+
ages.push({ name: 'rows newest', source: 'screener feed', ageSec: (now - Math.max(...ts)) / 1000 });
|
|
231
|
+
ages.push({ name: 'rows oldest', source: 'screener feed', ageSec: (now - Math.min(...ts)) / 1000 });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { kind: state.screen, title: state.screen.toUpperCase(), mint: null, label, canvas, ages, report: canvasText(canvas) };
|
|
235
|
+
}
|
|
@@ -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
|
/**
|
|
@@ -78,12 +89,16 @@ export class LiveContext {
|
|
|
78
89
|
started = false;
|
|
79
90
|
fastLaneStarted = false;
|
|
80
91
|
closed = false;
|
|
92
|
+
/** task-1401: true once close() ran (an async coin-link resolution must not open a token after quit). */
|
|
93
|
+
get isClosed() { return this.closed; }
|
|
81
94
|
/** Latest SOL/USD, fed from the screener feed each refresh; the fast lane turns on-chain SOL into USD. */
|
|
82
95
|
solUsd = null;
|
|
83
96
|
lastSolFetch = 0;
|
|
84
97
|
onLog;
|
|
85
98
|
lastFastLogAt = 0;
|
|
86
99
|
lastFastLog = '';
|
|
100
|
+
/** task-1404: last time the open token's wallet-intel leg was re-scanned (slow cadence; see refreshOpenIntel). */
|
|
101
|
+
lastIntelRescanAt = 0;
|
|
87
102
|
constructor(dl, opts) {
|
|
88
103
|
this.dl = dl;
|
|
89
104
|
this.mode = opts.mode;
|
|
@@ -122,7 +137,7 @@ export class LiveContext {
|
|
|
122
137
|
endpoints: opts.fastEndpoints,
|
|
123
138
|
solUsd: () => this.solUsd,
|
|
124
139
|
ws: opts.ws,
|
|
125
|
-
onPrice: (_p) => emit('fastprice'),
|
|
140
|
+
onPrice: (_p) => { this.observeSignals(); emit('fastprice'); },
|
|
126
141
|
onStatus: () => { emit('fastlane:status'); this.logFast(); },
|
|
127
142
|
})
|
|
128
143
|
: null;
|
|
@@ -164,7 +179,10 @@ export class LiveContext {
|
|
|
164
179
|
? new LivePositions(this.trade.paths?.liveLedger ?? defaultLiveLedgerPath(), { now: this.now, mark: (m) => this.positions.markOf(m), solUsd: () => this.positions.solUsd() ?? this.solUsd })
|
|
165
180
|
: null;
|
|
166
181
|
this.positionSource = new CombinedPositions(this.positions, this.livePositions);
|
|
167
|
-
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'); } });
|
|
168
186
|
// task-1360: followed wallets are polled only in a real live run (replay never spends credits); the store is always readable.
|
|
169
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 });
|
|
170
188
|
}
|
|
@@ -374,7 +392,7 @@ export class LiveContext {
|
|
|
374
392
|
}
|
|
375
393
|
/** Open a token: a fresh loader, first load kicked off; the view is readable immediately (row fields). */
|
|
376
394
|
openToken(row) {
|
|
377
|
-
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'); } });
|
|
378
396
|
this.token = t;
|
|
379
397
|
this.syncFastSet(); // promote the opened token to the fast lane (slow→fast, no seam)
|
|
380
398
|
this.x?.setFocus({ mint: row.mint, symbol: row.symbol }); // task-1357: the open token's X search (60 s, cached 15 min)
|
|
@@ -421,6 +439,51 @@ export class LiveContext {
|
|
|
421
439
|
if (sigs.length)
|
|
422
440
|
this.social.ingest(sigs.map((s) => chainSignalToRaw(s, this.now())));
|
|
423
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
|
+
}
|
|
424
487
|
/** The token screen's `1`..`6` keys. */
|
|
425
488
|
setTimeframe(n) {
|
|
426
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;
|