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.
Files changed (93) hide show
  1. package/dist/packages/agents/src/index.js +2 -0
  2. package/dist/packages/agents/src/spectator.js +54 -0
  3. package/dist/packages/agents/src/story.js +523 -0
  4. package/dist/packages/agents/src/tiers.js +1 -0
  5. package/dist/packages/agents/src/tools.js +1 -0
  6. package/dist/packages/cli/assets/claude/plugin/skills/REGISTRY.json +1 -1
  7. package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +15 -8
  8. package/dist/packages/cli/src/agent-cmd.js +6 -4
  9. package/dist/packages/cli/src/agent-spectator.js +17 -1
  10. package/dist/packages/cli/src/analyst/backend.js +23 -0
  11. package/dist/packages/cli/src/analyst/larp-gauge.js +60 -0
  12. package/dist/packages/cli/src/analyst/session.js +12 -1
  13. package/dist/packages/cli/src/app-help-gen.js +7 -7
  14. package/dist/packages/cli/src/backtest-cmd.js +13 -2
  15. package/dist/packages/cli/src/display.js +55 -9
  16. package/dist/packages/cli/src/live/dev-history.js +31 -15
  17. package/dist/packages/cli/src/live/index.js +5 -1
  18. package/dist/packages/cli/src/live/screens/newpairs-live.js +1 -1
  19. package/dist/packages/cli/src/live/screens/token-live.js +41 -11
  20. package/dist/packages/cli/src/live/screens/watchlist-live.js +1 -1
  21. package/dist/packages/cli/src/live/trade.js +22 -0
  22. package/dist/packages/cli/src/main.js +146 -12
  23. package/dist/packages/cli/src/mcp-follow.js +103 -6
  24. package/dist/packages/cli/src/plan-cmd.js +15 -5
  25. package/dist/packages/cli/src/popup.js +17 -1
  26. package/dist/packages/cli/src/scan/ui.js +7 -1
  27. package/dist/packages/cli/src/screens/agent-hud.js +741 -0
  28. package/dist/packages/cli/src/screens/analyst-session.js +4 -0
  29. package/dist/packages/cli/src/screens/brain.js +608 -0
  30. package/dist/packages/cli/src/screens/newpairs.js +1 -1
  31. package/dist/packages/cli/src/screens/pnl-chip.js +143 -0
  32. package/dist/packages/cli/src/screens/screener.js +1 -0
  33. package/dist/packages/cli/src/screens/token.js +16 -7
  34. package/dist/packages/cli/src/screens/trending.js +1 -1
  35. package/dist/packages/cli/src/state.js +69 -5
  36. package/dist/packages/cli/src/version.js +1 -1
  37. package/dist/packages/custom/src/schema.js +8 -2
  38. package/dist/packages/data/src/index.js +2 -0
  39. package/dist/packages/data/src/larp/score.js +220 -0
  40. package/dist/packages/data/src/larp/x-account.js +316 -0
  41. package/dist/packages/data/src/trade/agent/paper-desk.js +77 -12
  42. package/dist/packages/data/src/trade/plans/engine.js +7 -0
  43. package/dist/packages/data/src/trade/plans/mandate.js +28 -15
  44. package/dist/packages/data/src/trade/plans/service.js +1 -1
  45. package/dist/packages/mcp/assets/kb/app-help/dev-history.md +1 -1
  46. package/dist/packages/mcp/assets/kb/app-help/larp-check.md +5 -1
  47. package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +1 -1
  48. package/dist/packages/mcp/assets/kb/app-help/screen-token.md +3 -0
  49. package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +2 -1
  50. package/dist/packages/mcp/assets/kb/docs/docs-agents-agent-paper-trading.md +3 -2
  51. package/dist/packages/mcp/assets/kb/docs/docs-analyst-starting-a-run.md +2 -0
  52. package/dist/packages/mcp/assets/kb/docs/docs-configuration-chart-y-axis.md +32 -0
  53. package/dist/packages/mcp/assets/kb/docs/docs-configuration-price-display-mode.md +3 -3
  54. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
  55. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +1 -1
  56. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-running-the-tests.md +1 -1
  57. package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +1 -0
  58. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +60 -0
  59. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-verdict.md +0 -2
  60. package/dist/packages/mcp/assets/kb/docs/docs-larp-what-it-looks-at.md +4 -1
  61. package/dist/packages/mcp/assets/kb/docs/docs-larp.md +1 -1
  62. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-order-plans-and-standing-mandates.md +1 -9
  63. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-paper-only-mandates.md +41 -0
  64. package/dist/packages/mcp/assets/kb/docs/docs-popup-chart-market-cap-axis.md +1 -1
  65. package/dist/packages/mcp/assets/kb/docs/docs-popup-chart-what-it-shows.md +1 -0
  66. package/dist/packages/mcp/assets/kb/docs/docs-scan-the-read.md +2 -0
  67. package/dist/packages/mcp/assets/kb/docs/docs-screens-dev-history.md +10 -7
  68. package/dist/packages/mcp/assets/kb/docs/docs-spectator-follow-over-mcp-in-the-running-terminal.md +1 -1
  69. package/dist/packages/mcp/assets/kb/docs/docs-spectator-limits.md +2 -0
  70. package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-agent-hud-spectator-v2.md +58 -0
  71. package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-brain-view-and-the-pnl-chip.md +46 -0
  72. package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-three-surfaces.md +1 -1
  73. package/dist/packages/mcp/assets/kb/docs/docs-spectator.md +1 -1
  74. package/dist/packages/mcp/src/paper-agent.js +3 -2
  75. package/dist/packages/mcp/src/server.js +162 -21
  76. package/dist/packages/mcp/src/spectator-sink.js +85 -0
  77. package/dist/packages/packs/src/index.js +1 -0
  78. package/dist/packages/packs/src/larp-score-backtest.js +119 -0
  79. package/dist/packages/popup/src/pages/chart-lib.js +36 -15
  80. package/dist/packages/popup/src/pages/chart.css +9 -0
  81. package/dist/packages/popup/src/pages/chart.html +4 -1
  82. package/dist/packages/popup/src/pages/chart.js +34 -5
  83. package/dist/packages/popup/src/pages/spectator.css +45 -0
  84. package/dist/packages/popup/src/pages/spectator.html +1 -0
  85. package/dist/packages/popup/src/pages/spectator.js +69 -0
  86. package/dist/packages/popup/src/protocol.js +2 -0
  87. package/dist/packages/popup/src/server.js +5 -1
  88. package/dist/packages/recorder/src/dev-history.js +7 -3
  89. package/dist/packages/tui/src/canvas.js +7 -0
  90. package/dist/packages/tui/src/fmt.js +53 -0
  91. package/dist/packages/tui/src/keymap.js +4 -0
  92. package/dist/packages/tui/src/widgets.js +9 -4
  93. 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
