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
@@ -0,0 +1,326 @@
1
+ // `dexterm mcp …` — the read-only MCP server plus one-command setup for any agent (task-1405).
2
+ //
3
+ // dexterm mcp run the read-only MCP server on stdio (what the analyst uses and
4
+ // what `claude mcp add dexterm -- dexterm mcp` wires up)
5
+ // dexterm mcp install <client> write/print the MCP config for a client (claude-code, claude-desktop,
6
+ // cursor, codex, openclaw, generic). Shows the exact change, asks first,
7
+ // is idempotent, and backs the file up before editing.
8
+ // dexterm mcp doctor spawn the server over stdio, do the MCP handshake, and list the tools,
9
+ // prompts and resources the agent sees — proof the connection works.
10
+ //
11
+ // install writes nothing without consent: it prints the target file and the exact snippet, then (unless
12
+ // --yes) asks on a TTY; a non-TTY run without --yes prints the plan and stops. --print never writes. The
13
+ // config dir is resolved from the environment; --config FILE overrides it (tests point this at a temp dir),
14
+ // so this command NEVER has to touch the real ~/.claude, Cursor or Claude Desktop config to be exercised.
15
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { spawn } from 'node:child_process';
20
+ import readline from 'node:readline';
21
+ function flag(rest, name) { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; }
22
+ function has(rest, name) { return rest.includes(name); }
23
+ export function defaultCommand(env, useNode) {
24
+ if (!useNode)
25
+ return { command: 'dexterm', args: ['mcp'] };
26
+ // Fallback for when dexterm is not on PATH: run this repo's server bin with the current node.
27
+ return { command: process.execPath, args: [mcpBinPath(), 'mcp'] };
28
+ }
29
+ /** Where this repo's standalone MCP server bin lives (used by the --node command and by `doctor`). */
30
+ export function mcpBinPath() {
31
+ const here = dirname(fileURLToPath(import.meta.url)); // packages/cli/src
32
+ return resolve(here, '..', '..', 'mcp', 'bin', 'dexterm-mcp.mjs');
33
+ }
34
+ function home(env) { return env.HOME || env.USERPROFILE || homedir(); }
35
+ function appSupport(env, platform, app) {
36
+ if (platform === 'darwin')
37
+ return join(home(env), 'Library', 'Application Support', app);
38
+ if (platform === 'win32')
39
+ return join(env.APPDATA || join(home(env), 'AppData', 'Roaming'), app);
40
+ return join(env.XDG_CONFIG_HOME || join(home(env), '.config'), app);
41
+ }
42
+ export const CLIENTS = [
43
+ {
44
+ id: 'claude-code', label: 'Claude Code', format: 'json', keyPath: 'mcpServers',
45
+ path: (env) => join(env.DEXTERM_CWD || process.cwd(), '.mcp.json'),
46
+ note: 'project-scoped .mcp.json in the current directory; or run `claude mcp add dexterm -- dexterm mcp`.',
47
+ },
48
+ {
49
+ id: 'claude-desktop', label: 'Claude Desktop', format: 'json', keyPath: 'mcpServers',
50
+ path: (env, platform) => join(appSupport(env, platform, 'Claude'), 'claude_desktop_config.json'),
51
+ note: 'restart Claude Desktop after editing so it re-reads the config.',
52
+ },
53
+ {
54
+ id: 'cursor', label: 'Cursor', format: 'json', keyPath: 'mcpServers',
55
+ path: (env) => join(home(env), '.cursor', 'mcp.json'),
56
+ note: 'global Cursor MCP config (~/.cursor/mcp.json); a project .cursor/mcp.json works too.',
57
+ },
58
+ {
59
+ id: 'codex', label: 'Codex CLI', format: 'toml', keyPath: 'mcp_servers',
60
+ path: (env) => join(env.CODEX_HOME || join(home(env), '.codex'), 'config.toml'),
61
+ note: 'Codex reads MCP servers from the [mcp_servers.*] tables in ~/.codex/config.toml.',
62
+ },
63
+ {
64
+ id: 'openclaw', label: 'OpenClaw / generic agent', format: 'print', keyPath: 'mcpServers',
65
+ path: () => null,
66
+ note: 'OpenClaw and most agents take a standard mcpServers entry; paste this where your agent reads MCP servers, or pass --config FILE to write a JSON file.',
67
+ },
68
+ {
69
+ id: 'generic', label: 'Generic (print the snippet)', format: 'print', keyPath: 'mcpServers',
70
+ path: () => null,
71
+ note: 'the standard stdio MCP server entry; add it to your client\'s MCP config.',
72
+ },
73
+ ];
74
+ export function clientById(id) { return CLIENTS.find((c) => c.id === id); }
75
+ /** The JSON object a json-format client expects under its server map, for `dexterm`. */
76
+ export function serverEntryJson(cmd) {
77
+ return { command: cmd.command, args: cmd.args };
78
+ }
79
+ /** The TOML block a codex-style client expects. */
80
+ export function serverEntryToml(cmd) {
81
+ const args = cmd.args.map((a) => JSON.stringify(a)).join(', ');
82
+ return `[mcp_servers.dexterm]\ncommand = ${JSON.stringify(cmd.command)}\nargs = [${args}]\n`;
83
+ }
84
+ /** Plan a JSON merge: returns the new file text and whether anything changes. Preserves every other key. */
85
+ export function planJson(existingText, keyPath, entry) {
86
+ let obj = {};
87
+ if (existingText && existingText.trim()) {
88
+ try {
89
+ const parsed = JSON.parse(existingText);
90
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
91
+ obj = parsed;
92
+ }
93
+ catch {
94
+ throw new Error('the existing config is not valid JSON — fix or move it, then re-run');
95
+ }
96
+ }
97
+ const map = (obj[keyPath] && typeof obj[keyPath] === 'object' && !Array.isArray(obj[keyPath])) ? obj[keyPath] : {};
98
+ const prev = map.dexterm;
99
+ const was = prev === undefined ? 'absent' : JSON.stringify(prev) === JSON.stringify(entry) ? 'same' : 'different';
100
+ const next = { ...obj, [keyPath]: { ...map, dexterm: entry } };
101
+ return { text: JSON.stringify(next, null, 2) + '\n', changed: was !== 'same', was };
102
+ }
103
+ /** Plan a TOML merge: append the dexterm block when there is no [mcp_servers.dexterm] yet; never clobber. */
104
+ export function planToml(existingText, block) {
105
+ const cur = existingText ?? '';
106
+ if (cur.includes(block.trim()))
107
+ return { text: cur, changed: false, was: 'same', conflict: false };
108
+ if (/\[mcp_servers\.dexterm\]/.test(cur))
109
+ return { text: cur, changed: false, was: 'different', conflict: true };
110
+ const sep = cur.length && !cur.endsWith('\n\n') ? (cur.endsWith('\n') ? '\n' : '\n\n') : '';
111
+ return { text: cur + sep + block, changed: true, was: 'absent', conflict: false };
112
+ }
113
+ function readLineYesNo(io, prompt) {
114
+ const stdin = io.stdin;
115
+ if (!stdin || !stdin.isTTY)
116
+ return Promise.resolve(false);
117
+ return new Promise((res) => {
118
+ const rl = readline.createInterface({ input: stdin, output: io.stdout });
119
+ rl.question(prompt, (ans) => { rl.close(); res(/^y(es)?$/i.test(ans.trim())); });
120
+ });
121
+ }
122
+ function backupPath(file) {
123
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
124
+ return `${file}.bak-${stamp}`;
125
+ }
126
+ async function install(rest, io) {
127
+ const out = (s) => io.stdout.write(s + '\n');
128
+ const errOut = (s) => io.stderr.write(s + '\n');
129
+ const id = rest.find((a) => !a.startsWith('-'));
130
+ const spec = id ? clientById(id) : undefined;
131
+ if (!spec) {
132
+ errOut(`usage: dexterm mcp install <${CLIENTS.map((c) => c.id).join('|')}> [--config FILE] [--print] [--node] [--yes]`);
133
+ if (id)
134
+ errOut(`unknown client "${id}". known: ${CLIENTS.map((c) => c.id).join(', ')}`);
135
+ return 2;
136
+ }
137
+ const printOnly = has(rest, '--print');
138
+ const useNode = has(rest, '--node');
139
+ const yes = has(rest, '--yes') || has(rest, '-y');
140
+ const cmd = defaultCommand(io.env, useNode);
141
+ const override = flag(rest, '--config');
142
+ const target = override ? resolve(override) : spec.path(io.env, process.platform);
143
+ const forcePrint = spec.format === 'print' && !override;
144
+ // The snippet we would add, always shown.
145
+ const snippet = spec.format === 'toml' ? serverEntryToml(cmd) : JSON.stringify({ [spec.keyPath]: { dexterm: serverEntryJson(cmd) } }, null, 2);
146
+ out(`dexterm MCP server for ${spec.label}:`);
147
+ out('');
148
+ out(indent(snippet));
149
+ out('');
150
+ if (spec.note)
151
+ out(`note: ${spec.note}`);
152
+ if (printOnly || forcePrint) {
153
+ if (forcePrint && !printOnly)
154
+ out('(this client has no single well-known config file — paste the snippet above, or pass --config FILE to write it.)');
155
+ return 0;
156
+ }
157
+ if (!target) {
158
+ out('pass --config FILE to write this to a specific file.');
159
+ return 0;
160
+ }
161
+ const existed = existsSync(target);
162
+ const existingText = existed ? readFileSync(target, 'utf8') : null;
163
+ let plan;
164
+ try {
165
+ plan = spec.format === 'toml'
166
+ ? planToml(existingText, serverEntryToml(cmd))
167
+ : planJson(existingText, spec.keyPath, serverEntryJson(cmd));
168
+ }
169
+ catch (e) {
170
+ errOut(`dexterm mcp install: ${e.message} (${target})`);
171
+ return 1;
172
+ }
173
+ out('');
174
+ out(`target: ${target}${existed ? '' : ' (new file)'}`);
175
+ if (plan.conflict) {
176
+ out('a [mcp_servers.dexterm] section already exists and differs; not modifying it. Review it by hand, or edit it to match the snippet above.');
177
+ return 0;
178
+ }
179
+ if (!plan.changed) {
180
+ out('already configured — nothing to change (idempotent).');
181
+ return 0;
182
+ }
183
+ out(plan.was === 'different' ? 'this UPDATES the existing dexterm entry.' : existed ? 'this ADDS a dexterm entry (your other servers are preserved).' : 'this CREATES the config file with a dexterm entry.');
184
+ if (!yes) {
185
+ const ok = await readLineYesNo(io, 'write this change? [y/N] ');
186
+ if (!ok) {
187
+ out(io.stdin?.isTTY ? 'cancelled; nothing was written.' : 'not a TTY and --yes was not given; nothing written. Re-run with --yes to write, or --print to just print.');
188
+ return io.stdin?.isTTY ? 0 : 0;
189
+ }
190
+ }
191
+ try {
192
+ mkdirSync(dirname(target), { recursive: true });
193
+ if (existed) {
194
+ const bak = backupPath(target);
195
+ copyFileSync(target, bak);
196
+ out(`backed up: ${bak}`);
197
+ }
198
+ writeFileSync(target, plan.text, { mode: 0o600 });
199
+ out(`wrote ${target}.`);
200
+ out(`verify it: dexterm mcp doctor`);
201
+ return 0;
202
+ }
203
+ catch (e) {
204
+ errOut(`dexterm mcp install: could not write ${target}: ${e.message}`);
205
+ return 1;
206
+ }
207
+ }
208
+ function indent(s) { return s.split('\n').map((l) => ' ' + l).join('\n'); }
209
+ async function doctor(rest, io) {
210
+ const out = (s) => io.stdout.write(s + '\n');
211
+ const errOut = (s) => io.stderr.write(s + '\n');
212
+ // Prefer the command install writes (`dexterm mcp`); fall back to this repo's node bin when dexterm is
213
+ // not on PATH (e.g. a dev checkout). Either way this is a real stdio child, not an in-process shortcut.
214
+ const useNode = has(rest, '--node');
215
+ const cmd = useNode ? defaultCommand(io.env, true) : { command: 'dexterm', args: ['mcp'] };
216
+ out(`dexterm mcp doctor — spawning: ${cmd.command} ${cmd.args.join(' ')}`);
217
+ const result = await probe(cmd, io.env).catch((e) => ({ error: e.message }));
218
+ if ('error' in result && !useNode && /ENOENT|not found|spawn/i.test(result.error)) {
219
+ // dexterm not on PATH: retry with the bundled node bin so doctor still verifies the server.
220
+ out(`(dexterm not on PATH; retrying with this checkout's server)`);
221
+ const fallback = defaultCommand(io.env, true);
222
+ out(`dexterm mcp doctor — spawning: ${fallback.command} ${fallback.args.join(' ')}`);
223
+ const r2 = await probe(fallback, io.env).catch((e) => ({ error: e.message }));
224
+ return report(r2, out, errOut);
225
+ }
226
+ return report(result, out, errOut);
227
+ }
228
+ function report(r, out, errOut) {
229
+ if ('error' in r) {
230
+ errOut(`✗ could not reach the MCP server: ${r.error}`);
231
+ return 1;
232
+ }
233
+ const info = r.serverInfo;
234
+ out(`✓ connected to ${info?.name ?? 'dexterm'} ${info?.version ?? ''} over stdio (MCP initialize ok)`);
235
+ out(`tools (${r.tools.length}): ${r.tools.join(', ')}`);
236
+ out(`prompts/skills (${r.prompts.length}): ${r.prompts.join(', ') || '(none)'}`);
237
+ out(`resources: ${r.resources}`);
238
+ out('the connection works; add it to your agent with `dexterm mcp install <client>`.');
239
+ return 0;
240
+ }
241
+ function probe(cmd, env) {
242
+ return new Promise((res, rej) => {
243
+ const child = spawn(cmd.command, cmd.args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...env, DEXTERM_OFFLINE: '1', NO_COLOR: '1' } });
244
+ const timer = setTimeout(() => { try {
245
+ child.kill();
246
+ }
247
+ catch { /* ignore */ } rej(new Error('timed out waiting for the server (10s)')); }, 10_000);
248
+ let stderr = '';
249
+ child.on('error', (e) => { clearTimeout(timer); rej(e); });
250
+ child.stderr.on('data', (d) => { stderr += String(d); });
251
+ const rl = readline.createInterface({ input: child.stdout });
252
+ const pending = new Map();
253
+ let seq = 0;
254
+ const send = (method, params) => {
255
+ const id = ++seq;
256
+ const msg = { jsonrpc: '2.0', id, method, ...(params ? { params } : {}) };
257
+ return new Promise((r) => { pending.set(id, r); child.stdin.write(JSON.stringify(msg) + '\n'); });
258
+ };
259
+ rl.on('line', (line) => {
260
+ if (!line.trim())
261
+ return;
262
+ let msg;
263
+ try {
264
+ msg = JSON.parse(line);
265
+ }
266
+ catch {
267
+ return;
268
+ }
269
+ if (typeof msg.id === 'number' && pending.has(msg.id)) {
270
+ const r = pending.get(msg.id);
271
+ pending.delete(msg.id);
272
+ r(msg.result ?? {});
273
+ }
274
+ });
275
+ (async () => {
276
+ try {
277
+ const init = await send('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'dexterm-doctor', version: '1' } });
278
+ const toolsRes = await send('tools/list');
279
+ const promptsRes = await send('prompts/list');
280
+ const resRes = await send('resources/list');
281
+ clearTimeout(timer);
282
+ try {
283
+ child.stdin.end();
284
+ }
285
+ catch { /* ignore */ }
286
+ try {
287
+ child.kill();
288
+ }
289
+ catch { /* ignore */ }
290
+ const tools = Array.isArray(toolsRes.tools) ? toolsRes.tools.map((t) => String(t.name)) : [];
291
+ const prompts = Array.isArray(promptsRes.prompts) ? promptsRes.prompts.map((p) => String(p.name)) : [];
292
+ const resources = Array.isArray(resRes.resources) ? resRes.resources.length : 0;
293
+ res({ serverInfo: init.serverInfo ?? {}, tools, prompts, resources });
294
+ }
295
+ catch (e) {
296
+ clearTimeout(timer);
297
+ rej(new Error(`${e.message}${stderr ? ` · server stderr: ${stderr.slice(0, 200)}` : ''}`));
298
+ }
299
+ })();
300
+ });
301
+ }
302
+ const USAGE = `dexterm mcp [<command>]
303
+ (no command) run the read-only MCP server on stdio (claude mcp add dexterm -- dexterm mcp)
304
+ install <client> write/print the MCP config for a client and verify what changes
305
+ clients: ${CLIENTS.map((c) => c.id).join(', ')}
306
+ --config FILE write to a specific file (instead of the client's default)
307
+ --print only print the snippet, never write
308
+ --node emit a "node <path> mcp" command (when dexterm is not on PATH)
309
+ --yes write without the confirmation prompt
310
+ doctor spawn the server, do the MCP handshake, and list the tools/prompts/resources`;
311
+ /** `dexterm mcp …`. With no subcommand (or an arg the server consumes, e.g. --replay) it runs the server. */
312
+ export async function runMcp(rest, io) {
313
+ const sub = rest[0];
314
+ if (sub === 'install')
315
+ return install(rest.slice(1), io);
316
+ if (sub === 'doctor')
317
+ return doctor(rest.slice(1), io);
318
+ if (sub === 'help' || sub === '--help' || sub === '-h') {
319
+ io.stdout.write(USAGE + '\n');
320
+ return 0;
321
+ }
322
+ // Anything else (no args, or server flags like --replay) → the stdio server, unchanged.
323
+ const { runStdioServer } = await import('../../mcp/src/index.js');
324
+ await runStdioServer({ argv: rest });
325
+ return 0;
326
+ }
@@ -1,4 +1,5 @@
1
1
  import { RulesEngine, ruleToJson, ruleToText, slug, applyAndJournal, describeActions, } from '../../custom/src/index.js';
