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,316 @@
|
|
|
1
|
+
// LARP detector — keyless X account lookup (task-1471). Read-only, no login, no cookies, no X API key.
|
|
2
|
+
//
|
|
3
|
+
// Why a fallback chain: measured 2026-10-19 (handoff evidence x-repro.md), FxTwitter answers `404 User not found`
|
|
4
|
+
// for accounts that exist on roughly one call in six, a missing handle gets a 302 to the project's GitHub page on
|
|
5
|
+
// some calls, and "suspended" is reported only some of the time. vxtwitter serves a cached copy (its own
|
|
6
|
+
// `fetched_on`, up to about a day old). X's own oEmbed endpoint says exists / missing and the canonical handle, and
|
|
7
|
+
// nothing else. The X syndication timeline (429 / empty) and the Nitter-class mirrors (offline, suspended, or a 200
|
|
8
|
+
// "offline" page) gave nothing usable and are not in the chain.
|
|
9
|
+
//
|
|
10
|
+
// So no single answer is a verdict: `active` needs one source returning the profile; `suspended` needs a source
|
|
11
|
+
// saying so; `not_found` needs TWO independent sources (vxtwitter AND oEmbed) saying missing; anything else is
|
|
12
|
+
// `unavailable`. Every field that could not be read is listed in `unavailable` and is never guessed.
|
|
13
|
+
//
|
|
14
|
+
// Politeness: one handle per user action, at most five calls, a per-host spacing limiter, a TTL cache, and a
|
|
15
|
+
// circuit breaker per source (the shared HealthRegistry: 3 failures or 2× 429 in a minute → degraded, 30 s..10 min
|
|
16
|
+
// backoff). The bio is untrusted text: the caller firewalls it before it reaches a model.
|
|
17
|
+
import { HealthRegistry } from "../health.js";
|
|
18
|
+
import { systemClock } from "../clock.js";
|
|
19
|
+
import { parseHttpUrl } from "./links.js";
|
|
20
|
+
const RESERVED = new Set(['i', 'home', 'search', 'intent', 'hashtag', 'share', 'explore', 'settings', 'messages', 'notifications', 'login', 'signup', 'tos', 'privacy', 'compose']);
|
|
21
|
+
const HANDLE = /^[A-Za-z0-9_]{1,15}$/;
|
|
22
|
+
const X_HOSTS = new Set(['x.com', 'twitter.com', 'mobile.twitter.com', 'mobile.x.com', 'fxtwitter.com', 'vxtwitter.com', 'fixupx.com', 'nitter.net']);
|
|
23
|
+
const SOURCE_ID = { fxtwitter: 'fxtwitter', vxtwitter: 'vxtwitter', 'x-oembed': 'x-oembed' };
|
|
24
|
+
const HOST = { fxtwitter: 'api.fxtwitter.com', vxtwitter: 'api.vxtwitter.com', 'x-oembed': 'publish.x.com' };
|
|
25
|
+
/** Parse an advertised X link into its kind, handle and status id. Pure. */
|
|
26
|
+
export function parseXLink(raw) {
|
|
27
|
+
const u = parseHttpUrl(raw);
|
|
28
|
+
if (!u)
|
|
29
|
+
return { kind: 'other', handle: null, statusId: null };
|
|
30
|
+
const host = u.hostname.toLowerCase().replace(/^www\./, '');
|
|
31
|
+
if (!X_HOSTS.has(host))
|
|
32
|
+
return { kind: 'other', handle: null, statusId: null };
|
|
33
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
34
|
+
if (parts[0]?.toLowerCase() === 'i' && parts[1]?.toLowerCase() === 'communities')
|
|
35
|
+
return { kind: 'community', handle: null, statusId: null };
|
|
36
|
+
const h = parts[0] ?? '';
|
|
37
|
+
if (!HANDLE.test(h) || RESERVED.has(h.toLowerCase()))
|
|
38
|
+
return { kind: 'other', handle: null, statusId: null };
|
|
39
|
+
if (parts[1]?.toLowerCase() === 'status' && /^\d{1,25}$/.test(parts[2] ?? ''))
|
|
40
|
+
return { kind: 'status', handle: h, statusId: parts[2] };
|
|
41
|
+
return { kind: parts.length === 1 ? 'profile' : 'other', handle: h, statusId: null };
|
|
42
|
+
}
|
|
43
|
+
const json = (body) => {
|
|
44
|
+
try {
|
|
45
|
+
const v = JSON.parse(body);
|
|
46
|
+
return v && typeof v === 'object' ? v : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
53
|
+
const str = (v) => (typeof v === 'string' && v ? v : null);
|
|
54
|
+
const iso = (v) => { const t = typeof v === 'string' ? Date.parse(v) : NaN; return Number.isFinite(t) ? new Date(t).toISOString() : null; };
|
|
55
|
+
const days = (now, at) => (at ? Math.max(0, Math.floor((now - Date.parse(at)) / 86_400_000)) : null);
|
|
56
|
+
const bareHost = (raw) => { const u = raw ? parseHttpUrl(raw) : null; return u ? u.hostname.toLowerCase().replace(/^www\./, '') : null; };
|
|
57
|
+
function mentionsToken(bio, ctx) {
|
|
58
|
+
const sym = (ctx.symbol ?? '').trim();
|
|
59
|
+
const name = (ctx.name ?? '').trim();
|
|
60
|
+
if (!sym && !name && !ctx.mint)
|
|
61
|
+
return null;
|
|
62
|
+
const b = bio.toLowerCase();
|
|
63
|
+
if (ctx.mint && bio.includes(ctx.mint))
|
|
64
|
+
return true;
|
|
65
|
+
if (sym.length >= 2 && new RegExp(`(^|[^a-z0-9])\\$?${sym.toLowerCase().replace(/[^a-z0-9]/g, '')}([^a-z0-9]|$)`).test(b))
|
|
66
|
+
return true;
|
|
67
|
+
if (name.length >= 3 && b.includes(name.toLowerCase()))
|
|
68
|
+
return true;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
/** The lookup service: chain + cache + spacing + circuit breakers. One instance per process. */
|
|
72
|
+
export class XAccountLookup {
|
|
73
|
+
fetchImpl;
|
|
74
|
+
clock;
|
|
75
|
+
health;
|
|
76
|
+
minIntervalMs;
|
|
77
|
+
ttlMs;
|
|
78
|
+
ttlUnavailableMs;
|
|
79
|
+
maxEntries;
|
|
80
|
+
cache = new Map();
|
|
81
|
+
lastCall = new Map();
|
|
82
|
+
constructor(opts) {
|
|
83
|
+
this.fetchImpl = opts.fetch;
|
|
84
|
+
this.clock = opts.clock ?? systemClock;
|
|
85
|
+
this.health = opts.health ?? new HealthRegistry({ clock: this.clock });
|
|
86
|
+
this.minIntervalMs = opts.minIntervalMs ?? 1000;
|
|
87
|
+
this.ttlMs = opts.ttlMs ?? 30 * 60_000;
|
|
88
|
+
this.ttlUnavailableMs = opts.ttlUnavailableMs ?? 10 * 60_000;
|
|
89
|
+
this.maxEntries = opts.maxEntries ?? 500;
|
|
90
|
+
}
|
|
91
|
+
/** Per-host spacing: calls to one host are serialized at least minIntervalMs apart. */
|
|
92
|
+
async spaced(host, fn) {
|
|
93
|
+
const prev = this.lastCall.get(host) ?? Promise.resolve();
|
|
94
|
+
let release;
|
|
95
|
+
const mine = new Promise((r) => { release = r; });
|
|
96
|
+
this.lastCall.set(host, prev.then(() => mine));
|
|
97
|
+
await prev;
|
|
98
|
+
try {
|
|
99
|
+
return await fn();
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (this.minIntervalMs > 0)
|
|
103
|
+
void this.clock.sleep(this.minIntervalMs).then(release);
|
|
104
|
+
else
|
|
105
|
+
release();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async get(source, endpoint, url, trace) {
|
|
109
|
+
const sid = SOURCE_ID[source];
|
|
110
|
+
if (!this.health.isAvailable(sid)) {
|
|
111
|
+
trace.push({ source, endpoint, status: null, result: 'skipped: circuit open (source degraded)', ms: 0 });
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const t0 = this.clock.now();
|
|
115
|
+
try {
|
|
116
|
+
const r = await this.spaced(HOST[source], () => this.fetchImpl(url));
|
|
117
|
+
const ms = this.clock.now() - t0;
|
|
118
|
+
// 404 / 3xx are answers (a vote), not source failures; 429 and 5xx other than vxtwitter's suspended body are.
|
|
119
|
+
const failed = r.status === 0 || r.status === 429 || (r.status >= 500 && !/suspended/i.test(r.body));
|
|
120
|
+
this.health.record(sid, failed ? { ok: false, status: r.status, latencyMs: ms, error: `http ${r.status}` } : { ok: true, status: r.status, latencyMs: ms });
|
|
121
|
+
return { status: r.status, body: r.body, ms };
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
const ms = this.clock.now() - t0;
|
|
125
|
+
this.health.record(sid, { ok: false, latencyMs: ms, error: e.message });
|
|
126
|
+
trace.push({ source, endpoint, status: null, result: `error: ${e.message}`.slice(0, 120), ms });
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Look up the account behind an advertised X link. Never throws. */
|
|
131
|
+
async lookup(xUrl, ctx = {}) {
|
|
132
|
+
const link = parseXLink(xUrl);
|
|
133
|
+
const now = this.clock.now();
|
|
134
|
+
const key = `${link.handle?.toLowerCase() ?? xUrl}|${link.statusId ?? ''}|${(ctx.symbol ?? '').toLowerCase()}|${(ctx.name ?? '').toLowerCase()}|${ctx.mint ?? ''}|${bareHost(ctx.website) ?? ''}`;
|
|
135
|
+
const hit = this.cache.get(key);
|
|
136
|
+
if (hit && now - hit.at < (hit.report.status === 'unavailable' ? this.ttlUnavailableMs : this.ttlMs))
|
|
137
|
+
return { ...hit.report, cached: true };
|
|
138
|
+
const report = await this.uncached(xUrl, link, ctx);
|
|
139
|
+
this.cache.delete(key);
|
|
140
|
+
this.cache.set(key, { at: now, report });
|
|
141
|
+
while (this.cache.size > this.maxEntries)
|
|
142
|
+
this.cache.delete(this.cache.keys().next().value);
|
|
143
|
+
return report;
|
|
144
|
+
}
|
|
145
|
+
async uncached(url, link, ctx) {
|
|
146
|
+
const now = this.clock.now();
|
|
147
|
+
const trace = [];
|
|
148
|
+
const base = {
|
|
149
|
+
url, kind: link.kind, handle: link.handle, status: 'unavailable', screenName: null, id: null, renamed: null,
|
|
150
|
+
createdAt: null, ageDays: null, followers: null, following: null, tweets: null, lastPostAt: null, daysSinceLastPost: null,
|
|
151
|
+
bio: null, website: null, bioMentionsToken: null, linksSite: null, countsAsOf: null, countsSource: null,
|
|
152
|
+
unavailable: [], sources: trace, cached: false, fetchedAt: new Date(now).toISOString(),
|
|
153
|
+
};
|
|
154
|
+
if (!link.handle) {
|
|
155
|
+
base.unavailable = ['account (the X link is not an account or a post)'];
|
|
156
|
+
return base;
|
|
157
|
+
}
|
|
158
|
+
let h = link.handle;
|
|
159
|
+
// A status link: who wrote the post now. A different author means a renamed account or a borrowed post.
|
|
160
|
+
if (link.statusId) {
|
|
161
|
+
const r = await this.get('fxtwitter', 'status', `https://api.fxtwitter.com/${h}/status/${link.statusId}`, trace);
|
|
162
|
+
const j = r ? json(r.body) : null;
|
|
163
|
+
const author = str(j?.tweet?.author?.screen_name);
|
|
164
|
+
if (r)
|
|
165
|
+
trace.push({ source: 'fxtwitter', endpoint: 'status', status: r.status, result: author ? `post by @${author}` : `no post (${str(j?.message) ?? r.status})`, ms: r.ms });
|
|
166
|
+
if (author && author.toLowerCase() !== h.toLowerCase()) {
|
|
167
|
+
base.renamed = { from: h, to: author, evidence: `the linked post ${link.statusId} is now shown under @${author}` };
|
|
168
|
+
h = author;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
let profile = null;
|
|
172
|
+
let suspended = false;
|
|
173
|
+
let missingVotes = 0;
|
|
174
|
+
const missingBy = [];
|
|
175
|
+
// 1) FxTwitter profile: fresh counts. A 404 is one vote only (measured false 404s).
|
|
176
|
+
const fx = await this.get('fxtwitter', 'profile', `https://api.fxtwitter.com/${h}`, trace);
|
|
177
|
+
if (fx) {
|
|
178
|
+
const j = json(fx.body);
|
|
179
|
+
const u = j?.user;
|
|
180
|
+
if (fx.status === 200 && u) {
|
|
181
|
+
profile = { screenName: str(u.screen_name), id: str(u.id), createdAt: iso(u.joined), followers: num(u.followers), following: num(u.following), tweets: num(u.tweets), bio: typeof u.description === 'string' ? u.description : null, website: str(u.website?.url), asOf: new Date(now).toISOString() };
|
|
182
|
+
trace.push({ source: 'fxtwitter', endpoint: 'profile', status: 200, result: 'profile', ms: fx.ms });
|
|
183
|
+
}
|
|
184
|
+
else if (str(j?.reason) === 'suspended') {
|
|
185
|
+
suspended = true;
|
|
186
|
+
trace.push({ source: 'fxtwitter', endpoint: 'profile', status: fx.status, result: 'suspended', ms: fx.ms });
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
trace.push({ source: 'fxtwitter', endpoint: 'profile', status: fx.status, result: fx.status >= 300 && fx.status < 400 ? 'redirect (missing handle or error page)' : `no profile (${str(j?.message) ?? fx.status})`, ms: fx.ms });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// 2) vxtwitter: cached copy; says suspended / missing reliably in the probe.
|
|
193
|
+
if (!profile) {
|
|
194
|
+
const vx = await this.get('vxtwitter', 'profile', `https://api.vxtwitter.com/${h}`, trace);
|
|
195
|
+
if (vx) {
|
|
196
|
+
const j = json(vx.body);
|
|
197
|
+
if (vx.status === 200 && j && str(j.screen_name)) {
|
|
198
|
+
const fetchedOn = num(j.fetched_on);
|
|
199
|
+
profile = { screenName: str(j.screen_name), id: j.id === undefined || j.id === null ? null : String(j.id), createdAt: iso(j.created_at), followers: num(j.followers_count), following: num(j.following_count), tweets: num(j.tweet_count), bio: typeof j.description === 'string' ? j.description : null, website: null, asOf: fetchedOn ? new Date(fetchedOn * 1000).toISOString() : null };
|
|
200
|
+
trace.push({ source: 'vxtwitter', endpoint: 'profile', status: 200, result: 'profile (cached copy)', ms: vx.ms });
|
|
201
|
+
}
|
|
202
|
+
else if (/suspended/i.test(str(j?.error) ?? '')) {
|
|
203
|
+
suspended = true;
|
|
204
|
+
trace.push({ source: 'vxtwitter', endpoint: 'profile', status: vx.status, result: 'suspended', ms: vx.ms });
|
|
205
|
+
}
|
|
206
|
+
else if (vx.status === 404) {
|
|
207
|
+
missingVotes++;
|
|
208
|
+
missingBy.push('vxtwitter');
|
|
209
|
+
trace.push({ source: 'vxtwitter', endpoint: 'profile', status: 404, result: 'not found', ms: vx.ms });
|
|
210
|
+
}
|
|
211
|
+
else
|
|
212
|
+
trace.push({ source: 'vxtwitter', endpoint: 'profile', status: vx.status, result: 'no answer', ms: vx.ms });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// 3) X oEmbed: exists vs missing (200 for a suspended account too).
|
|
216
|
+
let oembedExists = null;
|
|
217
|
+
if (!profile && !suspended) {
|
|
218
|
+
const oe = await this.get('x-oembed', 'oembed', `https://publish.x.com/oembed?url=${encodeURIComponent(`https://x.com/${h}`)}`, trace);
|
|
219
|
+
if (oe) {
|
|
220
|
+
const j = json(oe.body);
|
|
221
|
+
if (oe.status === 200 && j) {
|
|
222
|
+
oembedExists = true;
|
|
223
|
+
trace.push({ source: 'x-oembed', endpoint: 'oembed', status: 200, result: 'exists', ms: oe.ms });
|
|
224
|
+
}
|
|
225
|
+
else if (oe.status === 404) {
|
|
226
|
+
oembedExists = false;
|
|
227
|
+
missingVotes++;
|
|
228
|
+
missingBy.push('x-oembed');
|
|
229
|
+
trace.push({ source: 'x-oembed', endpoint: 'oembed', status: 404, result: 'not found', ms: oe.ms });
|
|
230
|
+
}
|
|
231
|
+
else
|
|
232
|
+
trace.push({ source: 'x-oembed', endpoint: 'oembed', status: oe.status, result: 'no answer', ms: oe.ms });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// 4) Last post date (FxTwitter statuses), only for an account that exists.
|
|
236
|
+
let lastPostAt = null;
|
|
237
|
+
let postsRead = false;
|
|
238
|
+
if (profile) {
|
|
239
|
+
const st = await this.get('fxtwitter', 'statuses', `https://api.fxtwitter.com/2/profile/${h}/statuses`, trace);
|
|
240
|
+
const j = st ? json(st.body) : null;
|
|
241
|
+
const results = Array.isArray(j?.results) ? j.results : null;
|
|
242
|
+
if (st?.status === 200 && results) {
|
|
243
|
+
postsRead = true;
|
|
244
|
+
const times = results.map((r) => iso(r.created_at)).filter((t) => !!t).sort();
|
|
245
|
+
lastPostAt = times.at(-1) ?? null;
|
|
246
|
+
trace.push({ source: 'fxtwitter', endpoint: 'statuses', status: 200, result: `${results.length} recent posts`, ms: st.ms });
|
|
247
|
+
}
|
|
248
|
+
else if (st)
|
|
249
|
+
trace.push({ source: 'fxtwitter', endpoint: 'statuses', status: st.status, result: 'posts unavailable', ms: st.ms });
|
|
250
|
+
}
|
|
251
|
+
const out = { ...base };
|
|
252
|
+
if (profile) {
|
|
253
|
+
out.status = 'active';
|
|
254
|
+
Object.assign(out, {
|
|
255
|
+
screenName: profile.screenName, id: profile.id, createdAt: profile.createdAt, ageDays: days(now, profile.createdAt),
|
|
256
|
+
followers: profile.followers, following: profile.following, tweets: profile.tweets,
|
|
257
|
+
bio: profile.bio !== null ? profile.bio.slice(0, 280) : null, website: profile.website,
|
|
258
|
+
countsAsOf: profile.asOf, countsSource: trace.find((t) => t.endpoint === 'profile' && t.status === 200)?.source ?? null,
|
|
259
|
+
lastPostAt, daysSinceLastPost: days(now, lastPostAt),
|
|
260
|
+
});
|
|
261
|
+
// A posts list that came back empty for an account with zero tweets is "no posts", not missing.
|
|
262
|
+
if (postsRead && !lastPostAt && profile.tweets === 0)
|
|
263
|
+
out.daysSinceLastPost = null;
|
|
264
|
+
out.bioMentionsToken = profile.bio !== null ? mentionsToken(profile.bio, ctx) : null;
|
|
265
|
+
const site = bareHost(ctx.website);
|
|
266
|
+
if (site) {
|
|
267
|
+
const bioHit = (profile.bio ?? '').toLowerCase().includes(site);
|
|
268
|
+
const webHit = bareHost(profile.website) === site;
|
|
269
|
+
out.linksSite = bioHit || webHit ? true : (profile.website !== null || profile.bio !== null) ? false : null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else if (suspended)
|
|
273
|
+
out.status = 'suspended';
|
|
274
|
+
else if (missingVotes >= 2)
|
|
275
|
+
out.status = 'not_found';
|
|
276
|
+
else if (oembedExists)
|
|
277
|
+
out.status = 'unavailable'; // exists per X, but no source returned the profile
|
|
278
|
+
const miss = [];
|
|
279
|
+
if (out.status !== 'active') {
|
|
280
|
+
if (out.status === 'unavailable')
|
|
281
|
+
miss.push(`account status (${missingBy.length ? `only ${missingBy.join(' + ')} said missing; ` : ''}no source returned the profile)`);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
if (out.createdAt === null)
|
|
285
|
+
miss.push('account created date');
|
|
286
|
+
if (out.followers === null)
|
|
287
|
+
miss.push('follower count');
|
|
288
|
+
if (out.following === null)
|
|
289
|
+
miss.push('following count');
|
|
290
|
+
if (out.tweets === null)
|
|
291
|
+
miss.push('post count');
|
|
292
|
+
if (!postsRead)
|
|
293
|
+
miss.push('last post date');
|
|
294
|
+
if (out.bio === null)
|
|
295
|
+
miss.push('bio');
|
|
296
|
+
if (ctx.website && out.linksSite === null)
|
|
297
|
+
miss.push('site cross-link');
|
|
298
|
+
if (out.bioMentionsToken === null && (ctx.symbol || ctx.name))
|
|
299
|
+
miss.push('bio mentions the token');
|
|
300
|
+
}
|
|
301
|
+
out.unavailable = miss;
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Advertised handles per source, and whether they and the site's own X links agree. Pure. */
|
|
306
|
+
export function xHandleAgreement(advertised, siteLinks) {
|
|
307
|
+
const handles = advertised.map((l) => ({ h: parseXLink(l.url), source: l.source })).filter((x) => x.h.handle).map((x) => ({ handle: x.h.handle, source: x.source }));
|
|
308
|
+
const distinct = [...new Set(handles.map((x) => x.handle.toLowerCase()))];
|
|
309
|
+
const siteHandles = siteLinks === null ? null : [...new Set(siteLinks.map((u) => parseXLink(u)).filter((x) => x.kind === 'profile' && x.handle).map((x) => x.handle.toLowerCase()))];
|
|
310
|
+
const siteLinksAdvertised = siteHandles === null || !distinct.length ? null : siteHandles.length === 0 ? false : siteHandles.some((s) => distinct.includes(s));
|
|
311
|
+
const agree = distinct.length === 0 ? null : distinct.length === 1 && (siteHandles === null || siteHandles.length === 0 || siteHandles.includes(distinct[0]));
|
|
312
|
+
const siteAgrees = !distinct.length || !siteHandles?.length ? null : siteHandles.some((s) => distinct.includes(s));
|
|
313
|
+
const note = distinct.length > 1 ? `sources advertise different X accounts: ${distinct.map((d) => `@${d}`).join(', ')}`
|
|
314
|
+
: agree === false && siteHandles?.length ? `the website links @${siteHandles.join(', @')} but the token lists @${distinct[0]}` : null;
|
|
315
|
+
return { handles, agree, siteAgrees, siteHandles, siteLinksAdvertised, note };
|
|
316
|
+
}
|
|
@@ -34,6 +34,17 @@ export class PaperDeskError extends Error {
|
|
|
34
34
|
this.reason = reason;
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
export const DEFAULT_QUOTE_RETRY = { maxAttempts: 4, maxWaitMs: 6_000, maxTotalMs: 25_000 };
|
|
38
|
+
/** A keyless rate limit (a 429, or the data layer's own 429 cool-down / bucket pause), which is worth a bounded re-quote. */
|
|
39
|
+
export function isRateLimitedQuoteError(e) {
|
|
40
|
+
if (!(e instanceof Error))
|
|
41
|
+
return false;
|
|
42
|
+
if (e.name === 'JupiterRateLimitedError')
|
|
43
|
+
return true;
|
|
44
|
+
if (e.name === 'SourceUnavailableError')
|
|
45
|
+
return /jupiter/.test(e.message);
|
|
46
|
+
return e.name === 'HttpError' && e.status === 429;
|
|
47
|
+
}
|
|
37
48
|
const isFiniteNum = (x) => typeof x === 'number' && Number.isFinite(x);
|
|
38
49
|
/**
|
|
39
50
|
* The paper agent desk. One instance per armed mandate + paper wallet. All four verbs (open / size / confirm /
|
|
@@ -52,6 +63,8 @@ export class PaperAgentDesk {
|
|
|
52
63
|
kill;
|
|
53
64
|
clock;
|
|
54
65
|
agent;
|
|
66
|
+
quoteRetry;
|
|
67
|
+
onQuoteRetry;
|
|
55
68
|
tickets = new Map();
|
|
56
69
|
ticketSeq = 0;
|
|
57
70
|
constructor(deps) {
|
|
@@ -71,6 +84,9 @@ export class PaperAgentDesk {
|
|
|
71
84
|
this.kill = deps.killSwitch ?? new KillSwitch();
|
|
72
85
|
this.clock = deps.clock ?? systemClock;
|
|
73
86
|
this.agent = deps.agent ?? 'mcp-agent';
|
|
87
|
+
const r = deps.quoteRetry ?? DEFAULT_QUOTE_RETRY;
|
|
88
|
+
this.quoteRetry = { maxAttempts: Math.max(1, Math.floor(r.maxAttempts)), maxWaitMs: Math.max(0, r.maxWaitMs), maxTotalMs: Math.max(0, r.maxTotalMs) };
|
|
89
|
+
this.onQuoteRetry = deps.onQuoteRetry ?? null;
|
|
74
90
|
}
|
|
75
91
|
/** The agent's effective per-trade cap: the mandate cap times the weakest-tier multiplier. */
|
|
76
92
|
perTradeCapUsd() {
|
|
@@ -221,9 +237,9 @@ export class PaperAgentDesk {
|
|
|
221
237
|
this.requireAction('confirm', t.side, { ticketId });
|
|
222
238
|
this.requireMintAllowed('confirm', t.mint, { ticketId });
|
|
223
239
|
const order = await this.buildOrder('confirm', t);
|
|
224
|
-
const rec = await this.book('confirm', order, { ticketId });
|
|
240
|
+
const { rec, quoteAttempts } = await this.book('confirm', order, { ticketId });
|
|
225
241
|
this.tickets.delete(ticketId);
|
|
226
|
-
return { fill: rec, position: this.positionView(this.wallet.position(t.mint)) };
|
|
242
|
+
return { fill: rec, position: this.positionView(this.wallet.position(t.mint)), quoteAttempts };
|
|
227
243
|
}
|
|
228
244
|
/** Close a fraction of a held PAPER position in one call (open sell → size fraction → confirm). */
|
|
229
245
|
async close(input) {
|
|
@@ -239,8 +255,8 @@ export class PaperAgentDesk {
|
|
|
239
255
|
const t = { id: `pt_close_${++this.ticketSeq}`, mint: input.mint, symbol: pos.symbol, decimals: pos.decimals, side: 'sell', usd: null, fraction, openedAt: this.clock.now() };
|
|
240
256
|
this.note('agent.paper.close', { mint: input.mint, fraction });
|
|
241
257
|
const order = await this.buildOrder('close', t);
|
|
242
|
-
const rec = await this.book('close', order, { mint: input.mint });
|
|
243
|
-
return { fill: rec, position: this.positionView(this.wallet.position(input.mint)) };
|
|
258
|
+
const { rec, quoteAttempts } = await this.book('close', order, { mint: input.mint });
|
|
259
|
+
return { fill: rec, position: this.positionView(this.wallet.position(input.mint)), quoteAttempts };
|
|
244
260
|
}
|
|
245
261
|
/** Build the paper order from a ticket, re-checking the buy caps against the live wallet at execute time. */
|
|
246
262
|
async buildOrder(stage, t) {
|
|
@@ -265,15 +281,64 @@ export class PaperAgentDesk {
|
|
|
265
281
|
this.refuse(stage, 'bad-request', 'the sell fraction rounds to zero tokens', { ticketId: t.id });
|
|
266
282
|
return { mint: t.mint, symbol: pos.symbol, side: 'sell', decimals: pos.decimals, amountRaw, slippageBps: this.mandate.limits.maxSlippageBps };
|
|
267
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Fetch a FRESH quote for this call. A short rate limit is re-quoted inside the bounded `quoteRetry` budget:
|
|
286
|
+
* each retry is journaled and reported first, waits min(stated wait, maxWaitMs), then re-runs the gate, so a
|
|
287
|
+
* pause during the wait still halts the agent. Any other error, the last allowed attempt, or the spent budget
|
|
288
|
+
* (an attempt still in flight is aborted) refuses with the verbatim message. No quote outlives the call.
|
|
289
|
+
*/
|
|
290
|
+
async freshQuote(stage, order, extra) {
|
|
291
|
+
const { maxAttempts, maxWaitMs, maxTotalMs } = this.quoteRetry;
|
|
292
|
+
const started = this.clock.now();
|
|
293
|
+
let lastRateLimit = null;
|
|
294
|
+
for (let attempt = 1;; attempt++) {
|
|
295
|
+
const budget = new AbortController();
|
|
296
|
+
const disarm = new AbortController();
|
|
297
|
+
void this.clock.sleep(Math.max(1, maxTotalMs - (this.clock.now() - started)), disarm.signal)
|
|
298
|
+
.then(() => { if (!disarm.signal.aborted)
|
|
299
|
+
budget.abort(); });
|
|
300
|
+
let quote = null;
|
|
301
|
+
let failure = null;
|
|
302
|
+
try {
|
|
303
|
+
quote = await this.quote(buildQuoteRequest(order), budget.signal);
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
failure = e;
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
disarm.abort();
|
|
310
|
+
}
|
|
311
|
+
if (quote && !budget.signal.aborted)
|
|
312
|
+
return { quote, attempts: attempt };
|
|
313
|
+
const msg = failure instanceof Error ? failure.message : failure === null ? 'no quote' : String(failure);
|
|
314
|
+
const elapsed = this.clock.now() - started;
|
|
315
|
+
const tried = attempt > 1 ? ` (${attempt} quote attempts in ${Math.round(elapsed / 1000)}s)` : '';
|
|
316
|
+
if (budget.signal.aborted) {
|
|
317
|
+
this.refuse(stage, 'quote-failed', `could not price the trade within ${Math.round(maxTotalMs / 1000)}s${lastRateLimit ? `: ${lastRateLimit}` : ''}${tried}`, { ...extra, quoteAttempts: attempt });
|
|
318
|
+
}
|
|
319
|
+
if (!isRateLimitedQuoteError(failure))
|
|
320
|
+
this.refuse(stage, 'quote-failed', `could not price the trade: ${msg}${tried}`, { ...extra, quoteAttempts: attempt });
|
|
321
|
+
lastRateLimit = msg;
|
|
322
|
+
const stated = failure.retryAfterMs;
|
|
323
|
+
const wait = Math.min(maxWaitMs, Math.max(1_000, typeof stated === 'number' && Number.isFinite(stated) ? stated : 2_000 * attempt));
|
|
324
|
+
if (attempt >= maxAttempts || elapsed + wait >= maxTotalMs) {
|
|
325
|
+
this.refuse(stage, 'quote-failed', `could not price the trade: ${msg}${tried}`, { ...extra, quoteAttempts: attempt });
|
|
326
|
+
}
|
|
327
|
+
const info = { stage, mint: order.mint, side: order.side, attempt: attempt + 1, maxAttempts, waitMs: wait, message: msg };
|
|
328
|
+
this.note('agent.paper.quote-retry', { ...extra, ...info });
|
|
329
|
+
try {
|
|
330
|
+
this.onQuoteRetry?.(info);
|
|
331
|
+
}
|
|
332
|
+
catch { /* the spectator is best-effort */ }
|
|
333
|
+
await this.clock.sleep(wait);
|
|
334
|
+
this.gate(stage, extra);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
268
337
|
/** Fetch a fresh quote and book the fill through the PAPER executor; journal the booked fill. */
|
|
269
338
|
async book(stage, order, extra) {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
catch (e) {
|
|
275
|
-
this.refuse(stage, 'quote-failed', `could not price the trade: ${e instanceof Error ? e.message : String(e)}`, extra);
|
|
276
|
-
}
|
|
339
|
+
const { quote, attempts } = await this.freshQuote(stage, order, extra);
|
|
340
|
+
if (attempts > 1)
|
|
341
|
+
extra = { ...extra, quoteAttempts: attempts };
|
|
277
342
|
// Buys honour the mandate's price-impact ceiling (SwapQuote.priceImpactPct is already a PERCENT — the source
|
|
278
343
|
// converts Jupiter's 0..1 fraction once). Exits are never trapped by it, like the plan engine's stop leg.
|
|
279
344
|
if (order.side === 'buy' && isFiniteNum(quote.priceImpactPct) && quote.priceImpactPct > this.mandate.limits.maxPriceImpactPct + 1e-9) {
|
|
@@ -293,7 +358,7 @@ export class PaperAgentDesk {
|
|
|
293
358
|
solDelta: rec.solDelta.toString(), tokenDelta: rec.tokenDelta.toString(),
|
|
294
359
|
priceSol: rec.priceSol, priceUsd: rec.priceUsd, realizedLamports: rec.realizedLamports.toString(), fillId: rec.id,
|
|
295
360
|
});
|
|
296
|
-
return rec;
|
|
361
|
+
return { rec: rec, quoteAttempts: attempts };
|
|
297
362
|
}
|
|
298
363
|
// ───────────────────────────── the two reads ─────────────────────────────
|
|
299
364
|
positionView(pos) {
|
|
@@ -268,6 +268,8 @@ export class PlanEngine {
|
|
|
268
268
|
authorizeCopy(mandateId, proof, wallet) {
|
|
269
269
|
if (!this.gate.handsFreeAllowed())
|
|
270
270
|
throw new PolicyRefusal('gate-on', 'copy automation needs the less-safe opt-in');
|
|
271
|
+
if (this.mandates.get(mandateId)?.m.paperOnly)
|
|
272
|
+
throw new PolicyRefusal('paper-only-mandate', 'a paper-only mandate is for the agent paper desk; copy automation cannot use it');
|
|
271
273
|
this.armMandate(mandateId, proof);
|
|
272
274
|
return (input, supply, ref) => {
|
|
273
275
|
const check = () => {
|
|
@@ -315,6 +317,9 @@ export class PlanEngine {
|
|
|
315
317
|
const alive = mandateAlive(x.m, this.clock.now());
|
|
316
318
|
if (!alive.ok)
|
|
317
319
|
refuse(alive.reason, 'mandate is not alive');
|
|
320
|
+
// task-1469: a paper-only mandate (PAPER_HARD ceilings) feeds the agent paper desk only; no plan arms into it.
|
|
321
|
+
if (x.m.paperOnly)
|
|
322
|
+
refuse('paper-only-mandate', 'a paper-only mandate is for the agent paper desk; plans cannot arm into it');
|
|
318
323
|
if (TIERS[tier].rank > TIERS[x.m.maxTier].rank)
|
|
319
324
|
refuse('tier-not-allowed', `this mandate accepts up to ${x.m.maxTier}; ${tier} is weaker`);
|
|
320
325
|
const v = this.admit(r, x, tier);
|
|
@@ -599,6 +604,8 @@ export class PlanEngine {
|
|
|
599
604
|
const alive = mandateAlive(m, now);
|
|
600
605
|
if (!alive.ok)
|
|
601
606
|
return no(alive.reason, 'mandate not alive');
|
|
607
|
+
if (m.paperOnly)
|
|
608
|
+
return no('paper-only-mandate', 'a paper-only mandate never fires a plan');
|
|
602
609
|
if (now > p.expiresAt)
|
|
603
610
|
return no('plan-expired', 'plan expired');
|
|
604
611
|
if (!m.allowed.actions.includes(side))
|
|
@@ -13,6 +13,15 @@ export const HARD = Object.freeze({
|
|
|
13
13
|
tipBudgetLamports: 500_000_000, maxTipLamportsPerTx: 10_000_000, maxPriorityFeeLamportsPerTx: 10_000_000,
|
|
14
14
|
ttlMs: 24 * 3600_000, maxSendsPerSec: 20,
|
|
15
15
|
});
|
|
16
|
+
/**
|
|
17
|
+
* task-1469: ceilings for a PAPER-ONLY mandate (`dexterm mandate draft --paper`). Only the agent paper desk reads
|
|
18
|
+
* one: the plan engine refuses to arm a plan into it, fire under it or copy-trade through it, so these numbers never
|
|
19
|
+
* reach a signer, and HARD (above) stays the ceiling of every mandate a real fill can use. Fills are still priced
|
|
20
|
+
* on a live quote, so a bigger paper size pays its real price impact.
|
|
21
|
+
*/
|
|
22
|
+
export const PAPER_HARD = Object.freeze({
|
|
23
|
+
...HARD, totalUsd: 10_000, perTradeUsd: 2_000, perCoinUsd: 2_000, dailyLossUsd: 10_000, maxPriceImpactPct: 10,
|
|
24
|
+
});
|
|
16
25
|
/** Confirmation tiers and the multiplier they apply to a mandate's per-trade cap for plans confirmed that way. */
|
|
17
26
|
export const TIERS = Object.freeze({
|
|
18
27
|
'keypress': { rank: 0, perTradeMultiplier: 1.0, canArmMandate: true, canArmBatch: true },
|
|
@@ -37,6 +46,8 @@ function clamp(v, dflt, hardMax) {
|
|
|
37
46
|
export function draftMandate(input, ctx) {
|
|
38
47
|
const x = input ?? {};
|
|
39
48
|
const now = ctx.clock.now();
|
|
49
|
+
const paper = x.paperOnly === true;
|
|
50
|
+
const H = paper ? PAPER_HARD : HARD;
|
|
40
51
|
const actions = Array.isArray(x.actions) ? x.actions.filter((a) => a === 'buy' || a === 'sell') : ['buy', 'sell'];
|
|
41
52
|
if (actions.length === 0)
|
|
42
53
|
throw new MandateError('bad-request', 'at least one of buy/sell');
|
|
@@ -45,23 +56,23 @@ export function draftMandate(input, ctx) {
|
|
|
45
56
|
id: ctx.id ?? `mandate_${now.toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
46
57
|
wallet: 'fast', // the capped fast wallet; the main wallet is never named here
|
|
47
58
|
budget: {
|
|
48
|
-
totalUsd: clamp(x.totalUsd, 100,
|
|
49
|
-
perTradeUsd: clamp(x.perTradeUsd, 20,
|
|
50
|
-
perCoinUsd: clamp(x.perCoinUsd, 40,
|
|
51
|
-
dailyLossUsd: clamp(x.dailyLossUsd, 50,
|
|
59
|
+
totalUsd: clamp(x.totalUsd, 100, H.totalUsd),
|
|
60
|
+
perTradeUsd: clamp(x.perTradeUsd, 20, H.perTradeUsd),
|
|
61
|
+
perCoinUsd: clamp(x.perCoinUsd, 40, H.perCoinUsd),
|
|
62
|
+
dailyLossUsd: clamp(x.dailyLossUsd, 50, H.dailyLossUsd),
|
|
52
63
|
},
|
|
53
64
|
limits: {
|
|
54
|
-
maxArmedPlans: clamp(x.maxArmedPlans, 20,
|
|
55
|
-
maxInFlight: clamp(x.maxInFlight, 5,
|
|
56
|
-
maxOpenPositions: clamp(x.maxOpenPositions, 20,
|
|
57
|
-
maxSlippageBps: clamp(x.maxSlippageBps, 500,
|
|
58
|
-
maxPriceImpactPct: clamp(x.maxPriceImpactPct, 3,
|
|
59
|
-
maxSendsPerSec: clamp(x.maxSendsPerSec, 5,
|
|
65
|
+
maxArmedPlans: clamp(x.maxArmedPlans, 20, H.maxArmedPlans),
|
|
66
|
+
maxInFlight: clamp(x.maxInFlight, 5, H.maxInFlight),
|
|
67
|
+
maxOpenPositions: clamp(x.maxOpenPositions, 20, H.maxOpenPositions),
|
|
68
|
+
maxSlippageBps: clamp(x.maxSlippageBps, 500, H.maxSlippageBps),
|
|
69
|
+
maxPriceImpactPct: clamp(x.maxPriceImpactPct, 3, H.maxPriceImpactPct),
|
|
70
|
+
maxSendsPerSec: clamp(x.maxSendsPerSec, 5, H.maxSendsPerSec),
|
|
60
71
|
},
|
|
61
72
|
fees: {
|
|
62
|
-
tipBudgetLamports: clamp(x.tipBudgetLamports, 50_000_000,
|
|
63
|
-
maxTipLamportsPerTx: clamp(x.maxTipLamportsPerTx, 1_000_000,
|
|
64
|
-
maxPriorityFeeLamportsPerTx: clamp(x.maxPriorityFeeLamportsPerTx, 2_000_000,
|
|
73
|
+
tipBudgetLamports: clamp(x.tipBudgetLamports, 50_000_000, H.tipBudgetLamports),
|
|
74
|
+
maxTipLamportsPerTx: clamp(x.maxTipLamportsPerTx, 1_000_000, H.maxTipLamportsPerTx),
|
|
75
|
+
maxPriorityFeeLamportsPerTx: clamp(x.maxPriorityFeeLamportsPerTx, 2_000_000, H.maxPriorityFeeLamportsPerTx),
|
|
65
76
|
},
|
|
66
77
|
allowed: {
|
|
67
78
|
actions,
|
|
@@ -70,8 +81,9 @@ export function draftMandate(input, ctx) {
|
|
|
70
81
|
},
|
|
71
82
|
maxTier: (typeof x.maxTier === 'string' && x.maxTier in TIERS) ? x.maxTier : 'keypress',
|
|
72
83
|
createdAt: now,
|
|
73
|
-
expiresAt: now + clamp(x.ttlMs, 4 * 3600_000,
|
|
84
|
+
expiresAt: now + clamp(x.ttlMs, 4 * 3600_000, H.ttlMs),
|
|
74
85
|
status: 'draft', armedAt: null, armedBy: null, revokedAt: null, revokedReason: null,
|
|
86
|
+
...(paper ? { paperOnly: true } : {}),
|
|
75
87
|
};
|
|
76
88
|
// perTrade can never exceed total; perCoin never exceeds total.
|
|
77
89
|
m.budget.perTradeUsd = Math.min(m.budget.perTradeUsd, m.budget.totalUsd);
|
|
@@ -117,7 +129,8 @@ export function revokeMandate(m, reason, ctx) {
|
|
|
117
129
|
}
|
|
118
130
|
/** Hash of the caps a gesture arms — the proof binds to these numbers, not to the mandate id alone. */
|
|
119
131
|
export function capsHash(m) {
|
|
120
|
-
|
|
132
|
+
// task-1469: the paper-only flag is part of what a gesture arms; a normal mandate hashes exactly as before.
|
|
133
|
+
return createHash('sha256').update(JSON.stringify({ b: m.budget, l: m.limits, f: m.fees, a: m.allowed, t: m.maxTier, e: m.expiresAt, w: m.wallet, ...(m.paperOnly === true ? { p: 1 } : {}) })).digest('hex').slice(0, 16);
|
|
121
134
|
}
|
|
122
135
|
/** Alive = armed and not expired. Expiry is checked on every call, never cached. */
|
|
123
136
|
export function mandateAlive(m, now) {
|
|
@@ -149,7 +149,7 @@ export class PlanService {
|
|
|
149
149
|
return {
|
|
150
150
|
id: x.m.id, status: x.m.status, wallet: x.m.wallet, totalUsd: x.m.budget.totalUsd, perTradeUsd: x.m.budget.perTradeUsd,
|
|
151
151
|
perCoinUsd: x.m.budget.perCoinUsd, maxTier: x.m.maxTier, reservedUsd: L.reservedUsd, spentUsd: L.spentUsd,
|
|
152
|
-
realizedUsd: L.realizedUsd, lossUsd: L.lossUsd, expiresAt: x.m.expiresAt,
|
|
152
|
+
realizedUsd: L.realizedUsd, lossUsd: L.lossUsd, expiresAt: x.m.expiresAt, paperOnly: x.m.paperOnly === true,
|
|
153
153
|
};
|
|
154
154
|
});
|
|
155
155
|
}
|
|
@@ -29,4 +29,4 @@ sources:
|
|
|
29
29
|
date: 2026-10-16
|
|
30
30
|
---
|
|
31
31
|
|
|
32
|
-
The token screen's ON-CHAIN panel shows the creator's recorded track record (0.1.5): `Dev rugs
|
|
32
|
+
The token screen's ON-CHAIN panel shows the creator's recorded track record (0.1.5): `Dev rugs` (rugs out of labelled prior launches, e.g. `2/3 · n=3 · rec 7d`), `Dev launches` (`n=3 · rec 2h · dev:jup`: which feed named the creator), `Dev sold bag` (median minutes to the dev's exit, `med 20m · n=3 · rec 1d`), `Dev linked` (funder or cluster from the wallet-intel scan, with the scan's age) and `Linked dev rugs` (rugs by other wallets in that cluster). Every value names its source (`rec` = the recorder, `scan` = the wallet-intel scan), its sample (n=) and the age of its newest datum. It comes from dexterm's own local history recorder (keyless creator links + code-only outcome labels), so it says `unknown` when there is no record; it is a risk flag from observed history, never a certificate. Alert on it with the rule field `devrugs`, e.g. `when devrugs >= 2 then alert(warn, "new coin from a wallet that rugged before") scope newpairs`. Agents and the analyst use the `dev_history` MCP tool.
|
|
@@ -15,12 +15,16 @@ tags:
|
|
|
15
15
|
- domain
|
|
16
16
|
- github
|
|
17
17
|
- vet
|
|
18
|
+
- score
|
|
19
|
+
- x
|
|
20
|
+
- twitter
|
|
18
21
|
tools:
|
|
19
22
|
- larp_check
|
|
20
23
|
- get_links
|
|
21
24
|
- fetch_page
|
|
22
25
|
- check_domain
|
|
23
26
|
- check_github
|
|
27
|
+
- check_x
|
|
24
28
|
confidence: high
|
|
25
29
|
owner: maintainers
|
|
26
30
|
origin: app-help-generated
|
|
@@ -33,4 +37,4 @@ sources:
|
|
|
33
37
|
date: 2026-10-16
|
|
34
38
|
---
|
|
35
39
|
|
|
36
|
-
The LARP check vets whether a real product exists (website, domain age, GitHub) read-only — it is not a price call. Ask the analyst "is <coin> a larp?" (skill `larp-check`), or an agent can call the `larp_check` MCP tool. A
|
|
40
|
+
The LARP check vets whether a real product exists (website, domain age, GitHub, the X account) read-only — it is not a price call. Ask the analyst "is <coin> a larp?" (skill `larp-check`), or an agent can call the `larp_check` MCP tool. It returns a 0-100 LARP score: the sum of named flags with their points (low under 25, medium 25-49, high 50+), plus a list of what could not be read, which widens the range instead of lowering the score. A score is a summary of flags, never a certificate; it never recommends buy/sell.
|
|
@@ -32,6 +32,6 @@ sources:
|
|
|
32
32
|
date: 2026-10-16
|
|
33
33
|
---
|
|
34
34
|
|
|
35
|
-
Scan mode is an observe-only AI watcher (off by default, zero extra requests). Toggle it with `Z` (or `/scan on|off` on the screener line, or `--scan` at launch); `R` resumes a paused scan. It can only surface findings, never take a UI action or trade (structurally observe-only). The analyst can turn it on or off for you.
|
|
35
|
+
Scan mode is an observe-only AI watcher (off by default, zero extra requests). Toggle it with `Z` (or `/scan on|off` on the screener line, or `--scan` at launch); `R` resumes a paused scan. It can only surface findings, never take a UI action or trade (structurally observe-only). While it is on, the token screen's analyst panel shows its brain view: the scan log (locks, refreshes, releases, blocked actions) with the read streaming under it, the vetting checklist and the data strip. While it runs and you hold a paper or live position, a PnL chip shows it on the band under the header of every screen and on the chart popup; display only. The analyst can turn it on or off for you.
|
|
36
36
|
|
|
37
37
|
Keys: `Z`, `R`.
|
|
@@ -63,6 +63,7 @@ keys:
|
|
|
63
63
|
- T
|
|
64
64
|
- L
|
|
65
65
|
- C
|
|
66
|
+
- A
|
|
66
67
|
- I
|
|
67
68
|
- F
|
|
68
69
|
- Z
|
|
@@ -132,6 +133,7 @@ actions:
|
|
|
132
133
|
- open.tg
|
|
133
134
|
- open.links
|
|
134
135
|
- popup.chart
|
|
136
|
+
- display.chartAxis
|
|
135
137
|
- popup.token
|
|
136
138
|
- tape.toggle
|
|
137
139
|
- scan.toggle
|
|
@@ -218,6 +220,7 @@ Keys on this screen:
|
|
|
218
220
|
`T` — open TG
|
|
219
221
|
`L` — links
|
|
220
222
|
`C` — chart popup
|
|
223
|
+
`A` — chart axis mcap ⇄ price
|
|
221
224
|
`I` — token popup
|
|
222
225
|
`F` — flow ⇄ tape
|
|
223
226
|
`Z` — AI scan
|