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.
Files changed (60) hide show
  1. package/dist/packages/agents/src/bus.js +20 -5
  2. package/dist/packages/agents/src/executor.js +25 -0
  3. package/dist/packages/agents/src/gate.js +3 -0
  4. package/dist/packages/agents/src/identity.js +41 -2
  5. package/dist/packages/agents/src/tiers.js +5 -0
  6. package/dist/packages/agents/src/tools.js +3 -0
  7. package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +11 -3
  8. package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +13 -3
  9. package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +3 -3
  10. package/dist/packages/cli/src/agent-cmd.js +36 -3
  11. package/dist/packages/cli/src/alerts/adapters/desktop.js +40 -0
  12. package/dist/packages/cli/src/alerts/adapters/discord.js +36 -0
  13. package/dist/packages/cli/src/alerts/adapters/telegram.js +43 -0
  14. package/dist/packages/cli/src/alerts/adapters/toast.js +16 -0
  15. package/dist/packages/cli/src/alerts/creds.js +83 -0
  16. package/dist/packages/cli/src/alerts/format.js +41 -0
  17. package/dist/packages/cli/src/alerts/index.js +10 -0
  18. package/dist/packages/cli/src/alerts/notifier.js +53 -0
  19. package/dist/packages/cli/src/alerts/route.js +19 -0
  20. package/dist/packages/cli/src/alerts/types.js +1 -0
  21. package/dist/packages/cli/src/alerts-cmd.js +212 -0
  22. package/dist/packages/cli/src/backtest-cmd.js +62 -3
  23. package/dist/packages/cli/src/coin-target.js +2 -0
  24. package/dist/packages/cli/src/doctor.js +20 -6
  25. package/dist/packages/cli/src/export.js +235 -0
  26. package/dist/packages/cli/src/live/index.js +2 -0
  27. package/dist/packages/cli/src/main.js +243 -41
  28. package/dist/packages/cli/src/mcp-cmd.js +326 -0
  29. package/dist/packages/cli/src/rules.js +21 -2
  30. package/dist/packages/cli/src/skill-eval-build.js +109 -0
  31. package/dist/packages/cli/src/update.js +294 -0
  32. package/dist/packages/cli/src/version.js +3 -0
  33. package/dist/packages/cli/src/voice-cmd.js +119 -0
  34. package/dist/packages/cli/src/watch-cmd.js +190 -0
  35. package/dist/packages/custom/src/rules.js +1 -1
  36. package/dist/packages/custom/src/schema.js +24 -1
  37. package/dist/packages/data/src/coin-target.js +82 -0
  38. package/dist/packages/data/src/index.js +3 -0
  39. package/dist/packages/data/src/intel/helius-history.js +12 -1
  40. package/dist/packages/data/src/larp/link-baseline.js +196 -0
  41. package/dist/packages/data/src/sources/dexscreener.js +14 -0
  42. package/dist/packages/data/src/sources/pumpportal.js +6 -1
  43. package/dist/packages/mcp/src/server.js +89 -2
  44. package/dist/packages/packs/src/index.js +1 -0
  45. package/dist/packages/packs/src/skill-backtest.js +300 -0
  46. package/dist/packages/tui/src/index.js +3 -0
  47. package/dist/packages/tui/src/keymap.js +2 -0
  48. package/dist/packages/tui/src/raster-font.js +195 -0
  49. package/dist/packages/tui/src/raster.js +213 -0
  50. package/dist/packages/voice/src/controller.js +15 -3
  51. package/dist/packages/voice/src/doctor.js +75 -7
  52. package/dist/packages/voice/src/download.js +184 -0
  53. package/dist/packages/voice/src/engines.js +48 -8
  54. package/dist/packages/voice/src/index.js +7 -3
  55. package/dist/packages/voice/src/stt/whisper.js +14 -4
  56. package/dist/packages/voice/src/tts/espeak.js +131 -0
  57. package/dist/packages/voice/src/tts/piper.js +206 -0
  58. package/dist/packages/voice/src/tts/say.js +71 -35
  59. package/dist/packages/voice/src/tts/voices.js +206 -0
  60. 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: toolsForTierList(conn.session.tier) });
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 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() };
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: toolsForTier(tier), paused: rec.paused });
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: toolsForTier(session.tier).map((t) => ({ name: t, tier: agentTool(t)?.tier })), usage: this.gate.usage(session.agentId), subscriptions: [...session.topics], note: 'Your identity and tier come from the server. You cannot change them.' };
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) => ({ ...a, paused: a.paused === true, rotatedAt: typeof a.rotatedAt === 'number' ? a.rotatedAt : null }));
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.1.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.1.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.
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.2.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
- - Dev above 10 %, or a concentration/creator red flag: `SPECULATIVE` at best; raise `rug_probability` accordingly.
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.2.0"`, include `rug_probability`). Fill every structured field completely (`bias`, `key_levels`, `invalidation`, `size_hint`, `facts`, `missing_data`). Brevity applies to the prose only:
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.2.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 (dev above 10 %, serial creator, organic score below 40, top-10 ex-LP above 50 %, or a thin/young market).
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.2.0"`). Fill every structured field completely (`bias`, `setup_quality`, `key_levels`, `invalidation`, `size_hint`, `facts`, `missing_data`). Brevity applies to the prose only:
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
- out(`${a.name.padEnd(24)} ${a.tier.padEnd(9)} ${(a.paused ? 'yes' : 'no').padEnd(7)} ${new Date(a.createdAt).toISOString().slice(0, 19)} ${a.note ?? ''}`);
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
+ }