2
+ import { alertToDelivery, proposalToDelivery } from "./alerts/format.js";
2
3
  export const RULE_INPUT_MAX = 400;
3
4
  /** The engine observes/evaluates at most this often (the feed itself moves every 10 s; fixtures drift every tick). */
4
5
  export const RULES_TICK_MS = 2_000;
@@ -8,8 +9,11 @@ export function newRulesUi(engine = new RulesEngine()) {
8
9
  return {
9
10
  engine, errors: [], pane: 'rules', selected: 0, selectedProposal: 0, input: null, editingId: null, dry: null, notice: null,
10
11
  log: [], proposals: [], pending: { popups: [], bells: 0, ask: null, ticket: null }, lastTickAt: 0, nextId: 1, fires: 0,
12
+ pendingDeliveries: [],
11
13
  };
12
14
  }
15
+ /** Cap on the push-delivery backlog (a long run with no notifier should never grow unbounded). */
16
+ const MAX_DELIVERIES = 100;
13
17
  /**
14
18
  * Build the engine from `config.rules` plus the rules of installed packs (task-1358 Tier 4: a pack rule whose id the
15
19
  * user already has in config.rules is shadowed by the user's copy). Bad rules are reported, good ones run. Never throws.
@@ -33,7 +37,18 @@ export function rulesUiOf(state) {
33
37
  state.rulesUi = initRules(state.custom, state.now.getTime());
34
38
  return state.rulesUi;
35
39
  }
36
- /** The rows a rule can see: the screener feed (no skeletons) with the wallet-intel top-cluster share attached when the scan ran. */
40
+ /**
41
+ * task-1398: LARP-detector grade as a number so a grade change is a rule (`larp.delta`). Higher = more real.
42
+ * The string verdict is only known here (cli); the engine sees the number. `null` for no verdict (clause false).
43
+ */
44
+ export const LARP_GRADES = { REAL_PRODUCT: 3, EARLY_BUT_REAL: 2, UNVERIFIABLE: 1, LARP: 0 };
45
+ export function larpGrade(verdict) {
46
+ if (!verdict)
47
+ return null;
48
+ const g = LARP_GRADES[verdict];
49
+ return g === undefined ? null : g;
50
+ }
51
+ /** The rows a rule can see: the screener feed (no skeletons) with the wallet-intel top-cluster share and the LARP grade attached. */
37
52
  export function ruleRowsOf(state) {
38
53
  const out = [];
39
54
  const seen = new Set();
@@ -48,7 +63,7 @@ export function ruleRowsOf(state) {
48
63
  if (!r.mint || r.skeleton || seen.has(r.mint))
49
64
  continue;
50
65
  seen.add(r.mint);
51
- out.push(Object.assign(Object.create(null), r, { cluster: clusterOf(r.mint) }));
66
+ out.push(Object.assign(Object.create(null), r, { cluster: clusterOf(r.mint), larp: larpGrade(state.larpByMint?.get(r.mint)?.verdict) }));
52
67
  }
53
68
  return out;
54
69
  }