- let quote;
271
- try {
272
- quote = await this.quote(buildQuoteRequest(order));
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, HARD.totalUsd),
49
- perTradeUsd: clamp(x.perTradeUsd, 20, HARD.perTradeUsd),
50
- perCoinUsd: clamp(x.perCoinUsd, 40, HARD.perCoinUsd),
51
- dailyLossUsd: clamp(x.dailyLossUsd, 50, HARD.dailyLossUsd),
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, HARD.maxArmedPlans),
55
- maxInFlight: clamp(x.maxInFlight, 5, HARD.maxInFlight),
56
- maxOpenPositions: clamp(x.maxOpenPositions, 20, HARD.maxOpenPositions),
57
- maxSlippageBps: clamp(x.maxSlippageBps, 500, HARD.maxSlippageBps),
58
- maxPriceImpactPct: clamp(x.maxPriceImpactPct, 3, HARD.maxPriceImpactPct),
59
- maxSendsPerSec: clamp(x.maxSendsPerSec, 5, HARD.maxSendsPerSec),
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, HARD.tipBudgetLamports),
63
- maxTipLamportsPerTx: clamp(x.maxTipLamportsPerTx, 1_000_000, HARD.maxTipLamportsPerTx),
64
- maxPriorityFeeLamportsPerTx: clamp(x.maxPriorityFeeLamportsPerTx, 2_000_000, HARD.maxPriorityFeeLamportsPerTx),
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, HARD.ttlMs),
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
- 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 })).digest('hex').slice(0, 16);
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 (recorded)` (rugs out of labelled prior launches, with the sample n), `Dev launches seen`, `Dev sold its bag` (median minutes to the dev's exit) and `Dev linked` (funder or cluster from the wallet-intel scan). 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.
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 grade is a risk flag, never a certificate; it never recommends buy/sell.
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