dexterm-cli 0.1.1 → 0.1.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/agents/src/bus.js +20 -5
- package/dist/packages/agents/src/executor.js +25 -0
- package/dist/packages/agents/src/gate.js +3 -0
- package/dist/packages/agents/src/identity.js +41 -2
- package/dist/packages/agents/src/tiers.js +5 -0
- package/dist/packages/agents/src/tools.js +3 -0
- package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +11 -3
- package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +13 -3
- package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +3 -3
- package/dist/packages/cli/src/agent-cmd.js +36 -3
- package/dist/packages/cli/src/alerts/adapters/desktop.js +40 -0
- package/dist/packages/cli/src/alerts/adapters/discord.js +36 -0
- package/dist/packages/cli/src/alerts/adapters/telegram.js +43 -0
- package/dist/packages/cli/src/alerts/adapters/toast.js +16 -0
- package/dist/packages/cli/src/alerts/creds.js +83 -0
- package/dist/packages/cli/src/alerts/format.js +41 -0
- package/dist/packages/cli/src/alerts/index.js +10 -0
- package/dist/packages/cli/src/alerts/notifier.js +53 -0
- package/dist/packages/cli/src/alerts/route.js +19 -0
- package/dist/packages/cli/src/alerts/types.js +1 -0
- package/dist/packages/cli/src/alerts-cmd.js +212 -0
- package/dist/packages/cli/src/backtest-cmd.js +62 -3
- package/dist/packages/cli/src/coin-target.js +2 -0
- package/dist/packages/cli/src/doctor.js +20 -6
- package/dist/packages/cli/src/export.js +235 -0
- package/dist/packages/cli/src/live/index.js +2 -0
- package/dist/packages/cli/src/main.js +243 -41
- package/dist/packages/cli/src/mcp-cmd.js +326 -0
- package/dist/packages/cli/src/rules.js +21 -2
- package/dist/packages/cli/src/skill-eval-build.js +109 -0
- package/dist/packages/cli/src/update.js +294 -0
- package/dist/packages/cli/src/version.js +3 -0
- package/dist/packages/cli/src/voice-cmd.js +119 -0
- package/dist/packages/cli/src/watch-cmd.js +190 -0
- package/dist/packages/custom/src/rules.js +1 -1
- package/dist/packages/custom/src/schema.js +24 -1
- package/dist/packages/data/src/coin-target.js +82 -0
- package/dist/packages/data/src/index.js +3 -0
- package/dist/packages/data/src/intel/helius-history.js +12 -1
- package/dist/packages/data/src/larp/link-baseline.js +196 -0
- package/dist/packages/data/src/sources/dexscreener.js +14 -0
- package/dist/packages/data/src/sources/pumpportal.js +6 -1
- package/dist/packages/mcp/src/server.js +89 -2
- package/dist/packages/packs/src/index.js +1 -0
- package/dist/packages/packs/src/skill-backtest.js +300 -0
- package/dist/packages/tui/src/index.js +3 -0
- package/dist/packages/tui/src/keymap.js +2 -0
- package/dist/packages/tui/src/raster-font.js +195 -0
- package/dist/packages/tui/src/raster.js +213 -0
- package/dist/packages/voice/src/controller.js +15 -3
- package/dist/packages/voice/src/doctor.js +75 -7
- package/dist/packages/voice/src/download.js +184 -0
- package/dist/packages/voice/src/engines.js +48 -8
- package/dist/packages/voice/src/index.js +7 -3
- package/dist/packages/voice/src/stt/whisper.js +14 -4
- package/dist/packages/voice/src/tts/espeak.js +131 -0
- package/dist/packages/voice/src/tts/piper.js +206 -0
- package/dist/packages/voice/src/tts/say.js +71 -35
- package/dist/packages/voice/src/tts/voices.js +206 -0
- package/package.json +5 -9
|
@@ -192,7 +192,7 @@ export class AgentBus {
|
|
|
192
192
|
return;
|
|
193
193
|
}
|
|
194
194
|
if (msg.method === 'tools/list') {
|
|
195
|
-
this.reply(conn, id, { tools:
|
|
195
|
+
this.reply(conn, id, { tools: this.visibleToolDefs(conn.session) });
|
|
196
196
|
return;
|
|
197
197
|
}
|
|
198
198
|
if (msg.method === 'tools/call') {
|
|
@@ -245,14 +245,15 @@ export class AgentBus {
|
|
|
245
245
|
if (rec.paused)
|
|
246
246
|
this.paused.add(rec.id);
|
|
247
247
|
this.gate.setAgentLimits(rec.id, rec.limits);
|
|
248
|
-
const
|
|
248
|
+
const scope = rec.scope && rec.scope.length ? new Set(rec.scope) : null;
|
|
249
|
+
const session = { id: `sess_${(++this.sessionSeq).toString(36)}`, agentId: rec.id, agentName: rec.name, tier, transport: 'bus', openedAt: this.now(), lastActivity: this.now(), closed: false, closedReason: null, client, topics: new Set(), scope };
|
|
249
250
|
conn.session = session;
|
|
250
251
|
if (conn.helloTimer) {
|
|
251
252
|
clearTimeout(conn.helloTimer);
|
|
252
253
|
conn.helloTimer = null;
|
|
253
254
|
}
|
|
254
|
-
this.audit.append({ agentId: rec.id, agentName: rec.name, session: session.id, kind: 'connect', tool: '-', detail: `tier ${tier} · ${client} · ${conn.ws.remote}`, ok: true });
|
|
255
|
-
this.reply(conn, id, { protocol: BUS_PROTOCOL, agent: rec.name, tier, grantedTier: rec.tier, tools:
|
|
255
|
+
this.audit.append({ agentId: rec.id, agentName: rec.name, session: session.id, kind: 'connect', tool: '-', detail: `tier ${tier}${scope ? ` · mandate[${[...scope].join(',')}]` : ''} · ${client} · ${conn.ws.remote}`, ok: true });
|
|
256
|
+
this.reply(conn, id, { protocol: BUS_PROTOCOL, agent: rec.name, tier, grantedTier: rec.tier, tools: this.visibleTools(session), mandate: scope ? [...scope] : null, paused: rec.paused });
|
|
256
257
|
}
|
|
257
258
|
async onCall(conn, id, params) {
|
|
258
259
|
const session = conn.session;
|
|
@@ -309,7 +310,21 @@ export class AgentBus {
|
|
|
309
310
|
}
|
|
310
311
|
}
|
|
311
312
|
whoami(session) {
|
|
312
|
-
return { agent: session.agentName, tier: session.tier, tools:
|
|
313
|
+
return { agent: session.agentName, tier: session.tier, mandate: session.scope ? [...session.scope] : null, tools: this.visibleTools(session).map((t) => ({ name: t, tier: agentTool(t)?.tier })), usage: this.gate.usage(session.agentId), subscriptions: [...session.topics], note: 'Your identity, tier and mandate come from the server. You cannot change them.' };
|
|
314
|
+
}
|
|
315
|
+
/** Tool names this session may call: its tier surface, narrowed by a mandate (+ agent_whoami, always). */
|
|
316
|
+
visibleTools(session) {
|
|
317
|
+
const all = toolsForTier(session.tier);
|
|
318
|
+
if (!session.scope)
|
|
319
|
+
return all;
|
|
320
|
+
return all.filter((t) => t === 'agent_whoami' || session.scope.has(t));
|
|
321
|
+
}
|
|
322
|
+
/** The advertised tool DEFINITIONS for tools/list, narrowed the same way. */
|
|
323
|
+
visibleToolDefs(session) {
|
|
324
|
+
const defs = toolsForTierList(session.tier);
|
|
325
|
+
if (!session.scope)
|
|
326
|
+
return defs;
|
|
327
|
+
return defs.filter((t) => t.name === 'agent_whoami' || session.scope.has(t.name));
|
|
313
328
|
}
|
|
314
329
|
subscribe(conn, args) {
|
|
315
330
|
const session = conn.session;
|
|
@@ -116,6 +116,31 @@ export class FixtureExecutor {
|
|
|
116
116
|
const [token, rug, trades] = await Promise.all([this.exec(session, 'get_token', { mint: r.mint }), this.exec(session, 'rugcheck', { mint: r.mint }), this.exec(session, 'get_trades', { mint: r.mint, window: 'h1' })]);
|
|
117
117
|
return { mint: r.mint, interval: args.interval ?? '1h', token, rugcheck: rug, trades, availability: { market: true, security: true, tape: true, canVerdict: true } };
|
|
118
118
|
}
|
|
119
|
+
case 'analyze_token': {
|
|
120
|
+
const r = byMint(args.mint) ?? FIXTURE_ROWS.find((x) => x.symbol.toUpperCase() === String(args.symbol ?? '').replace(/^\$/, '').toUpperCase());
|
|
121
|
+
if (!r)
|
|
122
|
+
return { error: 'unknown token (fixtures)' };
|
|
123
|
+
// A deterministic verdict from the fixture row's audit (mirrors the real pack's rule flags).
|
|
124
|
+
const flags = [];
|
|
125
|
+
if (r.freeze)
|
|
126
|
+
flags.push('freeze_authority');
|
|
127
|
+
if (r.mintAuth)
|
|
128
|
+
flags.push('mint_authority');
|
|
129
|
+
if (r.lpLockedPct < 100)
|
|
130
|
+
flags.push('lp_unlocked');
|
|
131
|
+
if (r.top10Pct > 40)
|
|
132
|
+
flags.push('top10_concentrated');
|
|
133
|
+
if (r.organicScore < 40)
|
|
134
|
+
flags.push('organic_below_40');
|
|
135
|
+
if (r.ageSec < 600)
|
|
136
|
+
flags.push('under_10_minutes');
|
|
137
|
+
const verdict = r.rugScore >= 60 ? 'AVOID' : r.rugScore >= 25 ? 'SPECULATIVE' : 'WATCH';
|
|
138
|
+
return { pack: 'token-dossier', pack_version: 'fixtures', model_output: false, mint: r.mint, symbol: fw(r.symbol), deterministic_verdict: verdict, rug_probability: r.rugScore / 100, rug_model: 'H1-v0.1', flags, injection_suspected: false, note: 'Deterministic code stage (no model) on fixtures.', source: 'fixtures' };
|
|
139
|
+
}
|
|
140
|
+
case 'narrative_board':
|
|
141
|
+
return { pack: 'narrative-board', model_output: false, narratives: [], note: 'No recorded history on fixtures (the board needs a running recorder).' };
|
|
142
|
+
case 'rug_backtest':
|
|
143
|
+
return { pack: 'rug-backtest', model_output: false, note: 'No recorded history on fixtures (a backtest needs recorded t0 snapshots with labelled outcomes).' };
|
|
119
144
|
case 'open_token':
|
|
120
145
|
case 'show_in_screener':
|
|
121
146
|
case 'switch_feed':
|
|
@@ -95,6 +95,9 @@ export class AgentGate {
|
|
|
95
95
|
return refuse('unknown-tool', `unknown tool ${JSON.stringify(String(name).slice(0, 40))}`);
|
|
96
96
|
if (!tierAtLeast(session.tier, need))
|
|
97
97
|
return refuse('tier', `${name} needs tier ${need}; this agent is ${session.tier}`);
|
|
98
|
+
// Per-agent mandate (task-1405): a scoped agent may call only its listed tools (+ agent_whoami). Narrow-only.
|
|
99
|
+
if (session.scope && name !== 'agent_whoami' && !session.scope.has(name))
|
|
100
|
+
return refuse('mandate', `${name} is outside this agent's mandate (allowed: ${[...session.scope].join(', ')})`);
|
|
98
101
|
// spoofing: identity / capability fields in the arguments are never honoured
|
|
99
102
|
const args = req.args === undefined ? {} : req.args;
|
|
100
103
|
if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
|
|
@@ -10,11 +10,30 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from 'node:fs';
|
|
11
11
|
import { dirname, join } from 'node:path';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
|
-
import { isTier } from "./tiers.js";
|
|
13
|
+
import { isTier, TOOL_TIERS } from "./tiers.js";
|
|
14
14
|
export const AGENT_NAME_RE = /^[a-z][a-z0-9-]{0,23}$/;
|
|
15
15
|
export const TOKEN_PREFIX = 'dxa_';
|
|
16
16
|
/** Hard cap on registered agents (a flood of `agent add` is still bounded). */
|
|
17
17
|
export const MAX_AGENTS = 32;
|
|
18
|
+
/** Validate + normalise a mandate scope: known tool names only, deduped; agent_whoami is implicit. Throws on an unknown tool. */
|
|
19
|
+
export function sanitizeScope(tools) {
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const raw of tools) {
|
|
23
|
+
const t = String(raw).trim();
|
|
24
|
+
if (!t || t === 'agent_whoami')
|
|
25
|
+
continue;
|
|
26
|
+
if (!Object.hasOwn(TOOL_TIERS, t))
|
|
27
|
+
throw new Error(`unknown tool "${t.slice(0, 40)}" — a mandate may only list agent tools (${Object.keys(TOOL_TIERS).join(', ')})`);
|
|
28
|
+
if (!seen.has(t)) {
|
|
29
|
+
seen.add(t);
|
|
30
|
+
out.push(t);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!out.length)
|
|
34
|
+
throw new Error('a mandate needs at least one tool (use --clear to remove a mandate)');
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
18
37
|
export function dextermHome(env = process.env) {
|
|
19
38
|
return env.DEXTERM_HOME && env.DEXTERM_HOME.trim() ? env.DEXTERM_HOME : join(homedir(), '.dexterm');
|
|
20
39
|
}
|
|
@@ -60,7 +79,16 @@ export class AgentRegistry {
|
|
|
60
79
|
const list = Array.isArray(f?.agents) ? f.agents : [];
|
|
61
80
|
// Defensive re-validation: a hand-edited file cannot grant a tier outside the three or a bad name.
|
|
62
81
|
this.agents = list.filter((a) => !!a && typeof a === 'object' && typeof a.id === 'string' && typeof a.name === 'string' && AGENT_NAME_RE.test(a.name)
|
|
63
|
-
&& isTier(a.tier) && typeof a.tokenHash === 'string' && typeof a.salt === 'string').map((a) =>
|
|
82
|
+
&& isTier(a.tier) && typeof a.tokenHash === 'string' && typeof a.salt === 'string').map((a) => {
|
|
83
|
+
// A hand-edited scope is re-validated: keep only known tool names; an empty result drops the mandate.
|
|
84
|
+
const scope = Array.isArray(a.scope) ? a.scope.filter((t) => typeof t === 'string' && Object.hasOwn(TOOL_TIERS, t)) : undefined;
|
|
85
|
+
const rec = { ...a, paused: a.paused === true, rotatedAt: typeof a.rotatedAt === 'number' ? a.rotatedAt : null };
|
|
86
|
+
if (scope && scope.length)
|
|
87
|
+
rec.scope = [...new Set(scope)];
|
|
88
|
+
else
|
|
89
|
+
delete rec.scope;
|
|
90
|
+
return rec;
|
|
91
|
+
});
|
|
64
92
|
}
|
|
65
93
|
save() {
|
|
66
94
|
if (this.path === ':memory:')
|
|
@@ -142,6 +170,17 @@ export class AgentRegistry {
|
|
|
142
170
|
a.limits = sanitizeLimits(limits);
|
|
143
171
|
this.save();
|
|
144
172
|
}
|
|
173
|
+
/** Set (or clear, with null) the per-agent tool mandate. Throws on an unknown tool name. */
|
|
174
|
+
setScope(name, tools) {
|
|
175
|
+
const a = this.get(name);
|
|
176
|
+
if (!a)
|
|
177
|
+
throw new Error(`no agent "${name}"`);
|
|
178
|
+
if (tools === null)
|
|
179
|
+
delete a.scope;
|
|
180
|
+
else
|
|
181
|
+
a.scope = sanitizeScope(tools);
|
|
182
|
+
this.save();
|
|
183
|
+
}
|
|
145
184
|
/**
|
|
146
185
|
* Verify a presented name + token. Constant-time on the hash; the same `null` for an unknown name,
|
|
147
186
|
* a bad token shape and a wrong token (no oracle). Re-reads the file first so a rotate / remove from
|
|
@@ -42,6 +42,11 @@ export const TOOL_TIERS = Object.freeze({
|
|
|
42
42
|
get_candles: 'observe',
|
|
43
43
|
rugcheck: 'observe',
|
|
44
44
|
get_dev_history: 'observe',
|
|
45
|
+
// deterministic analysis packs (task-1356): read-only, no model, no token cost. Exposed to external
|
|
46
|
+
// agents in task-1405 so a bring-your-own-agent gets the same code-stage analysis the built-in analyst does.
|
|
47
|
+
analyze_token: 'observe',
|
|
48
|
+
narrative_board: 'observe',
|
|
49
|
+
rug_backtest: 'observe',
|
|
45
50
|
kb_lookup: 'observe',
|
|
46
51
|
kb_search: 'observe',
|
|
47
52
|
case_lookup: 'observe',
|
|
@@ -26,6 +26,9 @@ const TOOLS_DEF = [
|
|
|
26
26
|
{ name: 'get_candles', tier: 'observe', description: 'Candles plus computed summaries (range, drawdown, VWAP, change%).', inputSchema: { type: 'object', properties: { mint: MINT, interval: { type: 'string', enum: ['1m', '5m', '15m', '1h', '4h', '1d'] }, limit: { type: 'integer', minimum: 1, maximum: 300 } }, required: ['mint'], additionalProperties: false } },
|
|
27
27
|
{ name: 'rugcheck', tier: 'observe', description: 'RugCheck-style summary: risks[] (firewalled), normalised score, lpLockedPct, tokenProgram.', inputSchema: { type: 'object', properties: { mint: MINT }, required: ['mint'], additionalProperties: false } },
|
|
28
28
|
{ name: 'get_dev_history', tier: 'observe', description: 'Creator address and prior-launch forensics. Tier-1 fields need a key and are null on the keyless tier.', inputSchema: { type: 'object', properties: { mint: MINT }, required: ['mint'], additionalProperties: false } },
|
|
29
|
+
{ name: 'analyze_token', tier: 'observe', description: 'Deterministic token-dossier analysis pack: numeric features, named rule flags, a WATCH/SPECULATIVE/AVOID verdict, a p(rug) heuristic and data ages. No model, no token cost; a reasoning stage may only tighten the verdict, never loosen it.', inputSchema: { type: 'object', properties: { mint: MINT, symbol: SYMBOL, interval: { type: 'string', enum: ['1m', '5m', '15m', '1h', '4h', '1d'] } }, additionalProperties: false } },
|
|
30
|
+
{ name: 'narrative_board', tier: 'observe', description: 'The narrative board over recorded + trending tokens: per-narrative volume, new launches, runners and a "what is working" score. No model; returns a note when there is no recorded history yet.', inputSchema: { type: 'object', properties: { window: { type: 'string', enum: ['1h', '24h'] }, limit: { type: 'integer', minimum: 1, maximum: 50 }, summary: { type: 'boolean' } }, additionalProperties: false } },
|
|
31
|
+
{ name: 'rug_backtest', tier: 'observe', description: 'Backtest the rug model or recorded pack verdicts against RECORDED outcomes: honest precision/recall, Brier, AUC and calibration. Leakage-free by construction; returns a note when there is not enough history. Read-only.', inputSchema: { type: 'object', properties: { kind: { type: 'string', enum: ['heuristic', 'pack_verdicts'] }, horizon_sec: { type: 'integer', enum: [3600, 7200, 21600, 86400, 259200] }, positive_labels: { type: 'array', items: { type: 'string', enum: ['rug', 'dead', 'fade', 'flat', 'runner'] }, maxItems: 5 } }, additionalProperties: false } },
|
|
29
32
|
{ name: 'kb_lookup', tier: 'observe', description: 'One knowledge-base entry by id (mechanics / patterns / glossary). KB never holds facts about a live token.', inputSchema: { type: 'object', properties: { id: { type: 'string', maxLength: 64 } }, required: ['id'], additionalProperties: false } },
|
|
30
33
|
{ name: 'kb_search', tier: 'observe', description: 'Search the knowledge base; returns ids, titles and an excerpt.', inputSchema: { type: 'object', properties: { query: { type: 'string', maxLength: 200 }, limit: { type: 'integer', minimum: 1, maximum: 10 } }, required: ['query'], additionalProperties: false } },
|
|
31
34
|
{ name: 'case_lookup', tier: 'observe', description: 'Historical case files (verified on-chain references + outcome). By id, or list all with no id.', inputSchema: { type: 'object', properties: { id: { type: 'string', maxLength: 64 } }, additionalProperties: false } },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: larp-check
|
|
3
3
|
description: Vet the website and socials behind a Solana token to judge whether there is a real product or it is a LARP (claims a product that does not exist). Use when the user asks "is this real", "larp check", "is the product real", "check the website/socials".
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
argument-hint: <mint or symbol>
|
|
6
6
|
kind: skill
|
|
7
7
|
output_schema: schemas/larp.schema.json
|
|
@@ -30,7 +30,7 @@ You are judging one thing: **does a real product exist behind this coin, or is t
|
|
|
30
30
|
2. `get_token` with `{mint}` for a base58 address (32–44 chars), else `{symbol}`.
|
|
31
31
|
- If the result has `candidates`: stop. Verdict `AMBIGUOUS`, `mint` = "", list the candidates, ask the user to pick a mint. Analyse none of them.
|
|
32
32
|
- If the result has `error`: stop with `UNVERIFIABLE` and say why.
|
|
33
|
-
3. `get_links` `{mint}` — the website and socials the coin advertises. Note any `mismatch
|
|
33
|
+
3. `get_links` `{mint}` — the website and socials the coin advertises. Note any `mismatch` and read `baseline` (below).
|
|
34
34
|
4. `fetch_page` `{url, symbol, name}` on the primary `website` (pass the token symbol and name so the mention check runs). Read `signals` and the firewalled `excerpt`.
|
|
35
35
|
- Follow at most two more `fetch_page` calls: a `docs`/`whitepaper` link and an in-site app/demo link if the page advertises one. Do not crawl.
|
|
36
36
|
5. `check_domain` `{url}` on the primary website.
|
|
@@ -45,6 +45,14 @@ Do not call X/Twitter or Telegram through `fetch_page` expecting profile data: t
|
|
|
45
45
|
- **LARP signals:** a near-empty or default/parked/site-builder placeholder page; the site never mentions the token symbol or name; broken/unreachable advertised links; a domain registered the same day as the token behind privacy; a GitHub that is empty, archived, a fork, or has no commits since launch; claims of an app with no reachable app; advertised links that mismatch across sources.
|
|
46
46
|
- **Drainer/scam signals:** `drainerFlags` set, EVM wallet calls on a Solana token, wallet-connect as the only content. These make it worse than a LARP — flag `wallet_drainer_risk`.
|
|
47
47
|
|
|
48
|
+
## The links-only baseline (backtested, task-1401)
|
|
49
|
+
|
|
50
|
+
`get_links.baseline` grades the three primary links with a rubric tuned on recorded history (`grade`, `signals`, and the held-out rate in `measured`). It is a weak prior, not evidence about the product:
|
|
51
|
+
- On held-out coins its `LARP` grade was rare (14 of 328) and not reliably better than average; its `EARLY_BUT_REAL` coins went bad less often than average (10 % against 19 %). Never return `LARP` from the baseline alone: a `LARP` verdict needs a fetched-page, domain or GitHub fact that contradicts a claim. If nothing could be fetched, the verdict is `UNVERIFIABLE`.
|
|
52
|
+
- The link signals that went with bad outcomes in the backtest: an X link to a single post or a community instead of an account, no X at all, no Telegram. A social or launchpad page posted as the website did not, so fetch it before reading anything into it.
|
|
53
|
+
- No links at all is `UNVERIFIABLE`, and say plainly it is the riskier case: coins with no links went bad more often than average in the backtest.
|
|
54
|
+
- When the verdict rests mostly on links (pages blocked or thin), keep `confidence` at 0.6 or lower.
|
|
55
|
+
|
|
48
56
|
## Grounding rules (hard)
|
|
49
57
|
|
|
50
58
|
- Every claim, number, date, boolean or label you state comes from a tool result in this session. Put each in `facts[]` with the `tool`, the `field` path, and the `value` verbatim. If you cannot point at a tool field, do not state it.
|
|
@@ -64,4 +72,4 @@ Do not call X/Twitter or Telegram through `fetch_page` expecting profile data: t
|
|
|
64
72
|
|
|
65
73
|
## Output
|
|
66
74
|
|
|
67
|
-
Return only the JSON object required by the schema (`skill: "larp-check"`, `skill_version: "0.
|
|
75
|
+
Return only the JSON object required by the schema (`skill: "larp-check"`, `skill_version: "0.2.0"`). Fill `claims[]`, `facts[]` and `missing_data[]` completely. `summary` is two to four short lines for a 40-column panel: the verdict in plain words, the one or two strongest pieces of evidence (each with its source), and what was missing only if it matters. Do not repeat the confidence number (the app renders it), and add no disclaimer or boilerplate line.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: rug-check
|
|
3
3
|
description: Rug and honeypot forensics on one token - authorities, token program, LP, concentration, creator history, tape - with a rug probability. Use for "is this a rug", "safe?", "check it".
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
argument-hint: <mint or symbol>
|
|
6
6
|
kind: skill
|
|
7
7
|
output_schema: schemas/verdict.schema.json
|
|
@@ -32,7 +32,17 @@ Authorities → token program and extensions → LP → concentration and cluste
|
|
|
32
32
|
- Freeze authority enabled, mint authority enabled, or a Token-2022 program with a dangerous extension named in `security.risks[]`: verdict `AVOID`, `rug_probability ≥ 0.8`. The verdict is settled.
|
|
33
33
|
- A tape with almost no sells on a token hours old and many buyers (`tape.sells` under 5 % of `tape.buys` with `count` above 50) is a honeypot signal even when the freeze field is null: flag `sells_suppressed`.
|
|
34
34
|
- LP below 100 % locked on a Raydium/Meteora pair: flag `lp_unlocked`; combined with `concentration.top10PctExLp` above 50 % it is `AVOID`.
|
|
35
|
-
-
|
|
35
|
+
- Liquidity under $5,000 (`market.pair.liquidityUsd`): `SPECULATIVE` at best. This was the strongest signal in the backtest (task-1401, train split: 80 % of such coins went rug or dead within 72 h, against 40 % overall).
|
|
36
|
+
- Dev above 10 %, organic score below 40 and top-10 above 30 % did not go with a worse 72 h outcome in the backtest. Report them in `flags` and lower `confidence`, but on their own they do not make a coin `SPECULATIVE`. A serial-rugger creator still does (the backtest has no creator history, so that rule is untested).
|
|
37
|
+
|
|
38
|
+
## Anchoring `rug_probability` (backtest, task-1401)
|
|
39
|
+
|
|
40
|
+
Start from the train-split 72 h bad-outcome rate of the rule that decided the verdict, then move it with the tape and creator facts you can cite:
|
|
41
|
+
- liquidity under $5,000: anchor 0.8.
|
|
42
|
+
- no disqualifier and liquidity at or above $5,000: anchor 0.27.
|
|
43
|
+
- the authority disqualifiers keep `AVOID` and their floor. The backtest cannot score them: its labels are price outcomes, which miss honeypots and frozen holders, and the coins with an authority still enabled were almost all older tokens off pump.fun (median age 259 days).
|
|
44
|
+
|
|
45
|
+
On the later, held-out coins both anchored rates ran lower (55 % and 14 %) because fewer coins went bad overall: the ranking held, the level did not. These rates come from one machine's recorder over a few days and drift with the market; never round up toward certainty.
|
|
36
46
|
|
|
37
47
|
## Grounding rules (hard)
|
|
38
48
|
|
|
@@ -57,7 +67,7 @@ Authorities → token program and extensions → LP → concentration and cluste
|
|
|
57
67
|
|
|
58
68
|
## Output
|
|
59
69
|
|
|
60
|
-
Only the JSON object required by the schema (`skill: "rug-check"`, `skill_version: "0.
|
|
70
|
+
Only the JSON object required by the schema (`skill: "rug-check"`, `skill_version: "0.3.0"`, include `rug_probability`). Fill every structured field completely (`bias`, `key_levels`, `invalidation`, `size_hint`, `facts`, `missing_data`). Brevity applies to the prose only:
|
|
61
71
|
|
|
62
72
|
- `summary` is two to four short lines for a 40-column panel: line 1 answers "is it a rug" in plain words (the disqualifier if there is one, otherwise the main risk or "no disqualifier"), line 2 is the one or two strongest facts with their source fields. Add a short `missing:` line only when a gap changes the call.
|
|
63
73
|
- Do not repeat the verdict, rug probability, bias, key levels, invalidation or size in `summary`. The app renders those fields right beside it. Do not restate numbers the user already sees on the screener row.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: token-dossier
|
|
3
3
|
description: Full read-only dossier on one Solana token (market, holders, creator, risk, tape) with a WATCH/SPECULATIVE/AVOID verdict. Use when the user asks "what is this", "dossier", "look at $X".
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
argument-hint: <mint or symbol>
|
|
6
6
|
kind: skill
|
|
7
7
|
output_schema: schemas/verdict.schema.json
|
|
@@ -39,7 +39,7 @@ Read `availability` first: `present` lists the sections you have, `missing` the
|
|
|
39
39
|
Judge with what you have. Missing sections lower `confidence`; they do not block a verdict.
|
|
40
40
|
|
|
41
41
|
- `AVOID` when any disqualifier is present: freeze authority enabled, mint authority enabled, Token-2022 honeypot extension, LP locked below 100 % on a non-pump pair with top-10 ex-LP above 50 %, or wash tape (`tape.top2WalletVolumeSharePct` above 60 with `buyUsd ≈ sellUsd`).
|
|
42
|
-
- `SPECULATIVE` when there is no disqualifier but a material risk (
|
|
42
|
+
- `SPECULATIVE` when there is no disqualifier but a material risk: liquidity under $5,000 (the strongest signal in the task-1401 backtest), a serial creator, or top-10 ex-LP above 50 %. Dev above 10 % and organic score below 40 did not go with a worse 72 h outcome in that backtest: name them as risks and lower `confidence`, but they alone do not make a coin `SPECULATIVE`.
|
|
43
43
|
- `WATCH` otherwise.
|
|
44
44
|
- `INSUFFICIENT_DATA` **only** when `availability.canVerdict` is false — i.e. no price and no liquidity from any source. A null creator history, missing holders, no candles or no tape is NOT insufficient data on its own: produce a verdict from the rest and note what was missing.
|
|
45
45
|
- `missing_data`: copy every entry of `availability.missing` and `availability.knownUnavailable` here. The keyless creator-history Tier-1 gap belongs here every time, not in the verdict.
|
|
@@ -56,7 +56,7 @@ Judge with what you have. Missing sections lower `confidence`; they do not block
|
|
|
56
56
|
|
|
57
57
|
## Output
|
|
58
58
|
|
|
59
|
-
Return only the JSON object required by the schema (`skill: "token-dossier"`, `skill_version: "0.
|
|
59
|
+
Return only the JSON object required by the schema (`skill: "token-dossier"`, `skill_version: "0.3.0"`). Fill every structured field completely (`bias`, `setup_quality`, `key_levels`, `invalidation`, `size_hint`, `facts`, `missing_data`). Brevity applies to the prose only:
|
|
60
60
|
|
|
61
61
|
- `summary` is two to four short lines for a 40-column panel: line 1 is the call in plain words (bias plus the one-clause reason), line 2 is the one or two facts that decide it, each with its source field. Add a short `missing:` line only when a gap changes the call.
|
|
62
62
|
- Do not repeat the verdict, bias, setup, key levels, invalidation or size in `summary`. The app renders those fields right beside it. Do not restate numbers the user already sees on the screener row (price, % change, liquidity, volume).
|
|
@@ -24,6 +24,7 @@ function intFlag(rest, name) { const v = flag(rest, name); const n = v === undef
|
|
|
24
24
|
const USAGE = `dexterm agent <command>
|
|
25
25
|
add <name> --tier observe|annotate|propose [--note T] [--reads N] [--proposals N] [--connections N]
|
|
26
26
|
list | rotate <name> | remove <name> | tier <name> <tier> | pause <name> | resume <name>
|
|
27
|
+
mandate <name> --tools a,b,c | mandate <name> --clear (narrow an agent to a named set of tools)
|
|
27
28
|
audit [--agent NAME] [--limit N]
|
|
28
29
|
proposals [--agent NAME] [--status pending|confirmed|dismissed] [--limit N] [--db PATH]
|
|
29
30
|
packs
|
|
@@ -64,9 +65,12 @@ export async function runAgent(rest, io) {
|
|
|
64
65
|
out('no agents registered. Add one: dexterm agent add <name> --tier observe');
|
|
65
66
|
return 0;
|
|
66
67
|
}
|
|
67
|
-
out('name tier paused created note');
|
|
68
|
-
for (const a of list)
|
|
69
|
-
|
|
68
|
+
out('name tier paused created mandate / note');
|
|
69
|
+
for (const a of list) {
|
|
70
|
+
const mandate = a.scope && a.scope.length ? `mandate: ${a.scope.join(',')}` : '';
|
|
71
|
+
const tail = [mandate, a.note ?? ''].filter(Boolean).join(' · ');
|
|
72
|
+
out(`${a.name.padEnd(24)} ${a.tier.padEnd(9)} ${(a.paused ? 'yes' : 'no').padEnd(7)} ${new Date(a.createdAt).toISOString().slice(0, 19)} ${tail}`);
|
|
73
|
+
}
|
|
70
74
|
return 0;
|
|
71
75
|
}
|
|
72
76
|
case 'rotate': {
|
|
@@ -131,6 +135,35 @@ export async function runAgent(rest, io) {
|
|
|
131
135
|
return 1;
|
|
132
136
|
}
|
|
133
137
|
}
|
|
138
|
+
case 'mandate': {
|
|
139
|
+
const name = rest[1];
|
|
140
|
+
if (!name) {
|
|
141
|
+
errOut('usage: dexterm agent mandate <name> --tools a,b,c | --clear');
|
|
142
|
+
return 2;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
if (has(rest, '--clear')) {
|
|
146
|
+
registry.setScope(name, null);
|
|
147
|
+
out(`cleared the mandate for "${name}" (it may now use its full tier surface).`);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
const tools = flag(rest, '--tools');
|
|
151
|
+
if (!tools) {
|
|
152
|
+
errOut('usage: dexterm agent mandate <name> --tools a,b,c | --clear');
|
|
153
|
+
return 2;
|
|
154
|
+
}
|
|
155
|
+
const list = tools.split(',').map((t) => t.trim()).filter(Boolean);
|
|
156
|
+
registry.setScope(name, list);
|
|
157
|
+
const rec = registry.get(name);
|
|
158
|
+
out(`"${name}" is now mandated to: ${rec.scope.join(', ')}.`);
|
|
159
|
+
out('the agent can call only these tools (plus agent_whoami); a mandate only narrows the tier, never widens it.');
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
errOut(`dexterm agent mandate: ${e.message}`);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
134
167
|
case 'audit': {
|
|
135
168
|
const dbPath = flag(rest, '--db') || defaultDbPath(io.env);
|
|
136
169
|
const log = new SqliteAuditLog(dbPath);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// dexterm — the `desktop` adapter (task-1398). macOS `osascript -e 'display notification …'`, Linux
|
|
2
|
+
// `notify-send`. The command is run via execFile with an argv array (NO shell), so a crafted title/body
|
|
3
|
+
// cannot inject a command. macOS osascript takes one AppleScript string on argv; the already-firewalled
|
|
4
|
+
// title/body are additionally AppleScript-escaped (\\, ") and newline-stripped there. The runner is
|
|
5
|
+
// injectable so tests assert the command without spawning anything.
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { deliveryLine } from "../format.js";
|
|
8
|
+
const defaultRunner = (cmd, args) => new Promise((resolve, reject) => {
|
|
9
|
+
execFile(cmd, args, { timeout: 5000 }, (err) => (err ? reject(new Error(err.code === 'ENOENT' ? `${cmd} not found` : err.message)) : resolve()));
|
|
10
|
+
});
|
|
11
|
+
/** AppleScript string literal body: escape backslash and quote, drop any residual control chars. */
|
|
12
|
+
function osaString(s) {
|
|
13
|
+
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\u0000-\u001f\u007f]/g, ' ') + '"';
|
|
14
|
+
}
|
|
15
|
+
export function createDesktopAdapter(deps = {}) {
|
|
16
|
+
const platform = deps.platform ?? process.platform;
|
|
17
|
+
const run = deps.run ?? defaultRunner;
|
|
18
|
+
return {
|
|
19
|
+
name: 'desktop',
|
|
20
|
+
available: () => platform === 'darwin' || platform === 'linux',
|
|
21
|
+
deliver: async (d) => {
|
|
22
|
+
try {
|
|
23
|
+
if (platform === 'darwin') {
|
|
24
|
+
const script = `display notification ${osaString(d.body)} with title ${osaString(d.title)}`;
|
|
25
|
+
await run('osascript', ['-e', script]);
|
|
26
|
+
}
|
|
27
|
+
else if (platform === 'linux') {
|
|
28
|
+
await run('notify-send', ['--app-name=dexterm', d.title, d.body || deliveryLine(d)]);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
return { ok: false, skipped: true, reason: `desktop notifications unsupported on ${platform}` };
|
|
32
|
+
}
|
|
33
|
+
return { ok: true };
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
return { ok: false, reason: e.message };
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { deliveryLine } from "../format.js";
|
|
2
|
+
const nodeFetch = (url, init) => globalThis.fetch(url, init);
|
|
3
|
+
export function createDiscordAdapter(deps) {
|
|
4
|
+
const creds = deps.creds;
|
|
5
|
+
const doFetch = deps.fetch ?? nodeFetch;
|
|
6
|
+
const timeoutMs = deps.timeoutMs ?? 8000;
|
|
7
|
+
return {
|
|
8
|
+
name: 'discord',
|
|
9
|
+
available: () => !!creds?.webhook,
|
|
10
|
+
deliver: async (d) => {
|
|
11
|
+
if (!creds)
|
|
12
|
+
return { ok: false, skipped: true, reason: 'discord not configured (dexterm alerts set-discord)' };
|
|
13
|
+
const ac = new AbortController();
|
|
14
|
+
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
15
|
+
try {
|
|
16
|
+
const res = await doFetch(creds.webhook, {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: { 'content-type': 'application/json' },
|
|
19
|
+
// Content is already firewalled; cap to Discord's 2000-char limit for safety.
|
|
20
|
+
body: JSON.stringify({ content: deliveryLine(d).slice(0, 1900) }),
|
|
21
|
+
signal: ac.signal,
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok)
|
|
24
|
+
return { ok: false, reason: `discord ${res.status}` };
|
|
25
|
+
return { ok: true };
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
const name = e.name;
|
|
29
|
+
return { ok: false, reason: name === 'AbortError' ? 'discord timed out' : 'discord unreachable' };
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
clearTimeout(t);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { deliveryLine } from "../format.js";
|
|
2
|
+
const nodeFetch = (url, init) => globalThis.fetch(url, init);
|
|
3
|
+
export function createTelegramAdapter(deps) {
|
|
4
|
+
const creds = deps.creds;
|
|
5
|
+
const doFetch = deps.fetch ?? nodeFetch;
|
|
6
|
+
const timeoutMs = deps.timeoutMs ?? 8000;
|
|
7
|
+
return {
|
|
8
|
+
name: 'telegram',
|
|
9
|
+
available: () => !!creds?.token && !!creds?.chatId,
|
|
10
|
+
deliver: async (d) => {
|
|
11
|
+
if (!creds)
|
|
12
|
+
return { ok: false, skipped: true, reason: 'telegram not configured (dexterm alerts set-telegram)' };
|
|
13
|
+
const ac = new AbortController();
|
|
14
|
+
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
15
|
+
try {
|
|
16
|
+
const res = await doFetch(`https://api.telegram.org/bot${creds.token}/sendMessage`, {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: { 'content-type': 'application/json' },
|
|
19
|
+
body: JSON.stringify({ chat_id: creds.chatId, text: deliveryLine(d), disable_web_page_preview: true }),
|
|
20
|
+
signal: ac.signal,
|
|
21
|
+
});
|
|
22
|
+
// Never echo the token/url: only the status code and Telegram's own error description.
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
let desc = '';
|
|
25
|
+
try {
|
|
26
|
+
const j = JSON.parse(await res.text());
|
|
27
|
+
desc = j.description ? `: ${j.description}` : '';
|
|
28
|
+
}
|
|
29
|
+
catch { /* ignore body */ }
|
|
30
|
+
return { ok: false, reason: `telegram ${res.status}${desc}`.slice(0, 160) };
|
|
31
|
+
}
|
|
32
|
+
return { ok: true };
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
const name = e.name;
|
|
36
|
+
return { ok: false, reason: name === 'AbortError' ? 'telegram timed out' : 'telegram unreachable' };
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
clearTimeout(t);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { deliveryLine } from "../format.js";
|
|
2
|
+
export function createToastAdapter(sink) {
|
|
3
|
+
return {
|
|
4
|
+
name: 'toast',
|
|
5
|
+
available: () => true,
|
|
6
|
+
deliver: async (d) => {
|
|
7
|
+
try {
|
|
8
|
+
sink(deliveryLine(d), d);
|
|
9
|
+
return { ok: true };
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
return { ok: false, reason: e.message };
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// dexterm — alert-channel credentials (task-1398). Bot tokens and webhook URLs are NEVER in config.json
|
|
2
|
+
// (the config schema has no key-shaped fields, by rule). They live in ~/.dexterm/<channel>.json, mode 0600,
|
|
3
|
+
// outside every repo, read only by the adapters. Nothing here ever prints a secret; `redactCreds()` scrubs
|
|
4
|
+
// a token / webhook out of any string before it could reach a log, a toast, a test fixture or an error.
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, statSync, chmodSync, renameSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
export const telegramCredsPath = (home) => join(home, 'telegram.json');
|
|
8
|
+
export const discordCredsPath = (home) => join(home, 'discord.json');
|
|
9
|
+
function readJson(path) {
|
|
10
|
+
let text;
|
|
11
|
+
try {
|
|
12
|
+
text = readFileSync(path, 'utf8');
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const v = JSON.parse(text);
|
|
19
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function writeJson(path, home, value) {
|
|
26
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
27
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
28
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
|
|
29
|
+
// rename is atomic; chmod again in case an old file had looser bits.
|
|
30
|
+
try {
|
|
31
|
+
chmodSync(tmp, 0o600);
|
|
32
|
+
}
|
|
33
|
+
catch { /* best effort */ }
|
|
34
|
+
renameSync(tmp, path);
|
|
35
|
+
}
|
|
36
|
+
export function readTelegramCreds(home) {
|
|
37
|
+
const j = readJson(telegramCredsPath(home));
|
|
38
|
+
if (!j)
|
|
39
|
+
return null;
|
|
40
|
+
const token = typeof j.token === 'string' ? j.token.trim() : '';
|
|
41
|
+
const chatId = typeof j.chatId === 'string' ? j.chatId.trim() : (typeof j.chatId === 'number' ? String(j.chatId) : '');
|
|
42
|
+
if (!token || !chatId)
|
|
43
|
+
return null;
|
|
44
|
+
return { token, chatId };
|
|
45
|
+
}
|
|
46
|
+
export function writeTelegramCreds(home, creds) {
|
|
47
|
+
if (!creds.token.trim())
|
|
48
|
+
throw new Error('telegram token is empty');
|
|
49
|
+
if (!/^-?\d{1,19}$/.test(creds.chatId.trim()))
|
|
50
|
+
throw new Error('telegram chatId must be a numeric id (e.g. 123456789 or -1001234567890)');
|
|
51
|
+
writeJson(telegramCredsPath(home), home, { token: creds.token.trim(), chatId: creds.chatId.trim() });
|
|
52
|
+
}
|
|
53
|
+
export function readDiscordCreds(home) {
|
|
54
|
+
const j = readJson(discordCredsPath(home));
|
|
55
|
+
if (!j)
|
|
56
|
+
return null;
|
|
57
|
+
const webhook = typeof j.webhook === 'string' ? j.webhook.trim() : '';
|
|
58
|
+
if (!webhook)
|
|
59
|
+
return null;
|
|
60
|
+
return { webhook };
|
|
61
|
+
}
|
|
62
|
+
export function writeDiscordCreds(home, creds) {
|
|
63
|
+
const w = creds.webhook.trim();
|
|
64
|
+
if (!/^https:\/\/(?:[a-z0-9.-]+\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+$/i.test(w)) {
|
|
65
|
+
throw new Error('discord webhook must be a https://discord.com/api/webhooks/<id>/<token> URL');
|
|
66
|
+
}
|
|
67
|
+
writeJson(discordCredsPath(home), home, { webhook: w });
|
|
68
|
+
}
|
|
69
|
+
/** True when the file exists with 0600-ish perms; a looser mode is reported so the user can tighten it. */
|
|
70
|
+
export function credsMode(path) {
|
|
71
|
+
try {
|
|
72
|
+
return statSync(path).mode & 0o777;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const TELEGRAM_TOKEN_RE = /\b\d{6,}:[A-Za-z0-9_-]{20,}\b/g;
|
|
79
|
+
const DISCORD_WEBHOOK_RE = /https:\/\/(?:[a-z0-9.-]+\.)?discord(?:app)?\.com\/api\/webhooks\/\S+/gi;
|
|
80
|
+
/** Scrub any bot token / webhook URL out of a string before it could be logged or shown. Total; never throws. */
|
|
81
|
+
export function redactCreds(s) {
|
|
82
|
+
return String(s ?? '').replace(TELEGRAM_TOKEN_RE, 'bot[redacted]').replace(DISCORD_WEBHOOK_RE, '[redacted-webhook]');
|
|
83
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// dexterm — turn a rule-engine alert / proposal into a firewalled AlertDelivery (task-1398). Pure.
|
|
2
|
+
//
|
|
3
|
+
// Untrusted text (a token symbol, and whatever the user put in the rule's alert text) is run through the
|
|
4
|
+
// prompt firewall (control/bidi/ANSI stripped, URLs → [url], foreign base58 → [addr], wide glyphs → ▯,
|
|
5
|
+
// injection-like content neutralised) and the spoken form through the `say` sanitiser (defuses `[[…]]`,
|
|
6
|
+
// shortens addresses). Nothing reaches a channel raw.
|
|
7
|
+
import { firewallText } from '../../../data/src/index.js';
|
|
8
|
+
import { sanitizeSpeech } from '../../../voice/src/index.js';
|
|
9
|
+
const TITLE_MAX = 64;
|
|
10
|
+
const BODY_MAX = 180;
|
|
11
|
+
/** Firewall a string; keep the row's own mint readable, drop URLs, cap length. */
|
|
12
|
+
function fw(s, max, mint) {
|
|
13
|
+
return firewallText(s, { keepMint: mint ?? null, keepUrls: false, max }).text;
|
|
14
|
+
}
|
|
15
|
+
export function alertToDelivery(a) {
|
|
16
|
+
const symbol = fw(a.symbol, 24) || '?';
|
|
17
|
+
const body = fw(a.text, BODY_MAX, a.mint);
|
|
18
|
+
const title = `${symbol} ⚑`.slice(0, TITLE_MAX);
|
|
19
|
+
return {
|
|
20
|
+
kind: 'alert', level: a.level, title, body,
|
|
21
|
+
spoken: sanitizeSpeech(`${symbol}. ${body}`),
|
|
22
|
+
symbol, mint: a.mint, source: a.source, rule: a.rule, at: a.at,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function proposalToDelivery(p) {
|
|
26
|
+
const symbol = fw(p.symbol, 24) || '?';
|
|
27
|
+
const what = p.type === 'ticket'
|
|
28
|
+
? `rule proposes ${p.side} ${p.size} ${p.unit} — confirm in the app (nothing armed)`
|
|
29
|
+
: `rule asks the analyst: ${p.skill}`;
|
|
30
|
+
const body = fw(what, BODY_MAX, p.mint);
|
|
31
|
+
const title = `${symbol} ⚑ proposal`.slice(0, TITLE_MAX);
|
|
32
|
+
return {
|
|
33
|
+
kind: 'proposal', level: 'info', title, body,
|
|
34
|
+
spoken: sanitizeSpeech(`${symbol}. ${body}`),
|
|
35
|
+
symbol, mint: p.mint, source: p.source, rule: p.rule, at: p.at,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** The one-line form a text channel sends (Telegram / Discord / stdout toast). */
|
|
39
|
+
export function deliveryLine(d) {
|
|
40
|
+
return d.title === `${d.symbol} ⚑` ? `${d.title} ${d.body}` : `${d.title} — ${d.body}`;
|
|
41
|
+
}
|