dexterm-cli 0.1.5 → 0.1.6
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/index.js +2 -0
- package/dist/packages/agents/src/spectator.js +54 -0
- package/dist/packages/agents/src/story.js +523 -0
- package/dist/packages/agents/src/tiers.js +1 -0
- package/dist/packages/agents/src/tools.js +1 -0
- package/dist/packages/cli/assets/claude/plugin/skills/REGISTRY.json +1 -1
- package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +15 -8
- package/dist/packages/cli/src/agent-cmd.js +6 -4
- package/dist/packages/cli/src/agent-spectator.js +17 -1
- package/dist/packages/cli/src/analyst/backend.js +23 -0
- package/dist/packages/cli/src/analyst/larp-gauge.js +60 -0
- package/dist/packages/cli/src/analyst/session.js +12 -1
- package/dist/packages/cli/src/app-help-gen.js +7 -7
- package/dist/packages/cli/src/backtest-cmd.js +13 -2
- package/dist/packages/cli/src/display.js +55 -9
- package/dist/packages/cli/src/live/dev-history.js +31 -15
- package/dist/packages/cli/src/live/index.js +5 -1
- package/dist/packages/cli/src/live/screens/newpairs-live.js +1 -1
- package/dist/packages/cli/src/live/screens/token-live.js +41 -11
- package/dist/packages/cli/src/live/screens/watchlist-live.js +1 -1
- package/dist/packages/cli/src/live/trade.js +22 -0
- package/dist/packages/cli/src/main.js +146 -12
- package/dist/packages/cli/src/mcp-follow.js +103 -6
- package/dist/packages/cli/src/plan-cmd.js +15 -5
- package/dist/packages/cli/src/popup.js +17 -1
- package/dist/packages/cli/src/scan/ui.js +7 -1
- package/dist/packages/cli/src/screens/agent-hud.js +741 -0
- package/dist/packages/cli/src/screens/analyst-session.js +4 -0
- package/dist/packages/cli/src/screens/brain.js +608 -0
- package/dist/packages/cli/src/screens/newpairs.js +1 -1
- package/dist/packages/cli/src/screens/pnl-chip.js +143 -0
- package/dist/packages/cli/src/screens/screener.js +1 -0
- package/dist/packages/cli/src/screens/token.js +16 -7
- package/dist/packages/cli/src/screens/trending.js +1 -1
- package/dist/packages/cli/src/state.js +69 -5
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/custom/src/schema.js +8 -2
- package/dist/packages/data/src/index.js +2 -0
- package/dist/packages/data/src/larp/score.js +220 -0
- package/dist/packages/data/src/larp/x-account.js +316 -0
- package/dist/packages/data/src/trade/agent/paper-desk.js +77 -12
- package/dist/packages/data/src/trade/plans/engine.js +7 -0
- package/dist/packages/data/src/trade/plans/mandate.js +28 -15
- package/dist/packages/data/src/trade/plans/service.js +1 -1
- package/dist/packages/mcp/assets/kb/app-help/dev-history.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/larp-check.md +5 -1
- package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/screen-token.md +3 -0
- package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +2 -1
- package/dist/packages/mcp/assets/kb/docs/docs-agents-agent-paper-trading.md +3 -2
- package/dist/packages/mcp/assets/kb/docs/docs-analyst-starting-a-run.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-chart-y-axis.md +32 -0
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-price-display-mode.md +3 -3
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-running-the-tests.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +60 -0
- package/dist/packages/mcp/assets/kb/docs/docs-larp-the-verdict.md +0 -2
- package/dist/packages/mcp/assets/kb/docs/docs-larp-what-it-looks-at.md +4 -1
- package/dist/packages/mcp/assets/kb/docs/docs-larp.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-order-plans-and-standing-mandates.md +1 -9
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-paper-only-mandates.md +41 -0
- package/dist/packages/mcp/assets/kb/docs/docs-popup-chart-market-cap-axis.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-popup-chart-what-it-shows.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-scan-the-read.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-screens-dev-history.md +10 -7
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-follow-over-mcp-in-the-running-terminal.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-limits.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-agent-hud-spectator-v2.md +58 -0
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-brain-view-and-the-pnl-chip.md +46 -0
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-three-surfaces.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-spectator.md +1 -1
- package/dist/packages/mcp/src/paper-agent.js +3 -2
- package/dist/packages/mcp/src/server.js +162 -21
- package/dist/packages/mcp/src/spectator-sink.js +85 -0
- package/dist/packages/packs/src/index.js +1 -0
- package/dist/packages/packs/src/larp-score-backtest.js +119 -0
- package/dist/packages/popup/src/pages/chart-lib.js +36 -15
- package/dist/packages/popup/src/pages/chart.css +9 -0
- package/dist/packages/popup/src/pages/chart.html +4 -1
- package/dist/packages/popup/src/pages/chart.js +34 -5
- package/dist/packages/popup/src/pages/spectator.css +45 -0
- package/dist/packages/popup/src/pages/spectator.html +1 -0
- package/dist/packages/popup/src/pages/spectator.js +69 -0
- package/dist/packages/popup/src/protocol.js +2 -0
- package/dist/packages/popup/src/server.js +5 -1
- package/dist/packages/recorder/src/dev-history.js +7 -3
- package/dist/packages/tui/src/canvas.js +7 -0
- package/dist/packages/tui/src/fmt.js +53 -0
- package/dist/packages/tui/src/keymap.js +4 -0
- package/dist/packages/tui/src/widgets.js +9 -4
- package/package.json +1 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// dexterm — the persistent POSITION PnL chip (task-1473). While an auto mode runs and a position is open, one compact
|
|
2
|
+
// readout is on every terminal screen and on the chart popup: coin, mark PnL % with sign (and $ when the size is
|
|
3
|
+
// known), entry → mark, peak, drawdown from the peak, time held and the take-profit. Sources (state.ts pnlChipFor):
|
|
4
|
+
// · following an agent (task-1466 HUD) that holds a PAPER ticket — the folded story, TP = what the agent said;
|
|
5
|
+
// · AI scan mode with the user's OWN paper or live position (OwnPnlTracker; session peak, TP = the ledger target).
|
|
6
|
+
// Where it draws: the HUD dock's signal row when the dock is up (≥ HUD_MIN_H rows while following), else the band
|
|
7
|
+
// under the header (row 1, right-aligned: short terminals, scan mode), and the chart popup's chip strip (ChartVM.chip).
|
|
8
|
+
// It updates on every mark tick, flashes on a new peak (a ★ glyph says the same under calm mode / NO_COLOR) and drops
|
|
9
|
+
// fields from the right as the width shrinks. Display only: it has no input and reads only folded / ledger state.
|
|
10
|
+
import { mix } from '../../../tui/src/index.js';
|
|
11
|
+
import { fmtPx } from '../../../agents/src/index.js';
|
|
12
|
+
import { planFromThoughts, fmtDur } from "./brain.js";
|
|
13
|
+
export const PEAK_FLASH_MS = 1200;
|
|
14
|
+
/** The chip for the followed agent's open PAPER position, or null when nothing is held. */
|
|
15
|
+
export function pnlChipVM(v, now) {
|
|
16
|
+
const a = v.arc;
|
|
17
|
+
if (!a || a.stage !== 'open')
|
|
18
|
+
return null;
|
|
19
|
+
const dd = a.pnlPct !== null && a.peakPct !== null ? (1 + a.pnlPct) / (1 + a.peakPct) - 1 : null;
|
|
20
|
+
return {
|
|
21
|
+
kind: 'PAPER',
|
|
22
|
+
symbol: a.symbol,
|
|
23
|
+
pnlPct: a.pnlPct,
|
|
24
|
+
pnlUsd: a.pnlPct !== null && a.sizeUsd !== null ? a.sizeUsd * a.pnlPct : null,
|
|
25
|
+
entry: a.entryUsd !== null ? `$${fmtPx(a.entryUsd)}` : '—',
|
|
26
|
+
mark: a.mark ? `$${fmtPx(a.mark.priceUsd)}` : '—',
|
|
27
|
+
peakPct: a.peakPct,
|
|
28
|
+
ddPct: dd,
|
|
29
|
+
heldMs: a.filledAt !== null ? now - a.filledAt : null,
|
|
30
|
+
tp: planFromThoughts(v.thoughts, a.openedAt).tp,
|
|
31
|
+
newPeak: a.peakAt !== null && now - a.peakAt < PEAK_FLASH_MS && a.peakPct !== null && a.peakPct > 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The user's own position chip in AI scan mode. The ledgers keep no peak, so the peak is this session's highest
|
|
36
|
+
* mark (reset when the held coin or ledger changes, or the position goes flat). Display only.
|
|
37
|
+
*/
|
|
38
|
+
export class OwnPnlTracker {
|
|
39
|
+
cur = null;
|
|
40
|
+
update(s, now) {
|
|
41
|
+
if (!s) {
|
|
42
|
+
this.cur = null;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const seed = !this.cur || this.cur.s.mint !== s.mint || this.cur.s.kind !== s.kind;
|
|
46
|
+
if (seed)
|
|
47
|
+
this.cur = { s, peakPct: s.pnlPct, peakAt: null }; // the first mark is not a "new peak" flash
|
|
48
|
+
const c = this.cur;
|
|
49
|
+
c.s = s;
|
|
50
|
+
if (!seed && s.pnlPct !== null && (c.peakPct === null || s.pnlPct > c.peakPct + 1e-9)) {
|
|
51
|
+
c.peakPct = s.pnlPct;
|
|
52
|
+
c.peakAt = now;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
get mint() { return this.cur?.s.mint ?? null; }
|
|
56
|
+
vm(now) {
|
|
57
|
+
const c = this.cur;
|
|
58
|
+
if (!c)
|
|
59
|
+
return null;
|
|
60
|
+
const s = c.s;
|
|
61
|
+
const dd = s.pnlPct !== null && c.peakPct !== null ? (1 + s.pnlPct) / (1 + c.peakPct) - 1 : null;
|
|
62
|
+
return {
|
|
63
|
+
kind: s.kind === 'live' ? 'LIVE' : 'PAPER',
|
|
64
|
+
symbol: s.symbol,
|
|
65
|
+
pnlPct: s.pnlPct,
|
|
66
|
+
pnlUsd: s.pnlUsd,
|
|
67
|
+
entry: s.entryUsd !== null ? `$${fmtPx(s.entryUsd)}` : '—',
|
|
68
|
+
mark: s.markUsd !== null ? `$${fmtPx(s.markUsd)}` : '—',
|
|
69
|
+
peakPct: c.peakPct,
|
|
70
|
+
ddPct: dd,
|
|
71
|
+
heldMs: s.openedAt !== null ? Math.max(0, now - s.openedAt) : null,
|
|
72
|
+
tp: s.targetUsd !== null ? `$${fmtPx(s.targetUsd)}` : null,
|
|
73
|
+
tpNone: 'not set',
|
|
74
|
+
newPeak: c.peakAt !== null && now - c.peakAt < PEAK_FLASH_MS && c.peakPct !== null && c.peakPct > 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const pct = (r) => `${r >= 0 ? '+' : '−'}${Math.abs(r * 100).toFixed(1)}%`;
|
|
79
|
+
const usd = (n) => `${n >= 0 ? '+' : '−'}$${Math.abs(n).toFixed(2)}`;
|
|
80
|
+
/** The chip's segments in priority order (the first ones survive the narrowest widths). */
|
|
81
|
+
export function chipSegments(m) {
|
|
82
|
+
const segs = [];
|
|
83
|
+
const dir = m.pnlPct === null ? '·' : m.pnlPct >= 0 ? '▲' : '▼';
|
|
84
|
+
segs.push({ id: 'head', text: `${m.kind} $${m.symbol}` });
|
|
85
|
+
segs.push({ id: 'pnl', text: `${dir} ${m.pnlPct === null ? '—' : pct(m.pnlPct)}${m.pnlUsd !== null ? ` ${usd(m.pnlUsd)}` : ''}` });
|
|
86
|
+
if (m.peakPct !== null)
|
|
87
|
+
segs.push({ id: 'peak', text: `peak ${pct(m.peakPct)}${m.newPeak ? ' ★' : ''}` });
|
|
88
|
+
segs.push({ id: 'px', text: `${m.entry} → ${m.mark}` });
|
|
89
|
+
if (m.heldMs !== null)
|
|
90
|
+
segs.push({ id: 'held', text: `held ${fmtDur(m.heldMs)}` });
|
|
91
|
+
if (m.ddPct !== null && m.ddPct < -0.0005)
|
|
92
|
+
segs.push({ id: 'dd', text: `dd ${pct(m.ddPct)}` });
|
|
93
|
+
segs.push({ id: 'tp', text: m.tp ? `TP ${m.tp}` : `TP ${m.tpNone ?? 'agent-managed'}` });
|
|
94
|
+
return segs;
|
|
95
|
+
}
|
|
96
|
+
const SEP = ' │ ';
|
|
97
|
+
/** The chip text that fits in `maxW` (segments drop from the end). Pure, for tests. */
|
|
98
|
+
export function chipText(m, maxW) {
|
|
99
|
+
const segs = chipSegments(m);
|
|
100
|
+
let n = segs.length;
|
|
101
|
+
const join = (k) => ` ${segs.slice(0, k).map((s) => s.text).join(SEP)} `;
|
|
102
|
+
while (n > 1 && join(n).length > maxW)
|
|
103
|
+
n--;
|
|
104
|
+
const s = join(n);
|
|
105
|
+
return s.length <= maxW ? s : '';
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Draw the chip right-aligned to `right` (exclusive) on row `y`, no wider than `maxW`. Returns the x it starts at
|
|
109
|
+
* (or `right` when nothing fits).
|
|
110
|
+
*/
|
|
111
|
+
export function renderPnlChip(c, right, y, maxW, m, o) {
|
|
112
|
+
const t = c.theme;
|
|
113
|
+
const text = chipText(m, maxW);
|
|
114
|
+
if (!text)
|
|
115
|
+
return right;
|
|
116
|
+
const x0 = right - text.length;
|
|
117
|
+
const bg = mix(t.bg, t.magenta, 0.22);
|
|
118
|
+
const col = m.pnlPct === null ? t.muted : m.pnlPct >= 0 ? t.up : t.down;
|
|
119
|
+
c.text(x0, y, text, { fg: t.text, bg });
|
|
120
|
+
// re-colour the segments: head magenta, PnL red/green bold (flash on a new peak), peak green, dd warn
|
|
121
|
+
let x = x0 + 1;
|
|
122
|
+
for (const s of chipSegments(m)) {
|
|
123
|
+
if (x + s.text.length > right - 1)
|
|
124
|
+
break;
|
|
125
|
+
if (s.id === 'head')
|
|
126
|
+
c.text(x, y, s.text, { fg: t.magenta, bg, b: true });
|
|
127
|
+
else if (s.id === 'pnl') {
|
|
128
|
+
const flash = m.newPeak && !o.reduced;
|
|
129
|
+
c.text(x, y, s.text, flash ? { fg: t.bg, bg: t.up, b: true } : { fg: col, bg, b: true });
|
|
130
|
+
}
|
|
131
|
+
else if (s.id === 'peak')
|
|
132
|
+
c.text(x, y, s.text, { fg: m.peakPct !== null && m.peakPct >= 0 ? t.up : t.down, bg, b: m.newPeak });
|
|
133
|
+
else if (s.id === 'dd')
|
|
134
|
+
c.text(x, y, s.text, { fg: t.warn, bg });
|
|
135
|
+
else
|
|
136
|
+
c.text(x, y, s.text, { fg: t.muted, bg });
|
|
137
|
+
x += s.text.length;
|
|
138
|
+
if (x + SEP.length < right)
|
|
139
|
+
c.text(x, y, SEP, { fg: mix(bg, t.border, 0.8), bg });
|
|
140
|
+
x += SEP.length;
|
|
141
|
+
}
|
|
142
|
+
return x0;
|
|
143
|
+
}
|
|
@@ -195,6 +195,7 @@ export function renderScreener(c, state) {
|
|
|
195
195
|
: null;
|
|
196
196
|
table(c, box.x + 1, box.y, box.w - 2, box.h, {
|
|
197
197
|
columns: screenerColumns(c, bp, sc.gates, state.larpByMint ?? null, icon, mode), rows, selected: sel, scroll: Math.max(0, scroll), sortKey: sc.sort.key, sortDir: sc.sort.dir, bg: box.bg,
|
|
198
|
+
rowKey: (r) => r.mint, // task-1466: the agent HUD finds the rows it scanned
|
|
198
199
|
});
|
|
199
200
|
if (rows.length === 0)
|
|
200
201
|
emptyState(c, box, sc);
|
|
@@ -8,8 +8,9 @@ import { renderTokenLive } from "../live/screens/token-live.js";
|
|
|
8
8
|
import { aiTranscript } from "../fixtures.js";
|
|
9
9
|
import { renderAnalystSession } from "./analyst-session.js";
|
|
10
10
|
import { renderSocialPanel } from "./social-panel.js";
|
|
11
|
+
import { brainFor, factsFromFixture, renderBrainPanel } from "./brain.js";
|
|
11
12
|
import { layoutFor } from "../custom.js";
|
|
12
|
-
import { chartMode, priceModeOf } from "../display.js";
|
|
13
|
+
import { chartMode, chartTitle, chartAxisOf, priceModeOf } from "../display.js";
|
|
13
14
|
export function renderToken(c, state) {
|
|
14
15
|
if (state.live?.token && state.screener.source !== 'fixtures')
|
|
15
16
|
return renderTokenLive(c, state, state.live.token.view); // task-1298: live data path
|
|
@@ -48,13 +49,13 @@ export function renderToken(c, state) {
|
|
|
48
49
|
// price line: pump glow = gold bg band, dump = red band (the rain ramp of the moment)
|
|
49
50
|
const priceBg = pumping ? mix(t.bg, t.rainPump[2], 0.3) : dumping ? mix(t.bg, t.rainDump[2], 0.3) : t.bg;
|
|
50
51
|
// task-1366: mcap mode leads with the market cap and keeps the price beside it (fixture tokens always carry a supply)
|
|
51
|
-
const
|
|
52
|
-
if (
|
|
52
|
+
const pm = chartMode(priceModeOf(state), tk.price, tk.mcap);
|
|
53
|
+
if (pm.kind === 'mcap') {
|
|
53
54
|
x = c.text(1, y, fmtMcap(tk.mcap), { fg: pumping ? t.rainPump[3] : t.text, bg: priceBg, b: true });
|
|
54
55
|
x = c.text(x, y, ` (${fmtPrice(tk.price)})`, { fg: t.dim, bg: priceBg });
|
|
55
56
|
}
|
|
56
57
|
else
|
|
57
|
-
x = c.text(1, y, fmtPrice(tk.price), { fg:
|
|
58
|
+
x = c.text(1, y, fmtPrice(tk.price), { fg: pm.label ? t.warn : pumping ? t.rainPump[3] : t.text, bg: priceBg, b: true });
|
|
58
59
|
const deltas = [['5m', tk.chg.m5], ['1h', tk.chg.h1], ['6h', tk.chg.h6], ['24h', tk.chg.h24]];
|
|
59
60
|
x = c.text(x, y, ' ', { bg: t.bg });
|
|
60
61
|
for (const [k, vv] of deltas) {
|
|
@@ -70,7 +71,9 @@ export function renderToken(c, state) {
|
|
|
70
71
|
y += 1;
|
|
71
72
|
// ---- chart
|
|
72
73
|
const chartH = Math.max(10, Math.floor((bottom - y) * lay.chartFrac));
|
|
73
|
-
|
|
74
|
+
// task-1472: the chart Y axis is its own setting (default MARKET CAP, `A` toggles); the header above keeps `$`
|
|
75
|
+
const cm = chartMode(chartAxisOf(state), tk.price, tk.mcap);
|
|
76
|
+
const cb = c.box(0, y, mainW, chartH, { title: chartTitle(tk.symbol, '1h', cm, 'SOL'), caption: 'ohlcv: geckoterminal' });
|
|
74
77
|
const tfx = cb.x + cb.w - 2;
|
|
75
78
|
const tfs = ['1m', '5m', '15m', '1h', '4h', '1d'];
|
|
76
79
|
let tx = tfx - tfs.reduce((s, f) => s + f.length + 1, 0) - 24;
|
|
@@ -171,8 +174,14 @@ export function renderToken(c, state) {
|
|
|
171
174
|
}
|
|
172
175
|
}
|
|
173
176
|
}
|
|
174
|
-
if (showAi)
|
|
175
|
-
|
|
177
|
+
if (showAi) {
|
|
178
|
+
// task-1473: in an auto mode (following an agent / AI scan) the panel is the BRAIN VIEW; otherwise the analyst
|
|
179
|
+
const brain = brainFor(state, factsFromFixture(tk, state.larpByMint?.get(tk.mint)?.verdict ?? null), isCalm(v), state.live?.scanMeterLabel?.() ?? '');
|
|
180
|
+
if (brain)
|
|
181
|
+
renderBrainPanel(c, mainW, top, aiW, bottom - top, brain.model, brain.opts);
|
|
182
|
+
else
|
|
183
|
+
analystPanel(c, mainW, top, aiW, bottom - top, tk, state);
|
|
184
|
+
}
|
|
176
185
|
footer(c, state.tokenSocial
|
|
177
186
|
? [['esc', 'back'], ['s', 'on-chain'], ['f', 'filter'], ['x', state.socialShowSpam ? 'hide spam' : 'show hidden'], ['K', 'KOL lists'], ['a', showAi ? 'hide analyst' : 'analyst'], ['w', 'watch'], ['q', 'quit']]
|
|
178
187
|
: [['esc', 'back'], ['g', 'go to'], ['a', showAi ? 'hide analyst' : 'analyst'], ['h/t/r', 'holders/trades/traders'], ['w', 'watch'], ['b', 'alert'], ['1-6', 'timeframe'], ['q', 'quit']], state.theme.name, isCalm(v));
|
|
@@ -70,7 +70,7 @@ export function renderTrending(c, state) {
|
|
|
70
70
|
if (rows.length === 0)
|
|
71
71
|
c.textC(box.x, box.y + 2, box.w, all.length ? 'no rows match the filter — f cycles filters, n clears the narrative' : 'nothing trending yet — the keyless venues refresh every 60 s', { fg: t.muted, bg, i: true });
|
|
72
72
|
else
|
|
73
|
-
table(c, box.x + 1, box.y, box.w - 2, box.h, { columns: cols, rows, selected: sel, scroll, bg });
|
|
73
|
+
table(c, box.x + 1, box.y, box.w - 2, box.h, { columns: cols, rows, selected: sel, scroll, bg, rowKey: (r) => r.mint });
|
|
74
74
|
// sub-panels
|
|
75
75
|
const sy = top + boardH;
|
|
76
76
|
const news = feed.newsItems(12);
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// dexterm — app state consumed by the screens. Screens are pure render(canvas, state) functions;
|
|
2
2
|
// renderFrame() draws the screen, the toast, then runs the effects post-pass (VISUAL-V2 §3).
|
|
3
|
-
import { Canvas, THEMES, applyFx, toast, makeVfx, setSources, marketActivity, candleActivity, isCalm, } from '../../tui/src/index.js';
|
|
3
|
+
import { Canvas, THEMES, applyFx, toast, makeVfx, setSources, marketActivity, candleActivity, isCalm, breakpoint, } from '../../tui/src/index.js';
|
|
4
4
|
import { makeFixtures } from "./fixtures.js";
|
|
5
5
|
import { fromFixture } from "./screener/rows.js";
|
|
6
6
|
import { makeScreenerState, screenerView } from "./screener/view.js";
|
|
7
7
|
import { renderScreener } from "./screens/screener.js";
|
|
8
8
|
import { renderToken } from "./screens/token.js";
|
|
9
|
+
import { agentBrainActive } from "./screens/brain.js";
|
|
10
|
+
import { pnlChipVM, renderPnlChip } from "./screens/pnl-chip.js";
|
|
9
11
|
import { renderNewPairs } from "./screens/newpairs.js";
|
|
10
12
|
import { renderWatchlist } from "./screens/watchlist.js";
|
|
11
13
|
import { renderAnalyst } from "./screens/analyst.js";
|
|
@@ -28,7 +30,9 @@ import { openTicket } from "./trade.js";
|
|
|
28
30
|
import { computeFill } from '../../data/src/index.js';
|
|
29
31
|
import { renderScanTicker, scanIndicator } from "./scan/ui.js";
|
|
30
32
|
import { renderVoiceHud } from "./voice-hud.js";
|
|
33
|
+
import { composeHud, DOCK_ROWS, HUD_MIN_H } from "./screens/agent-hud.js";
|
|
31
34
|
import { workspaceBadge } from "./custom.js";
|
|
35
|
+
import { chartAxisOf, supplyRatio } from "./display.js";
|
|
32
36
|
/** Map key for the Analyst-tab's own conversation (not tied to a single token). */
|
|
33
37
|
export const ANALYST_TAB_KEY = '__analyst_tab__';
|
|
34
38
|
/**
|
|
@@ -426,8 +430,54 @@ export function refreshVfx(state) {
|
|
|
426
430
|
state.vfx.activity = act.activity;
|
|
427
431
|
state.vfx.mood = act.mood;
|
|
428
432
|
}
|
|
429
|
-
/**
|
|
433
|
+
/**
|
|
434
|
+
* Render one frame of the current screen into a fresh canvas: screen → toast → effects. task-1466: while the
|
|
435
|
+
* terminal follows an agent, the screen renders DOCK_ROWS shorter and the agent HUD's dock takes the bottom rows
|
|
436
|
+
* (so no data is hidden), with its overlays (row glow, cursor, cards, flashes, the kill cut) over the screen.
|
|
437
|
+
*/
|
|
430
438
|
export function renderFrame(state, w, h) {
|
|
439
|
+
const hud = state.agentHud;
|
|
440
|
+
if (!hud || state.screen === 'boot' || h < HUD_MIN_H)
|
|
441
|
+
return renderScreenFrame(state, w, h, true);
|
|
442
|
+
const now = hud.clock();
|
|
443
|
+
const screen = renderScreenFrame(state, w, h - DOCK_ROWS, false);
|
|
444
|
+
const view = hud.story.view(now);
|
|
445
|
+
// task-1473: on the token screen the analyst panel is the agent's brain view (same rule the screen used)
|
|
446
|
+
const brain = state.screen === 'token' && state.aiOpen !== false && breakpoint(w) !== 'compact' && !state.aiFocused && agentBrainActive(view, now);
|
|
447
|
+
return composeHud(new Canvas(w, h, state.theme), screen, view, {
|
|
448
|
+
now, reduced: !!hud.reduced || isCalm(state.vfx), depth: state.colorDepth, dataLabel: hud.dataLabel ?? null, screen: state.screen, brain,
|
|
449
|
+
chip: pnlChipFor(state, now, view),
|
|
450
|
+
mcapScale: chartAxisOf(state) === 'mcap' ? (mint) => hudMcapScale(state, mint) : undefined, // task-1472
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
/** task-1472: the supply ratio for the HUD's position card: the open token's (live, else fixture), else its screener row. */
|
|
454
|
+
function hudMcapScale(state, mint) {
|
|
455
|
+
const v = state.live?.token?.view;
|
|
456
|
+
if (v && v.mint === mint)
|
|
457
|
+
return supplyRatio(v.price, v.mcap);
|
|
458
|
+
const tk = state.token;
|
|
459
|
+
if (tk && tk.mint === mint)
|
|
460
|
+
return supplyRatio(tk.price, tk.mcap);
|
|
461
|
+
const row = state.screener.rows.find((r) => r.mint === mint); // the card also shows over the lists (task-1473)
|
|
462
|
+
return row ? supplyRatio(row.price, row.mcap) : null;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* task-1473: the POSITION PnL chip for the app's current auto mode, or null. Following an agent: its open PAPER
|
|
466
|
+
* ticket (the folded story). Else, while AI scan mode runs: the user's own held paper / live position (ownPnl).
|
|
467
|
+
* The same chip feeds the HUD dock, the band row (no dock) and the chart popup. Display only.
|
|
468
|
+
*/
|
|
469
|
+
export function pnlChipFor(state, now, view) {
|
|
470
|
+
const hud = state.agentHud;
|
|
471
|
+
if (hud) {
|
|
472
|
+
const m = pnlChipVM(view ?? hud.story.view(now), now);
|
|
473
|
+
if (m)
|
|
474
|
+
return m;
|
|
475
|
+
}
|
|
476
|
+
if (state.scan?.engine.on && state.ownPnl)
|
|
477
|
+
return state.ownPnl.vm(now);
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
function renderScreenFrame(state, w, h, bandChip = false) {
|
|
431
481
|
const c = new Canvas(w, h, state.theme);
|
|
432
482
|
c.fill(0, 0, w, h, ' ', { bg: state.theme.bg });
|
|
433
483
|
c.imageMode = state.images && state.imageProtocol && state.imageProtocol !== 'blocks' ? 'pixels' : 'cells';
|
|
@@ -445,11 +495,25 @@ export function renderFrame(state, w, h) {
|
|
|
445
495
|
renderVoiceHud(c, state.voice, state.theme);
|
|
446
496
|
// task-1358: the workspace badge (non-default name, a pending ctrl+w chord, the save-as input) on the band
|
|
447
497
|
// under the header, right-aligned, drawn after the effects so it is always legible. Nothing on `default`.
|
|
498
|
+
// task-1473 r2: no HUD dock (short terminal, or AI scan mode): the PnL chip rides the band, right-aligned,
|
|
499
|
+
// and the workspace badge moves to its left.
|
|
500
|
+
let bandRight = w - 1;
|
|
501
|
+
if (bandChip && state.screen !== 'boot') {
|
|
502
|
+
const now = state.agentHud?.clock() ?? state.now.getTime();
|
|
503
|
+
const chip = pnlChipFor(state, now);
|
|
504
|
+
if (chip) {
|
|
505
|
+
const x0 = renderPnlChip(c, w - 1, 1, Math.max(36, Math.floor(w * 0.7)), chip, { now, reduced: !!state.agentHud?.reduced || isCalm(state.vfx) });
|
|
506
|
+
if (x0 < w - 1) {
|
|
507
|
+
c.noRain(x0, 1, w - 1 - x0, 1);
|
|
508
|
+
bandRight = x0 - 1;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
448
512
|
const badge = state.screen !== 'boot' ? workspaceBadge(state.custom, state.now.getTime()) : null;
|
|
449
|
-
if (badge) {
|
|
513
|
+
if (badge && bandRight > 4) {
|
|
450
514
|
const t = state.theme;
|
|
451
|
-
const text = badge.text.slice(0, Math.max(0,
|
|
452
|
-
const x = Math.max(0,
|
|
515
|
+
const text = badge.text.slice(0, Math.max(0, bandRight - 1));
|
|
516
|
+
const x = Math.max(0, bandRight - text.length);
|
|
453
517
|
c.noRain(x, 1, text.length, 1);
|
|
454
518
|
c.text(x, 1, text, badge.kind === 'input' ? { fg: t.text, bg: t.accentSoft, b: true } : badge.kind === 'chord' ? { fg: t.warn, bg: t.warnSoft, b: true } : { fg: t.accent, bg: t.headBg, b: true });
|
|
455
519
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// The one place the app's version lives (task-1397). --version, the help banner and `dexterm update` read it;
|
|
2
2
|
// cli.test.ts keeps it equal to packages/cli/package.json (release.yml refuses a tag that differs).
|
|
3
|
-
export const VERSION = '0.1.
|
|
3
|
+
export const VERSION = '0.1.6';
|
|
@@ -140,6 +140,12 @@ export const LAYOUT_SCREENS = ['screener', 'token', 'newpairs', 'watchlist', 'an
|
|
|
140
140
|
*/
|
|
141
141
|
export const PRICE_MODES = ['price', 'mcap'];
|
|
142
142
|
export const DEFAULT_PRICE_MODE = 'price';
|
|
143
|
+
/**
|
|
144
|
+
* task-1472: what the CHART Y axis shows (the terminal token chart and the chart popup), separate from the table/header
|
|
145
|
+
* price mode: `mcap` (default; absent = mcap, so existing configs get it too) or `price`. `A` / the popup control toggle it.
|
|
146
|
+
*/
|
|
147
|
+
export const CHART_AXES = ['mcap', 'price'];
|
|
148
|
+
export const DEFAULT_CHART_AXIS = 'mcap';
|
|
143
149
|
const tokenProps = () => Object.fromEntries([...THEME_TOKENS.map((t) => [t, 'hex']), ...THEME_RAMPS.map((t) => [t, 'ramp'])]);
|
|
144
150
|
export const SCHEMAS = {
|
|
145
151
|
hex: { type: 'string', pattern: HEX },
|
|
@@ -236,8 +242,8 @@ export const SCHEMAS = {
|
|
|
236
242
|
user: { type: 'object', values: 'userTheme' },
|
|
237
243
|
} },
|
|
238
244
|
fx: { type: 'object', additional: false, props: { intensity: { type: 'string', enum: ['off', 'normal', 'fever'] }, sound: { type: 'boolean' } } },
|
|
239
|
-
/** task-1366: display options. `priceMode` price | mcap (the `$` key toggles and persists it). */
|
|
240
|
-
display: { type: 'object', additional: false, props: { priceMode: { type: 'string', enum: PRICE_MODES } } },
|
|
245
|
+
/** task-1366: display options. `priceMode` price | mcap (the `$` key toggles and persists it). task-1472: `chartAxis` mcap | price (`A`; absent = mcap). */
|
|
246
|
+
display: { type: 'object', additional: false, props: { priceMode: { type: 'string', enum: PRICE_MODES }, chartAxis: { type: 'string', enum: CHART_AXES } } },
|
|
241
247
|
layout: { type: 'object', additional: false, props: {
|
|
242
248
|
workspace: { type: 'string', pattern: WS_NAME },
|
|
243
249
|
workspaces: { type: 'object', values: 'workspace' },
|
|
@@ -53,6 +53,8 @@ export * from "./larp/links.js";
|
|
|
53
53
|
export * from "./larp/safe-fetch.js";
|
|
54
54
|
export * from "./larp/evidence.js";
|
|
55
55
|
export * from "./larp/link-baseline.js";
|
|
56
|
+
export * from "./larp/x-account.js";
|
|
57
|
+
export * from "./larp/score.js";
|
|
56
58
|
// task-1401: parse a CA / coin link from any input (go-to, analyst chat, filter line, --mint, MCP refs).
|
|
57
59
|
export * from "./coin-target.js";
|
|
58
60
|
export * from "./analyst-actions.js";
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// LARP score (task-1471): a 0-100 summary of the LARP check's flags. Pure, offline, deterministic.
|
|
2
|
+
//
|
|
3
|
+
// What it is: the transparent SUM of named flags, each with its points and the evidence that raised it. Higher
|
|
4
|
+
// means more of the things that go with LARP coins showed up. It is evidence, not a verdict: every flag can be
|
|
5
|
+
// innocent on its own, and the score never says a coin is fake, safe or worth anything.
|
|
6
|
+
//
|
|
7
|
+
// Missing data never lowers the score and is never scored as clean. A flag whose evidence could not be read (page
|
|
8
|
+
// blocked, RDAP down, the X sources unavailable) goes to `missing` with the points it COULD have added, and the
|
|
9
|
+
// range `[score, score + those points]` (and its band range) widens instead.
|
|
10
|
+
//
|
|
11
|
+
// Where the points come from (LARP_SCORE_WEIGHTS[*].source):
|
|
12
|
+
// - `calibrated`: the link-shape flags, fitted on the TRAIN split of the task-1401 eval set (1054 coins, 72 h
|
|
13
|
+
// outcome labels; packages/packs/src/larp-score-backtest.ts). points = clamp(round(8 × ln odds ratio of a bad
|
|
14
|
+
// outcome), 0, 12), using the flag only when ≥ 20 train coins carry it. The cap keeps link shape alone in the
|
|
15
|
+
// `medium` band at most: the larp-check skill never calls a coin LARP from links alone.
|
|
16
|
+
// - `prior`: page, domain, GitHub and X-account flags. The eval set does not hold that evidence point-in-time (the
|
|
17
|
+
// pages are gone or changed, and a deleted X account after a coin dies would leak the outcome), so these are
|
|
18
|
+
// pre-registered weights written from the skill text before any data, and are labelled as such everywhere.
|
|
19
|
+
import { linkSignals } from "./link-baseline.js";
|
|
20
|
+
export const LARP_SCORE_MODEL = 'larp-score-v1';
|
|
21
|
+
/**
|
|
22
|
+
* The shipped weights. The `calibrated` rows are asserted equal to a fresh fit on the committed eval set
|
|
23
|
+
* (packages/packs/test/larp-score-1471.test.ts), so a re-tune cannot ship stale points.
|
|
24
|
+
*/
|
|
25
|
+
export const LARP_SCORE_WEIGHTS = {
|
|
26
|
+
// links (DexScreener / Jupiter advertised links): calibrated where the eval set had ≥ 20 train coins.
|
|
27
|
+
no_website: { group: 'links', label: 'no website advertised', points: 9, source: 'calibrated' },
|
|
28
|
+
no_x: { group: 'links', label: 'no X account advertised', points: 10, source: 'calibrated' },
|
|
29
|
+
x_not_account: { group: 'links', label: 'X link is a single post or a community, not an account', points: 5, source: 'calibrated' },
|
|
30
|
+
no_telegram: { group: 'links', label: 'no Telegram advertised', points: 12, source: 'calibrated' },
|
|
31
|
+
site_is_social: { group: 'links', label: 'the "website" is a social page', points: 1, source: 'calibrated' },
|
|
32
|
+
domain_no_name_match: { group: 'links', label: "the website's domain does not name the token", points: 0, source: 'calibrated' },
|
|
33
|
+
cheap_tld: { group: 'links', label: 'website on a throwaway-priced TLD', points: 0, source: 'calibrated' },
|
|
34
|
+
site_is_launchpad: { group: 'links', label: 'the "website" is a launchpad/explorer page', points: 4, source: 'prior' },
|
|
35
|
+
site_linktree: { group: 'links', label: 'the "website" is a link hub', points: 2, source: 'prior' },
|
|
36
|
+
site_free_host: { group: 'links', label: 'website on a free/site-builder host', points: 2, source: 'prior' },
|
|
37
|
+
links_mismatch: { group: 'links', label: 'sources advertise different sites or X accounts', points: 8, source: 'prior' },
|
|
38
|
+
// website (one SSRF-safe fetch, no JavaScript)
|
|
39
|
+
drainer_markers: { group: 'website', label: 'wallet-drainer markers on the page', points: 30, source: 'prior' },
|
|
40
|
+
default_page: { group: 'website', label: 'website is a default/placeholder page', points: 15, source: 'prior' },
|
|
41
|
+
wallet_connect: { group: 'website', label: 'website asks to connect a wallet', points: 8, source: 'prior' },
|
|
42
|
+
never_names_token: { group: 'website', label: 'website never names the token', points: 8, source: 'prior' },
|
|
43
|
+
// domain (RDAP)
|
|
44
|
+
domain_new: { group: 'domain', label: 'domain registered under 30 days ago', points: 8, source: 'prior' },
|
|
45
|
+
domain_privacy: { group: 'domain', label: 'domain behind registrant privacy', points: 2, source: 'prior' },
|
|
46
|
+
// GitHub (keyless API)
|
|
47
|
+
no_github: { group: 'github', label: 'no GitHub repo advertised', points: 2, source: 'prior' },
|
|
48
|
+
github_archived: { group: 'github', label: 'GitHub repo is archived', points: 4, source: 'prior' },
|
|
49
|
+
github_fork: { group: 'github', label: 'GitHub repo is a fork', points: 4, source: 'prior' },
|
|
50
|
+
github_stale: { group: 'github', label: 'GitHub repo not pushed in 180+ days', points: 4, source: 'prior' },
|
|
51
|
+
// X account (keyless lookup chain)
|
|
52
|
+
x_suspended: { group: 'x', label: 'X account is suspended', points: 20, source: 'prior' },
|
|
53
|
+
x_not_found: { group: 'x', label: 'X account does not exist', points: 15, source: 'prior' },
|
|
54
|
+
x_account_days_old: { group: 'x', label: 'X account under 7 days old', points: 12, source: 'prior' },
|
|
55
|
+
x_account_new: { group: 'x', label: 'X account under 30 days old', points: 6, source: 'prior' },
|
|
56
|
+
x_tiny_following: { group: 'x', label: 'X account has under 50 followers', points: 5, source: 'prior' },
|
|
57
|
+
x_no_posts: { group: 'x', label: 'X account has no posts in 30 days', points: 6, source: 'prior' },
|
|
58
|
+
x_handle_mismatch: { group: 'x', label: "the website's X link and the advertised X account differ", points: 10, source: 'prior' },
|
|
59
|
+
x_renamed: { group: 'x', label: 'the linked X post is now under another handle', points: 5, source: 'prior' },
|
|
60
|
+
x_no_site_link: { group: 'x', label: "the X account's bio/website does not link the coin's site", points: 3, source: 'prior' },
|
|
61
|
+
};
|
|
62
|
+
/** Band thresholds (pre-registered round numbers; their measured precision is in LARP_SCORE_MEASURED). */
|
|
63
|
+
export const LARP_SCORE_BANDS = { medium: 25, high: 50 };
|
|
64
|
+
export const bandOf = (score) => (score >= LARP_SCORE_BANDS.high ? 'high' : score >= LARP_SCORE_BANDS.medium ? 'medium' : 'low');
|
|
65
|
+
/**
|
|
66
|
+
* Held-out TEST-split numbers for the link-shape part of the score (the only part the eval set can replay),
|
|
67
|
+
* from packages/packs/eval/larp-score-backtest.json (`dexterm backtest --subject larp-score`). A test asserts these match the committed report.
|
|
68
|
+
*/
|
|
69
|
+
export const LARP_SCORE_MEASURED = {
|
|
70
|
+
model: LARP_SCORE_MODEL,
|
|
71
|
+
eval: 'task-1401 eval set (built 2026-10-16), 72h outcome labels, bad = rug or dead',
|
|
72
|
+
part: 'link-shape flags only (page, domain, GitHub and X-account flags cannot be replayed on history)',
|
|
73
|
+
test: { n: 328, bad: 61, auroc: 0.691, by_band: { low: { n: 218, bad: 27 }, medium: { n: 110, bad: 34 }, high: { n: 0, bad: 0 } } },
|
|
74
|
+
train: { n: 726, bad: 294, auroc: 0.727 },
|
|
75
|
+
};
|
|
76
|
+
const W = LARP_SCORE_WEIGHTS;
|
|
77
|
+
const LINK_FLAG = {
|
|
78
|
+
site_is_social: 'site_is_social', site_is_launchpad: 'site_is_launchpad', site_linktree: 'site_linktree', site_free_host: 'site_free_host',
|
|
79
|
+
site_no_name_match: 'domain_no_name_match', cheap_tld: 'cheap_tld', x_not_profile: 'x_not_account', no_x: 'no_x', no_tg: 'no_telegram',
|
|
80
|
+
};
|
|
81
|
+
/** The link-shape flags of a feature set (shared by the live score and the backtest, so both score the same thing). */
|
|
82
|
+
export function linkFlagIds(f) {
|
|
83
|
+
const ids = linkSignals(f).map((s) => LINK_FLAG[s]);
|
|
84
|
+
if (f.web === 'none')
|
|
85
|
+
ids.unshift('no_website');
|
|
86
|
+
return ids;
|
|
87
|
+
}
|
|
88
|
+
/** Sum the link-shape points only (what the backtest scores). */
|
|
89
|
+
export function linkScore(f, weights = {}) {
|
|
90
|
+
return Math.min(100, linkFlagIds(f).reduce((a, id) => a + (weights[id] ?? W[id].points), 0));
|
|
91
|
+
}
|
|
92
|
+
/** Compute the LARP score from the gathered evidence. Pure. */
|
|
93
|
+
export function larpScore(ev) {
|
|
94
|
+
const flags = [];
|
|
95
|
+
const missing = [];
|
|
96
|
+
// A flag fitted to 0 points (it did not go with bad outcomes) is not listed: it would add nothing but noise.
|
|
97
|
+
const add = (id, evidence) => { const w = W[id]; if (w.points > 0)
|
|
98
|
+
flags.push({ id, group: w.group, label: w.label, points: w.points, weight: w.source, evidence }); };
|
|
99
|
+
const miss = (id, group, what, maxPoints) => { if (maxPoints > 0)
|
|
100
|
+
missing.push({ id, group, what, maxPoints }); };
|
|
101
|
+
const pts = (...ids) => ids.reduce((a, id) => a + W[id].points, 0);
|
|
102
|
+
if (ev.mode === 'token' && ev.link) {
|
|
103
|
+
const why = {
|
|
104
|
+
no_website: 'DexScreener/Jupiter list no website', no_x: 'DexScreener/Jupiter list no X link', no_telegram: 'DexScreener/Jupiter list no Telegram link',
|
|
105
|
+
x_not_account: `the advertised X link is a ${ev.link.x === 'status' ? 'single post' : ev.link.x === 'community' ? 'community' : 'non-account page'}`,
|
|
106
|
+
site_is_social: 'the advertised website is a social-network page', site_is_launchpad: 'the advertised website is a launchpad/explorer page',
|
|
107
|
+
site_linktree: 'the advertised website is a link hub', site_free_host: 'the advertised website is on a free/site-builder host',
|
|
108
|
+
domain_no_name_match: 'neither the symbol nor the name appears in the domain', cheap_tld: 'the domain is on a throwaway-priced TLD',
|
|
109
|
+
};
|
|
110
|
+
for (const id of linkFlagIds(ev.link))
|
|
111
|
+
add(id, why[id] ?? W[id].label);
|
|
112
|
+
}
|
|
113
|
+
if (ev.mismatch.length)
|
|
114
|
+
add('links_mismatch', ev.mismatch.join('; '));
|
|
115
|
+
// website page
|
|
116
|
+
if (ev.website) {
|
|
117
|
+
const p = ev.page;
|
|
118
|
+
if (!p || !p.ok)
|
|
119
|
+
miss('website:*', 'website', `website page not read${p && !p.ok ? ` (${p.reason})` : ''}`, pts('drainer_markers', 'default_page', 'wallet_connect', 'never_names_token'));
|
|
120
|
+
else {
|
|
121
|
+
if (p.drainerFlags.length)
|
|
122
|
+
add('drainer_markers', `markers: ${p.drainerFlags.slice(0, 3).join(', ')}`);
|
|
123
|
+
if (p.defaultPage)
|
|
124
|
+
add('default_page', 'the page matches a default/placeholder template');
|
|
125
|
+
if (p.walletConnect)
|
|
126
|
+
add('wallet_connect', 'the page carries a wallet-connect prompt');
|
|
127
|
+
if (p.mentionsSymbol === false && p.mentionsName === false)
|
|
128
|
+
add('never_names_token', 'neither the symbol nor the name appears in the page text');
|
|
129
|
+
else if (p.mentionsSymbol === null && p.mentionsName === null)
|
|
130
|
+
miss('never_names_token', 'website', 'whether the page names the token (no symbol/name to look for)', W.never_names_token.points);
|
|
131
|
+
}
|
|
132
|
+
const d = ev.domain;
|
|
133
|
+
if (!d || !d.ok)
|
|
134
|
+
miss('domain:*', 'domain', `domain registration not read${d && !d.ok ? ` (${d.reason})` : ''}`, pts('domain_new', 'domain_privacy'));
|
|
135
|
+
else {
|
|
136
|
+
if (typeof d.ageDays === 'number' && d.ageDays < 30)
|
|
137
|
+
add('domain_new', `registered ${d.ageDays} days ago (RDAP)`);
|
|
138
|
+
else if (d.ageDays === null)
|
|
139
|
+
miss('domain_new', 'domain', 'domain registration date', W.domain_new.points);
|
|
140
|
+
if (d.privacy === true)
|
|
141
|
+
add('domain_privacy', 'the RDAP registrant contact is redacted');
|
|
142
|
+
else if (d.privacy === null)
|
|
143
|
+
miss('domain_privacy', 'domain', 'registrant privacy', W.domain_privacy.points);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// GitHub
|
|
147
|
+
const g = ev.github;
|
|
148
|
+
if (g === 'none') {
|
|
149
|
+
if (ev.mode === 'token')
|
|
150
|
+
add('no_github', 'no github.com repo link among the advertised links or on the fetched page');
|
|
151
|
+
}
|
|
152
|
+
else if (g && !g.ok)
|
|
153
|
+
miss('github:*', 'github', `GitHub repo not read (${g.reason})`, pts('github_archived', 'github_fork', 'github_stale'));
|
|
154
|
+
else if (g) {
|
|
155
|
+
if (g.archived)
|
|
156
|
+
add('github_archived', 'the GitHub API marks the repo archived');
|
|
157
|
+
if (g.fork)
|
|
158
|
+
add('github_fork', 'the GitHub API marks the repo a fork');
|
|
159
|
+
if (typeof g.daysSincePush === 'number' && g.daysSincePush > 180)
|
|
160
|
+
add('github_stale', `last push ${g.daysSincePush} days ago`);
|
|
161
|
+
}
|
|
162
|
+
// X account
|
|
163
|
+
const x = ev.x;
|
|
164
|
+
if (x && x.handle) {
|
|
165
|
+
const at = `@${x.screenName ?? x.handle}`;
|
|
166
|
+
if (x.status === 'suspended')
|
|
167
|
+
add('x_suspended', `${at}: an X lookup source reports the account suspended`);
|
|
168
|
+
else if (x.status === 'not_found')
|
|
169
|
+
add('x_not_found', `@${x.handle}: two independent sources (vxtwitter, X oEmbed) found no such account`);
|
|
170
|
+
else if (x.status === 'unavailable')
|
|
171
|
+
miss('x:*', 'x', `X account not read (${x.unavailable[0] ?? 'every source failed'})`, Math.max(pts('x_suspended'), pts('x_account_days_old', 'x_tiny_following', 'x_no_posts', 'x_no_site_link')));
|
|
172
|
+
else {
|
|
173
|
+
if (typeof x.ageDays === 'number' && x.ageDays < 7)
|
|
174
|
+
add('x_account_days_old', `${at} created ${x.createdAt?.slice(0, 10)} (${x.ageDays} days ago)`);
|
|
175
|
+
else if (typeof x.ageDays === 'number' && x.ageDays < 30)
|
|
176
|
+
add('x_account_new', `${at} created ${x.createdAt?.slice(0, 10)} (${x.ageDays} days ago)`);
|
|
177
|
+
else if (x.ageDays === null)
|
|
178
|
+
miss('x_account_days_old', 'x', 'X account created date', W.x_account_days_old.points);
|
|
179
|
+
if (typeof x.followers === 'number' && x.followers < 50)
|
|
180
|
+
add('x_tiny_following', `${at} has ${x.followers} followers${x.countsSource === 'vxtwitter' && x.countsAsOf ? ` (cached copy from ${x.countsAsOf.slice(0, 16).replace('T', ' ')} UTC)` : ''}`);
|
|
181
|
+
else if (x.followers === null)
|
|
182
|
+
miss('x_tiny_following', 'x', 'X follower count', W.x_tiny_following.points);
|
|
183
|
+
if (x.tweets === 0)
|
|
184
|
+
add('x_no_posts', `${at} has never posted`);
|
|
185
|
+
else if (typeof x.daysSinceLastPost === 'number' && x.daysSinceLastPost > 30)
|
|
186
|
+
add('x_no_posts', `${at} last posted ${x.lastPostAt?.slice(0, 10)} (${x.daysSinceLastPost} days ago)`);
|
|
187
|
+
else if (x.lastPostAt === null && x.tweets !== 0)
|
|
188
|
+
miss('x_no_posts', 'x', 'X last post date', W.x_no_posts.points);
|
|
189
|
+
if (x.linksSite === false)
|
|
190
|
+
add('x_no_site_link', `${at}'s bio and website field do not mention ${ev.website ? new URL(ev.website).hostname.replace(/^www\./, '') : 'the site'}`);
|
|
191
|
+
else if (x.linksSite === null && ev.website)
|
|
192
|
+
miss('x_no_site_link', 'x', "whether the X account links the coin's site", W.x_no_site_link.points);
|
|
193
|
+
}
|
|
194
|
+
if (x.renamed)
|
|
195
|
+
add('x_renamed', x.renamed.evidence);
|
|
196
|
+
}
|
|
197
|
+
if (ev.xAgreement?.siteAgrees === false)
|
|
198
|
+
add('x_handle_mismatch', ev.xAgreement.note ?? "the website's X link and the advertised account differ");
|
|
199
|
+
const GROUP_MAX = {
|
|
200
|
+
website: pts('drainer_markers', 'default_page', 'wallet_connect', 'never_names_token'),
|
|
201
|
+
domain: pts('domain_new', 'domain_privacy'),
|
|
202
|
+
github: pts('github_archived', 'github_fork', 'github_stale'),
|
|
203
|
+
x: Math.max(pts('x_suspended'), pts('x_account_days_old', 'x_tiny_following', 'x_no_posts', 'x_no_site_link')),
|
|
204
|
+
};
|
|
205
|
+
for (const n of ev.notChecked ?? [])
|
|
206
|
+
if (!missing.some((m) => m.group === n.group))
|
|
207
|
+
miss(`${n.group}:*`, n.group, n.why, GROUP_MAX[n.group]);
|
|
208
|
+
flags.sort((a, b) => b.points - a.points);
|
|
209
|
+
const score = Math.min(100, flags.reduce((a, f) => a + f.points, 0));
|
|
210
|
+
const hi = Math.min(100, score + missing.reduce((a, m) => a + m.maxPoints, 0));
|
|
211
|
+
const m = LARP_SCORE_MEASURED.test;
|
|
212
|
+
return {
|
|
213
|
+
model: LARP_SCORE_MODEL, score, band: bandOf(score),
|
|
214
|
+
range: { lo: score, hi, bandLo: bandOf(score), bandHi: bandOf(hi) },
|
|
215
|
+
thresholds: { ...LARP_SCORE_BANDS },
|
|
216
|
+
flags, top: flags.filter((f) => f.points > 0).slice(0, 3), missing,
|
|
217
|
+
measured: `link-shape part only, held-out test split (${m.n} coins, ${m.bad} went bad within 72 h): AUROC ${m.auroc.toFixed(2)}; ${m.by_band.low.bad}/${m.by_band.low.n} low-band and ${m.by_band.medium.bad}/${m.by_band.medium.n} medium-band coins went bad. Page, domain, GitHub and X points are pre-registered priors, not fitted.`,
|
|
218
|
+
caveat: 'a sum of named flags, evidence not a verdict: any flag can be innocent, a low score is not a safety check, and missing data widens the range instead of lowering the score',
|
|
219
|
+
};
|
|
220
|
+
}
|