dexterm-cli 0.2.1 → 0.2.2
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/autotrade/src/chat/connector.js +5 -4
- package/dist/packages/autotrade/src/chat/control.js +3 -0
- package/dist/packages/autotrade/src/chat/hub.js +37 -2
- package/dist/packages/autotrade/src/chat/index.js +3 -2
- package/dist/packages/autotrade/src/chat/readonly.js +530 -0
- package/dist/packages/autotrade/src/chat/service.js +12 -1
- package/dist/packages/autotrade/src/engine.js +75 -29
- package/dist/packages/autotrade/src/index.js +2 -0
- package/dist/packages/autotrade/src/runner.js +72 -14
- package/dist/packages/autotrade/src/store.js +1 -1
- package/dist/packages/autotrade/src/strategy.js +17 -0
- package/dist/packages/autotrade/src/tiers.js +194 -0
- package/dist/packages/classifier/src/config.js +90 -12
- package/dist/packages/classifier/src/index.js +1 -0
- package/dist/packages/classifier/src/win-acl.js +71 -0
- package/dist/packages/cli/src/app-help-gen.js +1 -1
- package/dist/packages/cli/src/autotrade-cmd/fleet.js +4 -2
- package/dist/packages/cli/src/autotrade-cmd/run.js +4 -2
- package/dist/packages/cli/src/autotrade-cmd/service.js +3 -1
- package/dist/packages/cli/src/autotrade-cmd/strategies.js +1 -1
- package/dist/packages/cli/src/autotrade-cmd/telegram-host.js +2 -0
- package/dist/packages/cli/src/classifier-cmd.js +16 -8
- package/dist/packages/cli/src/skill-eval-build.js +7 -2
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/mcp/assets/kb/app-help/autotrade-telegram.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-confidence-tiers-experimental-unproven-paper-only.md +56 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-hosted-jev-with-your-own-key.md +3 -1
- 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-narratives-where-to-find-it.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-read-only-data-check-top-and-signals-experimental.md +48 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-will-not-do.md +1 -1
- package/dist/packages/mcp/src/server.js +2 -1
- package/dist/packages/packs/src/skill-care.js +10 -2
- package/package.json +1 -1
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// dexterm autotrade chat — the read-only data commands /check, /top and /signals (task-1426).
|
|
2
|
+
//
|
|
3
|
+
// Three commands that only READ, for the paired account in its private chat (the hub's allowlist, chat binding and
|
|
4
|
+
// rate limit apply as for /status):
|
|
5
|
+
// /check <CA> one coin: the LARP score (the `larp_check` tool's 0-100 sum of named flags) and the key risk facts
|
|
6
|
+
// (authorities, holder concentration, LP lock, liquidity), each with the age of its data
|
|
7
|
+
// /top [N] the first N (default 5, 1..10) of the screener's trending list, marking the runner's own coins
|
|
8
|
+
// /signals [N] the last N (default 5, 1..20) signals the runner raised: screen matches, safety refusals, the drain
|
|
9
|
+
// guard, stuck bags, guards, halts and fills
|
|
10
|
+
// Every answer says "data, not advice". No write path: the router is built from a ReadOnlyPort only (it never sees
|
|
11
|
+
// the ControlPort or the approval broker, and it returns lines, never a confirm button), and the host's port can
|
|
12
|
+
// call only the read tools in READ_TOOLS. Token text (symbol, name) is untrusted: inert() makes it plain text with no
|
|
13
|
+
// tappable command, mention or link; a CA is shown only after it validated as base58, and only shortened.
|
|
14
|
+
import { hostname, userInfo } from 'node:os';
|
|
15
|
+
import { hideSecrets, inert, pct, shortMint, usdShort } from "./format.js";
|
|
16
|
+
/** The read-only commands (also in the connector's closed list). */
|
|
17
|
+
export const READ_COMMANDS = ['check', 'top', 'signals'];
|
|
18
|
+
/** The only data tools the host's port may call (the MCP tool layer's read tools). */
|
|
19
|
+
export const READ_TOOLS = ['get_token', 'larp_check', 'screener_query'];
|
|
20
|
+
export const TOP_DEFAULT = 5;
|
|
21
|
+
export const TOP_MAX = 10;
|
|
22
|
+
export const SIGNALS_DEFAULT = 5;
|
|
23
|
+
export const SIGNALS_MAX = 20;
|
|
24
|
+
/** Signals the bot keeps in memory (newest last). */
|
|
25
|
+
export const SIGNAL_LOG_KEEP = 200;
|
|
26
|
+
/** A Solana mint: base58 (no 0, O, I, l), 32 to 44 characters. */
|
|
27
|
+
export const MINT_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
28
|
+
/** task-2146: one answer stays short (Telegram's own cap is 4096; the transport clips at 3500 as a last fence). */
|
|
29
|
+
export const READ_REPLY_MAX_CHARS = 1800;
|
|
30
|
+
export const READ_REPLY_MAX_LINES = 26;
|
|
31
|
+
/** task-2146: an answer that takes longer than this says so instead of hanging (the per-call limits are shorter). */
|
|
32
|
+
export const READ_TIMEOUT_MS = 45_000;
|
|
33
|
+
/** task-2146: answers that call the data sources, per chat per minute (the hub's 10 a minute per sender also applies). */
|
|
34
|
+
export const READS_PER_MIN = 5;
|
|
35
|
+
export const NOT_ADVICE = 'data, not advice';
|
|
36
|
+
export const USAGE_CHECK = "Usage: /check <CA> (the coin's mint address: 32 to 44 base58 characters). Read-only; data, not advice.";
|
|
37
|
+
export const USAGE_TOP = `Usage: /top [N] (N from 1 to ${TOP_MAX}, default ${TOP_DEFAULT}). Read-only; data, not advice.`;
|
|
38
|
+
export const USAGE_SIGNALS = `Usage: /signals [N] (N from 1 to ${SIGNALS_MAX}, default ${SIGNALS_DEFAULT}). Read-only; data, not advice.`;
|
|
39
|
+
const isUnavailable = (v) => !!v && typeof v === 'object' && typeof v.unavailable === 'string';
|
|
40
|
+
/** "45s", "12m", "3h", "2d". */
|
|
41
|
+
export function ageShort(ms) {
|
|
42
|
+
if (ms === null || ms === undefined || !Number.isFinite(ms))
|
|
43
|
+
return '?';
|
|
44
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
45
|
+
if (s < 60)
|
|
46
|
+
return `${s}s`;
|
|
47
|
+
if (s < 3600)
|
|
48
|
+
return `${Math.round(s / 60)}m`;
|
|
49
|
+
if (s < 48 * 3600)
|
|
50
|
+
return `${Math.round(s / 3600)}h`;
|
|
51
|
+
return `${Math.round(s / 86400)}d`;
|
|
52
|
+
}
|
|
53
|
+
const old = (at, now) => (at === null ? 'age unknown' : `${ageShort(now - at)} old`);
|
|
54
|
+
/** `[]` → def, `['7']` → 7 clamped to 1..max, anything else → null (the usage line). */
|
|
55
|
+
export function parseCount(args, def, max) {
|
|
56
|
+
if (args.length === 0)
|
|
57
|
+
return def;
|
|
58
|
+
if (args.length > 1 || !/^\d{1,4}$/.test(args[0]))
|
|
59
|
+
return null;
|
|
60
|
+
return Math.min(max, Math.max(1, Number(args[0])));
|
|
61
|
+
}
|
|
62
|
+
const yesNo = (v, on, off) => (v === null ? `${off.split(' ')[0]} authority unknown` : v ? on : off);
|
|
63
|
+
const pctPlain = (v) => (v === null || !Number.isFinite(v) ? '?' : `${v.toFixed(1)}%`);
|
|
64
|
+
// ───────────────────────────── outgoing text (task-2146) ─────────────────────────────
|
|
65
|
+
/**
|
|
66
|
+
* Characters that render as nothing (or flip text direction) and that the prompt firewall does not strip: soft hyphen,
|
|
67
|
+
* combining grapheme joiner, Arabic letter mark, Hangul fillers, Mongolian separators, word joiners and invisible
|
|
68
|
+
* operators, the deprecated format characters, interlinear annotation marks and the tag block.
|
|
69
|
+
*/
|
|
70
|
+
const INVISIBLE = /[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180F\u2060-\u2064\u206A-\u206F\u3164\uFFA0\uFFF9-\uFFFB]|[\u{E0000}-\u{E007F}]/gu;
|
|
71
|
+
/** Half of a surrogate pair (a cut emoji): Telegram refuses the whole message when one is in it. */
|
|
72
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
73
|
+
/**
|
|
74
|
+
* On top of inert(): what Telegram still turns into a tappable thing in a plain-text message. A bare domain
|
|
75
|
+
* ("claim-now.xyz", "t.me") gets a one-dot leader instead of its dot, a #hashtag and a $CASHTAG get look-alikes.
|
|
76
|
+
*/
|
|
77
|
+
export function defuse(s) {
|
|
78
|
+
return s
|
|
79
|
+
.replace(INVISIBLE, '')
|
|
80
|
+
.replace(/([\p{L}\p{N}_-])\.(?=\p{L})/gu, '$1\u2024')
|
|
81
|
+
.replace(/#/g, '\uFF03')
|
|
82
|
+
.replace(/\$(?=\p{L})/gu, '\uFF04')
|
|
83
|
+
.replace(LONE_SURROGATE, '');
|
|
84
|
+
}
|
|
85
|
+
/** Token or engine text for a read-only answer: inert() (secrets, paths, the prompt firewall, the cap) then defuse(). */
|
|
86
|
+
export const tx = (text, max = 32, source = 'token') => defuse(inert(text, max, source));
|
|
87
|
+
/** This computer's names (host, short host, login), which a read-only answer never shows. */
|
|
88
|
+
export function localNames() {
|
|
89
|
+
const out = [];
|
|
90
|
+
try {
|
|
91
|
+
const h = hostname();
|
|
92
|
+
out.push(h, h.split('.')[0]);
|
|
93
|
+
}
|
|
94
|
+
catch { /* no host name */ }
|
|
95
|
+
try {
|
|
96
|
+
out.push(userInfo().username);
|
|
97
|
+
}
|
|
98
|
+
catch { /* no user */ }
|
|
99
|
+
return [...new Set(out.filter((x) => typeof x === 'string' && x.length >= 4 && !/^(localhost|root|user)$/i.test(x)))];
|
|
100
|
+
}
|
|
101
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
102
|
+
/** Cut at a code point, never inside a surrogate pair. */
|
|
103
|
+
function cut(s, max) {
|
|
104
|
+
if (s.length <= max)
|
|
105
|
+
return s;
|
|
106
|
+
let end = Math.max(1, max - 1);
|
|
107
|
+
if (/[\uD800-\uDBFF]/.test(s[end - 1] ?? ''))
|
|
108
|
+
end--;
|
|
109
|
+
return `${s.slice(0, end)}…`;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* task-2180: dexterm's own fixed wording in the read-only answers. A computer name can be a plain word the bot itself
|
|
113
|
+
* uses (GitHub's runners log in as `runner`; a login `data` or `check` is possible too). Only for such a name, an
|
|
114
|
+
* exact fragment below that stands between prose boundaries is left as written, so "the runner holds this coin now"
|
|
115
|
+
* stays readable. Everywhere else (token text, engine and source text, errors, paths, host names) the name is hidden
|
|
116
|
+
* as before, in every case variant; a name that is not part of this wording is hidden everywhere, unchanged.
|
|
117
|
+
* A fragment missing here only means the bot's own word shows as [host]: it never shows a name.
|
|
118
|
+
*/
|
|
119
|
+
export const OWN_WORDING = [
|
|
120
|
+
NOT_ADVICE, 'Data, not advice', USAGE_CHECK, USAGE_TOP, USAGE_SIGNALS,
|
|
121
|
+
'The LARP score is evidence, not a verdict, and not a safety check',
|
|
122
|
+
'Too many data requests: up to', 'a minute in this chat', '/kill and /pause always work',
|
|
123
|
+
'A /check is still running: wait for its answer',
|
|
124
|
+
'Could not read this coin right now', 'Could not read the ranking right now', 'Could not read the signals right now',
|
|
125
|
+
'Nothing was guessed; try again in a minute',
|
|
126
|
+
'CHECK', 'LARP score', 'LARP score: could not be read', 'checks unread', 'check unread', 'checked',
|
|
127
|
+
'risk:', 'risk: could not be read', 'risk: the sources reported no holder/authority data for this coin',
|
|
128
|
+
'mint authority ON', 'mint authority revoked', 'mint authority unknown', 'freeze authority ON', 'freeze authority revoked', 'freeze authority unknown',
|
|
129
|
+
'top-10 holders', 'LP locked', 'rug score', 'age unknown',
|
|
130
|
+
'market: mcap', 'market: could not be read', 'liquidity', 'holders', 'coin age',
|
|
131
|
+
'the runner holds this coin now (/positions)', 'a candidate of the runner now',
|
|
132
|
+
'TOP', 'Nothing ranked right now', '/check <CA> for one coin', '1h vol', 'HELD', 'candidate',
|
|
133
|
+
'SIGNALS', 'newest first', 'No signals yet: nothing matched the screen or tripped a check since the bot came on',
|
|
134
|
+
"the screener's trending list", 'the screener returned nothing: offline, or the sources did not answer',
|
|
135
|
+
'the screener did not answer', 'no live screener here', "the runner's own coins instead", 'No ranking right now',
|
|
136
|
+
'No signals: no run has started since the bot came on',
|
|
137
|
+
'SCREEN', 'SAFETY', 'DRAIN', 'STUCK', 'WRITTEN OFF', 'GUARD', 'HALT', 'SELL', 'BUY',
|
|
138
|
+
'matched the screen', 'refused by', 'cannot sell', 'closed at realizable value', 'halted', '(paper book)',
|
|
139
|
+
];
|
|
140
|
+
// NO_LIVE_DATA is declared with the host side below, so the wording list is completed on first use.
|
|
141
|
+
let ownWording = null;
|
|
142
|
+
let ownRe = null;
|
|
143
|
+
const allOwnWording = () => (ownWording ??= [...OWN_WORDING, NO_LIVE_DATA]);
|
|
144
|
+
function ownWordingRe() {
|
|
145
|
+
// Prose boundaries only: a fragment glued to a host or path character ("runner.local", "runner-box", "∕runner∕")
|
|
146
|
+
// is not the bot's wording.
|
|
147
|
+
ownRe ??= new RegExp(`(?<=^|[\\s(\\["'·])(?:${[...allOwnWording()].sort((a, b) => b.length - a.length).map(escapeRe).join('|')})(?=$|[\\s)\\],;!?"'·…]|[.:](?:\\s|$))`, 'gu');
|
|
148
|
+
return ownRe;
|
|
149
|
+
}
|
|
150
|
+
/** `re` replaced with [host] everywhere in `s` except inside the bot's own wording (OWN_WORDING). */
|
|
151
|
+
function hideOutsideOwnWording(s, re) {
|
|
152
|
+
let out = '';
|
|
153
|
+
let at = 0;
|
|
154
|
+
for (const m of s.matchAll(ownWordingRe())) {
|
|
155
|
+
out += s.slice(at, m.index).replace(re, '[host]') + m[0];
|
|
156
|
+
at = m.index + m[0].length;
|
|
157
|
+
}
|
|
158
|
+
return out + s.slice(at).replace(re, '[host]');
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The last fence on every read-only answer: secrets and paths hidden again (an error text from a source can carry
|
|
162
|
+
* either), this computer's names replaced, invisible characters and cut surrogates dropped, and the answer capped
|
|
163
|
+
* in lines and characters (a "(cut)" line says so).
|
|
164
|
+
*/
|
|
165
|
+
export function finalizeReply(lines, names) {
|
|
166
|
+
const nameRes = names.filter((n) => typeof n === 'string' && n.length > 0).map((n) => ({
|
|
167
|
+
re: new RegExp(escapeRe(n), 'gi'),
|
|
168
|
+
own: allOwnWording().some((w) => w.toLowerCase().includes(n.toLowerCase())),
|
|
169
|
+
}));
|
|
170
|
+
const clean = lines.map((l) => {
|
|
171
|
+
let s = hideSecrets(String(l)).replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, '[url]');
|
|
172
|
+
for (const { re, own } of nameRes)
|
|
173
|
+
s = own ? hideOutsideOwnWording(s, re) : s.replace(re, '[host]');
|
|
174
|
+
return cut(defuse(s), 400);
|
|
175
|
+
});
|
|
176
|
+
const out = [];
|
|
177
|
+
let used = 0;
|
|
178
|
+
for (const l of clean) {
|
|
179
|
+
if (out.length >= READ_REPLY_MAX_LINES - 1 || used + l.length + 1 > READ_REPLY_MAX_CHARS - 40) {
|
|
180
|
+
out.push('(cut: the answer was too long for one message)');
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
out.push(l);
|
|
184
|
+
used += l.length + 1;
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
export class ReadOnlyRouter {
|
|
189
|
+
port;
|
|
190
|
+
now;
|
|
191
|
+
names;
|
|
192
|
+
timeoutMs;
|
|
193
|
+
/** Chats with a /check in flight (one at a time per chat). */
|
|
194
|
+
checking = new Set();
|
|
195
|
+
readTimes = new Map();
|
|
196
|
+
limitTold = new Map();
|
|
197
|
+
constructor(port, now = Date.now, opts = {}) {
|
|
198
|
+
this.port = port;
|
|
199
|
+
this.now = now;
|
|
200
|
+
this.names = opts.hideNames ?? localNames();
|
|
201
|
+
this.timeoutMs = opts.timeoutMs ?? READ_TIMEOUT_MS;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Lines only: a read-only command never produces a button or a confirm. Every answer goes through finalizeReply.
|
|
205
|
+
* `limited`: the chat was over READS_PER_MIN (no data was read; `lines` is the once-a-minute notice, or empty).
|
|
206
|
+
*/
|
|
207
|
+
async handle(c) {
|
|
208
|
+
const r = await this.route(c);
|
|
209
|
+
const lines = finalizeReply(r.lines, this.names);
|
|
210
|
+
return { ...r, lines, ...(r.failed ? { failed: lines[0] ?? '' } : {}) };
|
|
211
|
+
}
|
|
212
|
+
/** Count one data read for the chat; the answer when the chat is over its limit (null = go ahead). */
|
|
213
|
+
overLimit(chatId) {
|
|
214
|
+
const now = this.now();
|
|
215
|
+
const ts = (this.readTimes.get(chatId) ?? []).filter((t) => now - t < 60_000);
|
|
216
|
+
if (ts.length < READS_PER_MIN) {
|
|
217
|
+
ts.push(now);
|
|
218
|
+
this.readTimes.set(chatId, ts);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
this.readTimes.set(chatId, ts);
|
|
222
|
+
if (now - (this.limitTold.get(chatId) ?? -Infinity) < 60_000)
|
|
223
|
+
return { lines: [], limited: true };
|
|
224
|
+
this.limitTold.set(chatId, now);
|
|
225
|
+
return { lines: [`Too many data requests: up to ${READS_PER_MIN} a minute in this chat. /kill and /pause always work.`], limited: true };
|
|
226
|
+
}
|
|
227
|
+
route(c) {
|
|
228
|
+
switch (c.command) {
|
|
229
|
+
case 'check': return this.check(c);
|
|
230
|
+
case 'top': return this.top(c);
|
|
231
|
+
case 'signals': return this.signals(c);
|
|
232
|
+
default: return Promise.resolve({ lines: [USAGE_CHECK] });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** A port answer within the router's limit; a late or failed one becomes an Unavailable that says what was not read. */
|
|
236
|
+
async ask(what, p) {
|
|
237
|
+
try {
|
|
238
|
+
return await within(Promise.resolve().then(p), this.timeoutMs, what);
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
return { unavailable: `Could not read ${what} right now (${errOf(e)}). Nothing was guessed; try again in a minute.`, failed: true };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async check(c) {
|
|
245
|
+
// The CA is never echoed unless it validated; an invalid one gets the usage line only.
|
|
246
|
+
if (c.args.length !== 1 || !MINT_RE.test(c.args[0]))
|
|
247
|
+
return { lines: [USAGE_CHECK] };
|
|
248
|
+
const mint = c.args[0];
|
|
249
|
+
if (this.checking.has(c.chatId))
|
|
250
|
+
return { lines: ['A /check is still running: wait for its answer.'] };
|
|
251
|
+
const lim = this.overLimit(c.chatId);
|
|
252
|
+
if (lim)
|
|
253
|
+
return lim;
|
|
254
|
+
this.checking.add(c.chatId);
|
|
255
|
+
let v;
|
|
256
|
+
try {
|
|
257
|
+
v = await this.ask('this coin', () => this.port.check(mint));
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
this.checking.delete(c.chatId);
|
|
261
|
+
}
|
|
262
|
+
if (isUnavailable(v))
|
|
263
|
+
return { lines: [v.unavailable], ...(v.failed ? { failed: v.unavailable } : {}) };
|
|
264
|
+
const now = this.now();
|
|
265
|
+
const sym = v.symbol ? `"${tx(v.symbol, 20)}" ` : '';
|
|
266
|
+
const name = v.name ? ` · "${tx(v.name, 32)}"` : '';
|
|
267
|
+
const lines = [`CHECK ${sym}${shortMint(v.mint)}${name} · ${NOT_ADVICE}`];
|
|
268
|
+
if (v.larp) {
|
|
269
|
+
const range = v.larp.hi !== null && v.larp.hi > v.larp.score ? ` · up to ${v.larp.hi} with ${v.larp.unread} check${v.larp.unread === 1 ? '' : 's'} unread` : '';
|
|
270
|
+
lines.push(`LARP score ${v.larp.score}/100 (${tx(v.larp.band, 12, 'engine')})${range} · checked ${ageShort(now - v.at)} ago`);
|
|
271
|
+
for (const f of v.larp.flags.slice(0, 3))
|
|
272
|
+
lines.push(` · ${tx(f.label, 70, 'engine')} (+${f.points})`);
|
|
273
|
+
}
|
|
274
|
+
else
|
|
275
|
+
lines.push(`LARP score: could not be read${v.larpError ? ` (${tx(v.larpError, 80, 'engine')})` : ''}`);
|
|
276
|
+
if (v.audit) {
|
|
277
|
+
const a = v.audit;
|
|
278
|
+
lines.push(`risk: ${yesNo(a.mintAuth, 'mint authority ON', 'mint authority revoked')} · ${yesNo(a.freezeAuth, 'freeze authority ON', 'freeze authority revoked')} · top-10 holders ${pctPlain(a.top10Pct)} · dev ${pctPlain(a.devPct)} · LP locked ${pctPlain(a.lpLockedPct)}${a.rugScore !== null ? ` · rug score ${Math.round(a.rugScore)}` : ''} (${old(a.fetchedAt, now)})`);
|
|
279
|
+
}
|
|
280
|
+
else
|
|
281
|
+
lines.push(v.dataError ? `risk: could not be read (${tx(v.dataError, 80, 'engine')})` : 'risk: the sources reported no holder/authority data for this coin');
|
|
282
|
+
if (v.market) {
|
|
283
|
+
const m = v.market;
|
|
284
|
+
lines.push(`market: mcap ${usdShort(m.mcap)} · liquidity ${usdShort(m.liq)}${m.holders !== null ? ` · ${m.holders} holders` : ''}${m.tokenAgeSec !== null ? ` · coin age ${ageShort(m.tokenAgeSec * 1000)}` : ''} (${old(m.fetchedAt, now)})`);
|
|
285
|
+
}
|
|
286
|
+
else
|
|
287
|
+
lines.push(`market: could not be read${v.dataError ? ` (${tx(v.dataError, 80, 'engine')})` : ''}`);
|
|
288
|
+
if (v.engine)
|
|
289
|
+
lines.push(v.engine === 'held' ? 'the runner holds this coin now (/positions)' : 'a candidate of the runner now');
|
|
290
|
+
lines.push('The LARP score is evidence, not a verdict, and not a safety check. Data, not advice.');
|
|
291
|
+
return { lines };
|
|
292
|
+
}
|
|
293
|
+
async top(c) {
|
|
294
|
+
const n = parseCount(c.args, TOP_DEFAULT, TOP_MAX);
|
|
295
|
+
if (n === null)
|
|
296
|
+
return { lines: [USAGE_TOP] };
|
|
297
|
+
const lim = this.overLimit(c.chatId);
|
|
298
|
+
if (lim)
|
|
299
|
+
return lim;
|
|
300
|
+
const v = await this.ask('the ranking', () => this.port.top(n));
|
|
301
|
+
if (isUnavailable(v))
|
|
302
|
+
return { lines: [v.unavailable], ...(v.failed ? { failed: v.unavailable } : {}) };
|
|
303
|
+
if (!v.rows.length)
|
|
304
|
+
return { lines: [`Nothing ranked right now (${tx(v.source, 80, 'engine')}).`] };
|
|
305
|
+
const now = this.now();
|
|
306
|
+
const ages = v.rows.map((r) => r.fetchedAt).filter((x) => x !== null);
|
|
307
|
+
const oldest = ages.length ? Math.min(...ages) : null;
|
|
308
|
+
const rows = v.rows.slice(0, n);
|
|
309
|
+
return {
|
|
310
|
+
lines: [
|
|
311
|
+
`TOP ${rows.length} · ${tx(v.source, 80, 'engine')} (${old(oldest, now)}) · ${NOT_ADVICE}`,
|
|
312
|
+
...rows.map((r, i) => `${i + 1}. ${r.symbol ? `"${tx(r.symbol, 16)}" ` : ''}${shortMint(r.mint)} · mcap ${usdShort(r.mcap)} · liq ${usdShort(r.liq)}${r.volH1 !== null ? ` · 1h vol ${usdShort(r.volH1)}` : ''}${r.chgH1 !== null ? ` · 1h ${pct(r.chgH1)}` : ''}${r.tokenAgeSec !== null ? ` · age ${ageShort(r.tokenAgeSec * 1000)}` : ''}${r.engine === 'held' ? ' · HELD' : r.engine === 'candidate' ? ' · candidate' : ''}`),
|
|
313
|
+
'/check <CA> for one coin.',
|
|
314
|
+
],
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
async signals(c) {
|
|
318
|
+
const n = parseCount(c.args, SIGNALS_DEFAULT, SIGNALS_MAX);
|
|
319
|
+
if (n === null)
|
|
320
|
+
return { lines: [USAGE_SIGNALS] };
|
|
321
|
+
const lim = this.overLimit(c.chatId);
|
|
322
|
+
if (lim)
|
|
323
|
+
return lim;
|
|
324
|
+
const v = await this.ask('the signals', () => this.port.signals(n));
|
|
325
|
+
if (isUnavailable(v))
|
|
326
|
+
return { lines: [v.unavailable], ...(v.failed ? { failed: v.unavailable } : {}) };
|
|
327
|
+
if (!v.rows.length)
|
|
328
|
+
return { lines: ['No signals yet: nothing matched the screen or tripped a check since the bot came on.'] };
|
|
329
|
+
const now = this.now();
|
|
330
|
+
const rows = v.rows.slice(0, n);
|
|
331
|
+
return {
|
|
332
|
+
lines: [
|
|
333
|
+
`SIGNALS · last ${rows.length} of ${v.total} · newest first · ${NOT_ADVICE}`,
|
|
334
|
+
...rows.map((s) => `${ageShort(now - s.at)} ago · ${tx(s.title, 14, 'engine')}${s.strategy ? ` · ${tx(s.strategy, 24, 'engine')}` : ''} · ${s.symbol ? `"${tx(s.symbol, 16)}" ` : ''}${s.mint && MINT_RE.test(s.mint) ? `${shortMint(s.mint)} ` : ''}${tx(s.text, 120, 'engine')}`),
|
|
335
|
+
],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// ───────────────────────────── the host side ─────────────────────────────
|
|
340
|
+
/** Narrow a tool layer (the MCP tools a run already has) to the read tools; any other name throws. */
|
|
341
|
+
export function readOnlyCall(tools) {
|
|
342
|
+
return (tool, args) => {
|
|
343
|
+
if (!READ_TOOLS.includes(tool))
|
|
344
|
+
return Promise.reject(new Error(`not a read tool: ${String(tool).slice(0, 32)}`));
|
|
345
|
+
return tools.call(tool, args);
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
349
|
+
const bool = (v) => (typeof v === 'boolean' ? v : null);
|
|
350
|
+
const rec = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
|
|
351
|
+
const str = (v) => (typeof v === 'string' && v ? v : null);
|
|
352
|
+
/** The tool layer wraps token text as `<untrusted …>text</untrusted>` (escaped): back to the raw (still untrusted) text. */
|
|
353
|
+
export function untrustedText(v) {
|
|
354
|
+
const s = str(v);
|
|
355
|
+
if (!s)
|
|
356
|
+
return null;
|
|
357
|
+
const m = s.match(/^<untrusted [^>]*>([\s\S]*)<\/untrusted>$/);
|
|
358
|
+
if (!m)
|
|
359
|
+
return s;
|
|
360
|
+
return m[1].replace(/&(amp|lt|gt|quot);/g, (x) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[x] ?? x);
|
|
361
|
+
}
|
|
362
|
+
async function within(p, ms, what) {
|
|
363
|
+
// A plain (ref'd) timer, cleared in finally: a check that never answers still settles.
|
|
364
|
+
let timer;
|
|
365
|
+
try {
|
|
366
|
+
return await Promise.race([p, new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`${what}: no answer in ${Math.round(ms / 1000)} s`)), ms); })]);
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
clearTimeout(timer);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** An error as chat may show it: secrets and paths hidden, links and addresses replaced, capped (task-2146). */
|
|
373
|
+
const errOf = (e) => tx(e instanceof Error ? e.message : String(e), 120, 'engine');
|
|
374
|
+
/** The signals the runner raised, from its ledger as it is written (the bot's own copy, in memory). */
|
|
375
|
+
export class SignalLog {
|
|
376
|
+
rows = [];
|
|
377
|
+
symbols = new Map();
|
|
378
|
+
seen = 0;
|
|
379
|
+
keep;
|
|
380
|
+
constructor(keep = SIGNAL_LOG_KEEP) { this.keep = keep; }
|
|
381
|
+
get total() { return this.seen; }
|
|
382
|
+
push(s) {
|
|
383
|
+
this.rows.push(s);
|
|
384
|
+
this.seen++;
|
|
385
|
+
if (this.rows.length > this.keep)
|
|
386
|
+
this.rows.splice(0, this.rows.length - this.keep);
|
|
387
|
+
}
|
|
388
|
+
/** Newest first. */
|
|
389
|
+
recent(n) { return this.rows.slice(-Math.max(0, n)).reverse(); }
|
|
390
|
+
/** One run ledger event → a signal when it is one (screen match, safety refusal, drain, stuck, guard, halt, fill). */
|
|
391
|
+
onLedger(e) {
|
|
392
|
+
const mint = str(e.mint);
|
|
393
|
+
const sym = str(e.symbol);
|
|
394
|
+
if (mint && sym)
|
|
395
|
+
this.symbols.set(mint, sym);
|
|
396
|
+
if (this.symbols.size > 2000) {
|
|
397
|
+
const f = this.symbols.keys().next().value;
|
|
398
|
+
if (f !== undefined)
|
|
399
|
+
this.symbols.delete(f);
|
|
400
|
+
}
|
|
401
|
+
const symbol = sym ?? (mint ? this.symbols.get(mint) ?? null : null);
|
|
402
|
+
const strategy = str(e.strategyId);
|
|
403
|
+
const s = (level, title, text) => this.push({ at: e.t, level, title, text, mint, symbol, strategy });
|
|
404
|
+
const detail = (k) => { const v = e[k]; return v === undefined || v === null ? '' : String(v); };
|
|
405
|
+
switch (e.kind) {
|
|
406
|
+
case 'candidate': {
|
|
407
|
+
const m = Array.isArray(e.matched) ? e.matched.map(String).join(', ') : '';
|
|
408
|
+
s('info', 'SCREEN', `matched the screen${m ? `: ${m}` : ''}`);
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
case 'refused':
|
|
412
|
+
if (e.stage === 'vet')
|
|
413
|
+
s('warn', 'SAFETY', `refused by ${detail('rule')}${detail('detail') ? `: ${detail('detail')}` : ''}`);
|
|
414
|
+
break;
|
|
415
|
+
case 'drain_guard':
|
|
416
|
+
s('alert', 'DRAIN', `${detail('reason')}${detail('detail') ? `: ${detail('detail')}` : ''}`);
|
|
417
|
+
break;
|
|
418
|
+
case 'stuck':
|
|
419
|
+
s('warn', 'STUCK', `cannot sell${detail('why') ? ` (${detail('why')})` : ''}`);
|
|
420
|
+
break;
|
|
421
|
+
case 'written_off':
|
|
422
|
+
s('warn', 'WRITTEN OFF', detail('why') || 'closed at realizable value');
|
|
423
|
+
break;
|
|
424
|
+
case 'guard':
|
|
425
|
+
s('warn', 'GUARD', `${detail('state')}${detail('effect') ? `: ${detail('effect')}` : ''}`);
|
|
426
|
+
break;
|
|
427
|
+
case 'halt':
|
|
428
|
+
s('alert', 'HALT', detail('reason') || 'halted');
|
|
429
|
+
break;
|
|
430
|
+
case 'fill': {
|
|
431
|
+
const side = detail('side');
|
|
432
|
+
const usd = num(e.usd);
|
|
433
|
+
s('info', side === 'sell' ? 'SELL' : 'BUY', `${side || 'fill'}${usd !== null ? ` ${usdShort(usd)}` : ''}${detail('reason') ? ` · ${detail('reason')}` : ''} (paper book)`);
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
default: break;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const NO_LIVE_DATA = 'No live data here: the bot is not attached to a run on live data (no run, a replay, the stub, or offline). Start one with `dexterm autotrade run <id> --paper`.';
|
|
441
|
+
/** The ReadOnlyPort of a runner host: the run's read tools and its state, nothing else. */
|
|
442
|
+
export class HostReadPort {
|
|
443
|
+
d;
|
|
444
|
+
now;
|
|
445
|
+
constructor(d) { this.d = d; this.now = d.now ?? Date.now; }
|
|
446
|
+
mark(mint) {
|
|
447
|
+
const st = this.d.state();
|
|
448
|
+
if (!st)
|
|
449
|
+
return null;
|
|
450
|
+
if (st.positions.some((p) => p.mint === mint))
|
|
451
|
+
return 'held';
|
|
452
|
+
if (st.candidates.some((c) => c.mint === mint))
|
|
453
|
+
return 'candidate';
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
async check(mint) {
|
|
457
|
+
if (!MINT_RE.test(mint))
|
|
458
|
+
return { unavailable: USAGE_CHECK };
|
|
459
|
+
const call = this.d.call();
|
|
460
|
+
if (!call)
|
|
461
|
+
return { unavailable: NO_LIVE_DATA };
|
|
462
|
+
const t = this.d.timeoutMs ?? {};
|
|
463
|
+
const [tok, larp] = await Promise.all([
|
|
464
|
+
within(call('get_token', { mint }), t.token ?? 15_000, 'token data').catch((e) => ({ error: errOf(e) })),
|
|
465
|
+
within(call('larp_check', { mint }), t.larp ?? 25_000, 'LARP check').catch((e) => ({ error: errOf(e) })),
|
|
466
|
+
]);
|
|
467
|
+
const at = this.now();
|
|
468
|
+
const tk = rec(tok.token);
|
|
469
|
+
const pair = rec(tok.pair);
|
|
470
|
+
const stats = rec(tok.stats);
|
|
471
|
+
const audit = rec(tok.audit);
|
|
472
|
+
const ls = rec(larp.larp_score);
|
|
473
|
+
const score = num(larp.score);
|
|
474
|
+
const flagsSrc = Array.isArray(ls?.top) && ls.top.length ? ls.top : Array.isArray(ls?.flags) ? ls.flags : [];
|
|
475
|
+
const flags = flagsSrc.map(rec).filter((x) => !!x && typeof x.label === 'string' && num(x.points) !== null).map((x) => ({ label: String(x.label), points: num(x.points) })).sort((a, b) => b.points - a.points);
|
|
476
|
+
const range = rec(ls?.range);
|
|
477
|
+
const created = num(tk?.createdAt) ?? num(pair?.createdAt);
|
|
478
|
+
const mcapRes = rec(pair?.mcapResolved);
|
|
479
|
+
return {
|
|
480
|
+
mint, at,
|
|
481
|
+
symbol: untrustedText(tk?.symbol), name: untrustedText(tk?.name),
|
|
482
|
+
larp: score === null ? null : { score, band: str(larp.band) ?? '?', hi: num(range?.hi), unread: Array.isArray(ls?.missing) ? ls.missing.length : 0, flags },
|
|
483
|
+
larpError: score === null ? str(larp.error) ?? 'no score came back' : null,
|
|
484
|
+
market: pair ? { mcap: num(mcapRes?.value) ?? num(pair.mcap), liq: num(pair.liquidityUsd), holders: num(stats?.holderCount), tokenAgeSec: created !== null ? Math.max(0, Math.round((at - created) / 1000)) : null, fetchedAt: num(pair.fetchedAt) } : null,
|
|
485
|
+
audit: audit ? { mintAuth: bool(audit.mintAuthority), freezeAuth: bool(audit.freezeAuthority), top10Pct: num(audit.top10Pct), devPct: num(audit.devPct), lpLockedPct: num(audit.lpLockedPct), rugScore: num(audit.rugScore), fetchedAt: num(audit.fetchedAt) } : null,
|
|
486
|
+
dataError: pair ? null : str(tok.error) ?? 'the sources did not answer',
|
|
487
|
+
engine: this.mark(mint),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
async top(n) {
|
|
491
|
+
const k = Math.min(TOP_MAX, Math.max(1, Math.floor(n) || TOP_DEFAULT));
|
|
492
|
+
const call = this.d.call();
|
|
493
|
+
let why = '';
|
|
494
|
+
if (call) {
|
|
495
|
+
try {
|
|
496
|
+
const r = await within(call('screener_query', { preset: 'trending', limit: k }), this.d.timeoutMs?.top ?? 20_000, 'screener');
|
|
497
|
+
const rows = Array.isArray(r.rows) ? r.rows.map(rec).filter((x) => !!x && typeof x.mint === 'string' && MINT_RE.test(x.mint)) : [];
|
|
498
|
+
if (rows.length) {
|
|
499
|
+
return {
|
|
500
|
+
source: "the screener's trending list", at: this.now(),
|
|
501
|
+
rows: rows.slice(0, k).map((x) => ({
|
|
502
|
+
mint: String(x.mint), symbol: untrustedText(x.symbol), mcap: num(x.mcap), liq: num(x.liquidityUsd), volH1: num(x.volH1), chgH1: num(rec(x.priceChange)?.h1),
|
|
503
|
+
tokenAgeSec: num(x.tokenAgeSec), fetchedAt: num(x.fetchedAt), engine: this.mark(String(x.mint)),
|
|
504
|
+
})),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
why = 'the screener returned nothing: offline, or the sources did not answer';
|
|
508
|
+
}
|
|
509
|
+
catch (e) {
|
|
510
|
+
why = `the screener did not answer (${errOf(e)})`;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// No live screener: the runner's own candidates and holdings, which it ranks by nothing but time.
|
|
514
|
+
const st = this.d.state();
|
|
515
|
+
const own = st ? [...st.positions.map((p) => ({ mint: p.mint, symbol: p.symbol, engine: 'held' })), ...st.candidates.map((c) => ({ mint: c.mint, symbol: c.symbol, engine: 'candidate' }))] : [];
|
|
516
|
+
if (own.length) {
|
|
517
|
+
return {
|
|
518
|
+
source: `${why || 'no live screener here'}; the runner's own coins instead`, at: this.now(),
|
|
519
|
+
rows: own.slice(0, k).map((x) => ({ mint: x.mint, symbol: x.symbol, mcap: null, liq: null, volH1: null, chgH1: null, tokenAgeSec: null, fetchedAt: st.t, engine: x.engine })),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
return { unavailable: why ? `No ranking right now: ${why}.` : NO_LIVE_DATA };
|
|
523
|
+
}
|
|
524
|
+
signals(n) {
|
|
525
|
+
const log = this.d.signals;
|
|
526
|
+
if (!log.total && !this.d.state())
|
|
527
|
+
return { unavailable: 'No signals: no run has started since the bot came on.' };
|
|
528
|
+
return { at: this.now(), total: log.total, rows: log.recent(Math.min(SIGNALS_MAX, Math.max(1, Math.floor(n) || SIGNALS_DEFAULT))) };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// NotifyBus the run ledger as it is written → messages
|
|
8
8
|
// HostControlPort the router's actions, through the SAME control file the CLI and the Q screen write
|
|
9
9
|
// (autotrade/control.json, by: "chat"), the runner's live state, and the strategy store
|
|
10
|
+
// HostReadPort task-1426: /check /top /signals from the run's read tools (setReadTools) and the signals the
|
|
11
|
+
// runner raised (SignalLog, fed by the ledger tap); read-only, separate from HostControlPort
|
|
10
12
|
// The runner never waits on chat: the bus queues, the broker answers when a tap lands, and a dead connection means
|
|
11
13
|
// no entries that need an approval (UNDELIVERED = skip), never a blocked exit.
|
|
12
14
|
import { riskDelta } from '../../../mcp/src/index.js';
|
|
@@ -20,6 +22,7 @@ import { ChatAudit } from "./audit.js";
|
|
|
20
22
|
import { defaultTelegram, loadConnectors, saveConnectors } from "./config.js";
|
|
21
23
|
import { ChatHub } from "./hub.js";
|
|
22
24
|
import { NotifyBus } from "./notify.js";
|
|
25
|
+
import { HostReadPort, SignalLog } from "./readonly.js";
|
|
23
26
|
/** connectors.json re-read when it changes (a local `dexterm telegram approvals off` lands without a restart). */
|
|
24
27
|
export class ConfigView {
|
|
25
28
|
cached = null;
|
|
@@ -175,6 +178,10 @@ export class TelegramService {
|
|
|
175
178
|
audit;
|
|
176
179
|
config;
|
|
177
180
|
port;
|
|
181
|
+
/** task-1426: the read-only data commands' port and the runner's signals. */
|
|
182
|
+
reads;
|
|
183
|
+
signals = new SignalLog();
|
|
184
|
+
readTools = null;
|
|
178
185
|
o;
|
|
179
186
|
now;
|
|
180
187
|
ac = new AbortController();
|
|
@@ -190,7 +197,8 @@ export class TelegramService {
|
|
|
190
197
|
const cfg = this.config.get;
|
|
191
198
|
this.broker = new ApprovalBroker({ connector: o.connector, store: this.store, audit: this.audit, port: o.port, config: cfg, now: this.now, maxOpen: o.maxOpen, timers: o.timers, alert: o.alert });
|
|
192
199
|
this.port = new HostControlPort({ home: o.home, runId: o.runId, state: o.state, strategy: o.strategy, config: this.config, broker: () => this.broker, onSwitch: o.onSwitch ?? (() => { }) });
|
|
193
|
-
this.
|
|
200
|
+
this.reads = new HostReadPort({ call: () => this.readTools, state: o.state, signals: this.signals, now: this.now });
|
|
201
|
+
this.hub = new ChatHub({ connector: o.connector, config: cfg, audit: this.audit, badge: badgeOf(o.port.network), broker: this.broker, port: this.port, reads: this.reads, now: this.now, alert: o.alert });
|
|
194
202
|
const delays = o.retryDelaysMs ?? [1000, 4000];
|
|
195
203
|
this.bus = new NotifyBus({
|
|
196
204
|
badge: badgeOf(o.port.network), config: cfg, now: this.now, card: o.card, approvalCounts: (since) => this.store.countsSince(since),
|
|
@@ -229,9 +237,12 @@ export class TelegramService {
|
|
|
229
237
|
await this.broker.sweep();
|
|
230
238
|
await this.bus.tick();
|
|
231
239
|
}
|
|
240
|
+
/** task-1426: the read tools of the run on live data (null when it ends, or on a replay): /check and /top read them. */
|
|
241
|
+
setReadTools(call) { this.readTools = call; }
|
|
232
242
|
/** The run ledger tap: every event, as it is written. */
|
|
233
243
|
onLedger = (e) => {
|
|
234
244
|
this.bus.onEvent(e);
|
|
245
|
+
this.signals.onLedger(e);
|
|
235
246
|
if (e.kind === 'fill' && typeof e.proposalId === 'string')
|
|
236
247
|
this.broker.filled(e.proposalId);
|
|
237
248
|
};
|