@@ -91,6 +106,8 @@ function applyOutputs(state, ui, res, rows, now) {
91
106
  if (a.kind === 'alert') {
92
107
  push({ ...base, kind: 'alert', level: a.level, text: a.text });
93
108
  summary.alerts.push(`${f.symbol}: ${a.text}`);
109
+ if (ui.pendingDeliveries.length < MAX_DELIVERIES)
110
+ ui.pendingDeliveries.push(alertToDelivery(a));
94
111
  }
95
112
  else if (a.kind === 'proposal') {
96
113
  const card = a.type === 'ticket'
@@ -99,6 +116,8 @@ function applyOutputs(state, ui, res, rows, now) {
99
116
  ui.proposals.unshift(card);
100
117
  if (ui.proposals.length > MAX_PROPOSALS)
101
118
  ui.proposals.length = MAX_PROPOSALS;
119
+ if (ui.pendingDeliveries.length < MAX_DELIVERIES)
120
+ ui.pendingDeliveries.push(proposalToDelivery(a));
102
121
  summary.proposals += 1;
103
122
  push({ ...base, kind: 'proposal', level: 'info', text: `PROPOSAL ${describeCard(card)} — ctrl+r, tab, ⏎ to act (you confirm there)` });
104
123
  }
@@ -0,0 +1,109 @@
1
+ // Build the skill backtest eval set from a recorder DB (task-1401). Read-only on the DB, no network.
2
+ //
3
+ // One row per coin. The label is the recorder's own outcome at the horizon (keyless proxy). The link evidence is
4
+ // the DexScreener / Jupiter token record the app's cache already holds (ns `ds:token` / `jup:token`), used ONLY
5
+ // when that record was fetched within `maxLagSec` AFTER the row's t0 anchor: the cache keeps the newest fetch per
6
+ // coin, and a coin that kept trading was re-fetched long after t0 (when it may have bought a DexScreener profile),
7
+ // while a coin that died stopped being fetched near t0. Taking a late record would leak the outcome into the
8
+ // evidence, so a coin with no record inside the window is excluded and counted, never back-filled.
9
+ //
10
+ // The rows keep the mint, t0, the label, URL-free link features and the t0 snapshot numbers. No URL, name or
11
+ // symbol is written (the name and symbol are read only to compute the domain-match boolean).
12
+ import { linkFeatures } from '../../data/src/index.js';
13
+ import { LABELER_VERSION } from '../../recorder/src/index.js';
14
+ import { SKILL_EVAL_SCHEMA } from '../../packs/src/index.js';
15
+ function readCache(db, ns, mint) {
16
+ const row = db.prepare('SELECT json, fetched_at FROM cache WHERE ns = ? AND key = ?').get(ns, mint);
17
+ if (!row)
18
+ return null;
19
+ try {
20
+ const j = JSON.parse(row.json);
21
+ const s = j.token?.socials ?? {};
22
+ return { fetched_at: Number(row.fetched_at), socials: { web: s.web ?? null, x: s.x ?? null, tg: s.tg ?? null }, symbol: j.token?.symbol ?? null, name: j.token?.name ?? null };
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
29
+ const bool = (v) => (v === null || v === undefined ? null : Number(v) !== 0);
30
+ export function buildSkillEvalSet(db, opts = {}) {
31
+ const horizon = opts.horizonSec ?? 259_200;
32
+ const lagMs = (opts.maxLagSec ?? 3_600) * 1000;
33
+ const testFrac = opts.testFrac ?? 0.3;
34
+ const excluded = { no_cached_link_record: 0, no_point_in_time_link_record: 0, no_t0_snapshot: 0, chain_verified_label: 0 };
35
+ const mints = db.prepare("SELECT DISTINCT mint FROM outcomes WHERE horizon_sec = ? AND label != 'unobserved'").all(horizon).map((r) => r.mint);
36
+ const outStmt = db.prepare("SELECT t0, label, label_tier FROM outcomes WHERE mint = ? AND horizon_sec = ? AND label != 'unobserved' ORDER BY t0 DESC");
37
+ const snapStmt = db.prepare('SELECT * FROM token_snapshots WHERE mint = ? AND t = ? AND is_t0 = 1 LIMIT 1');
38
+ const rows = [];
39
+ for (const mint of mints) {
40
+ const ds = readCache(db, 'ds:token', mint);
41
+ const jup = readCache(db, 'jup:token', mint);
42
+ if (!ds && !jup) {
43
+ excluded.no_cached_link_record++;
44
+ continue;
45
+ }
46
+ const outs = outStmt.all(mint, horizon);
47
+ let picked = null;
48
+ for (const o of outs) {
49
+ const t0 = Number(o.t0);
50
+ const srcs = [['jupiter', jup], ['dexscreener', ds]]
51
+ .filter((x) => !!x[1] && x[1].fetched_at >= t0 && x[1].fetched_at <= t0 + lagMs);
52
+ if (srcs.length) {
53
+ picked = { t0, label: o.label, label_tier: o.label_tier, srcs };
54
+ break;
55
+ }
56
+ }
57
+ if (!picked) {
58
+ excluded.no_point_in_time_link_record++;
59
+ continue;
60
+ }
61
+ if (picked.label_tier !== 'keyless-proxy') {
62
+ excluded.chain_verified_label++;
63
+ continue;
64
+ }
65
+ const snap = snapStmt.get(mint, picked.t0);
66
+ if (!snap) {
67
+ excluded.no_t0_snapshot++;
68
+ continue;
69
+ }
70
+ // The store's merge order: Jupiter first, then DexScreener (packages/data/src/store.ts).
71
+ const pick = (k) => picked.srcs.map(([, r]) => r.socials[k]).find((v) => v) ?? null;
72
+ const symbol = picked.srcs.map(([, r]) => r.symbol).find((v) => v) ?? snap.symbol;
73
+ const name = picked.srcs.map(([, r]) => r.name).find((v) => v) ?? snap.name;
74
+ const createdAt = num(snap.created_at);
75
+ rows.push({
76
+ mint, t0: picked.t0, split: 'train',
77
+ outcome: picked.label, bad: picked.label === 'rug' || picked.label === 'dead',
78
+ link: linkFeatures({ web: pick('web'), x: pick('x'), tg: pick('tg'), symbol, name }),
79
+ link_sources: picked.srcs.map(([s]) => s),
80
+ link_lag_sec: Math.round((Math.max(...picked.srcs.map(([, r]) => r.fetched_at)) - picked.t0) / 1000),
81
+ rug: {
82
+ freeze_auth: bool(snap.freeze_auth_enabled), mint_auth: bool(snap.mint_auth_enabled),
83
+ dev_pct: num(snap.dev_pct), top10_pct: num(snap.top_holders_pct), organic_score: num(snap.organic_score),
84
+ liquidity_usd: num(snap.liquidity_usd), price_usd: num(snap.price_usd), lp_locked_pct: num(snap.lp_locked_pct),
85
+ launchpad: snap.launchpad ?? null,
86
+ age_sec: createdAt ? Math.max(0, Math.round((picked.t0 - createdAt) / 1000)) : null,
87
+ },
88
+ });
89
+ }
90
+ rows.sort((a, b) => a.t0 - b.t0 || a.mint.localeCompare(b.mint));
91
+ const cutIdx = Math.min(rows.length, Math.floor(rows.length * (1 - testFrac)));
92
+ const cutoff = rows[cutIdx]?.t0 ?? Number.MAX_SAFE_INTEGER;
93
+ for (const r of rows)
94
+ r.split = r.t0 >= cutoff ? 'test' : 'train';
95
+ const train = rows.filter((r) => r.split === 'train');
96
+ const test = rows.filter((r) => r.split === 'test');
97
+ return {
98
+ schema: SKILL_EVAL_SCHEMA,
99
+ built_at: new Date(opts.now ?? Date.now()).toISOString(),
100
+ source: { kind: 'recorder', labeler_version: LABELER_VERSION, db_note: 'the app history DB on the machine that built it (outcomes + cache + token_snapshots); not shipped' },
101
+ horizon_sec: horizon,
102
+ label_rule: `recorder outcome at ${horizon}s (keyless proxy, labeler ${LABELER_VERSION}); bad = rug ∪ dead, ok = flat ∪ fade ∪ runner; unobserved dropped`,
103
+ evidence_rule: `link features from the cached DexScreener/Jupiter token record only when it was fetched within ${lagMs / 1000}s after the row's t0 (latest such t0 per coin); rug-check features from that t0 snapshot`,
104
+ split_rule: `by time: the latest ${Math.round(testFrac * 100)}% of rows by t0 (t0 ≥ ${new Date(cutoff).toISOString()}) are the held-out TEST split, fixed before tuning`,
105
+ cutoff_t0: cutoff,
106
+ counts: { rows: rows.length, train: train.length, test: test.length, bad_train: train.filter((r) => r.bad).length, bad_test: test.filter((r) => r.bad).length, excluded },
107
+ rows,
108
+ };
109
+ }