dexterm-cli 0.1.4 → 0.1.5

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 (63) hide show
  1. package/dist/packages/cli/src/agent-paper.js +60 -0
  2. package/dist/packages/cli/src/analyst/backend.js +183 -0
  3. package/dist/packages/cli/src/analyst/codex.js +301 -0
  4. package/dist/packages/cli/src/analyst/session.js +9 -2
  5. package/dist/packages/cli/src/app-help-gen.js +15 -6
  6. package/dist/packages/cli/src/doctor.js +58 -2
  7. package/dist/packages/cli/src/live/dev-history.js +99 -0
  8. package/dist/packages/cli/src/live/newpairs.js +12 -0
  9. package/dist/packages/cli/src/live/screens/token-live.js +11 -1
  10. package/dist/packages/cli/src/live/token.js +1 -0
  11. package/dist/packages/cli/src/main.js +122 -8
  12. package/dist/packages/cli/src/mcp-follow.js +37 -7
  13. package/dist/packages/cli/src/record-cmd.js +2 -0
  14. package/dist/packages/cli/src/rules.js +25 -2
  15. package/dist/packages/cli/src/screens/analyst-session.js +5 -3
  16. package/dist/packages/cli/src/screens/analyst.js +4 -3
  17. package/dist/packages/cli/src/version.js +1 -1
  18. package/dist/packages/custom/src/rules.js +2 -2
  19. package/dist/packages/custom/src/schema.js +5 -1
  20. package/dist/packages/data/src/fixtures.js +28 -6
  21. package/dist/packages/data/src/index.js +3 -0
  22. package/dist/packages/data/src/sources/jupiter.js +1 -0
  23. package/dist/packages/data/src/trade/agent/paper-desk.js +336 -0
  24. package/dist/packages/data/src/trade/agent/rendezvous.js +161 -0
  25. package/dist/packages/mcp/assets/kb/app-help/agent-paper-trade.md +38 -0
  26. package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +1 -1
  27. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +1 -1
  28. package/dist/packages/mcp/assets/kb/app-help/dev-history.md +32 -0
  29. package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +1 -1
  30. package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +4 -1
  31. package/dist/packages/mcp/assets/kb/app-help/screen-token.md +1 -0
  32. package/dist/packages/mcp/assets/kb/app-help/use-codex-analyst.md +31 -0
  33. package/dist/packages/mcp/assets/kb/docs/docs-agents-agent-paper-trading.md +50 -0
  34. package/dist/packages/mcp/assets/kb/docs/docs-agents-directory-metadata-prepared-not-submitted.md +36 -0
  35. package/dist/packages/mcp/assets/kb/docs/docs-agents-install-in-one-command.md +2 -0
  36. package/dist/packages/mcp/assets/kb/docs/docs-analyst-if-claude-is-missing.md +1 -1
  37. package/dist/packages/mcp/assets/kb/docs/docs-analyst-use-codex-instead-of-claude.md +35 -0
  38. package/dist/packages/mcp/assets/kb/docs/docs-beginners-macos-or-linux-npm.md +1 -1
  39. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
  40. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +1 -1
  41. package/dist/packages/mcp/assets/kb/docs/docs-configuration-environment-variables.md +2 -0
  42. package/dist/packages/mcp/assets/kb/docs/docs-configuration-rules-engine.md +1 -0
  43. package/dist/packages/mcp/assets/kb/docs/docs-configuration-the-analyst-s-launch.md +2 -0
  44. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +13 -3
  45. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +1 -1
  46. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -2
  47. package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +0 -2
  48. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-letting-an-agent-paper-trade-experimental.md +32 -0
  49. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-what-the-live-path-keeps.md +1 -1
  50. package/dist/packages/mcp/assets/kb/docs/docs-screens-dev-history.md +44 -0
  51. package/dist/packages/mcp/assets/kb/docs/docs-screens-how-well-it-predicts-measured-held-out.md +46 -0
  52. package/dist/packages/mcp/assets/kb/docs/docs-spectator-follow-over-mcp-in-the-running-terminal.md +1 -1
  53. package/dist/packages/mcp/assets/kb/docs/docs-troubleshooting-doctor-lines.md +2 -0
  54. package/dist/packages/mcp/src/index.js +5 -2
  55. package/dist/packages/mcp/src/paper-agent.js +92 -0
  56. package/dist/packages/mcp/src/server.js +206 -12
  57. package/dist/packages/mcp/src/spectator-sink.js +73 -4
  58. package/dist/packages/mcp/src/user-content.js +1 -0
  59. package/dist/packages/recorder/src/dev-history.js +129 -0
  60. package/dist/packages/recorder/src/index.js +1 -0
  61. package/dist/packages/recorder/src/recorder.js +23 -0
  62. package/dist/packages/recorder/src/schema.js +11 -0
  63. package/package.json +1 -1
@@ -17,14 +17,15 @@ import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import readline from 'node:readline';
19
19
  import { fileURLToPath } from 'node:url';
20
- import { createDataLayer, resolveRpcUrl, redactSecrets, defaultDbPath, replayTransport, FixtureStore, extractLinks, bestPairByMint, safeFetch, fetchPage, pageSignals, domainAgeViaRdap, githubRepoActivity, parseHttpUrl, primaryLinks, linkFeatures, gradeLinks, DEFAULT_LINK_RUBRIC, LINK_RUBRIC_MEASURED, parseCoinTarget, resolveCoinTarget, isBareOpen, validateAnalystAction, serializeActionLine, ANALYST_ACTION_TYPES, ACTION_FILE_ENV, ACTION_TOKEN_ENV, } from '../../data/src/index.js';
20
+ import { createDataLayer, resolveRpcUrl, redactSecrets, defaultDbPath, replayTransport, FixtureStore, extractLinks, bestPairByMint, safeFetch, fetchPage, pageSignals, domainAgeViaRdap, githubRepoActivity, parseHttpUrl, primaryLinks, linkFeatures, gradeLinks, DEFAULT_LINK_RUBRIC, LINK_RUBRIC_MEASURED, parseCoinTarget, resolveCoinTarget, isBareOpen, validateAnalystAction, serializeActionLine, ANALYST_ACTION_TYPES, ACTION_FILE_ENV, ACTION_TOKEN_ENV, lamportsToSol, rawToTokens, } from '../../data/src/index.js';
21
21
  import { buildDossier } from "./dossier.js";
22
22
  import { firewall } from "./firewall.js";
23
23
  import { OverlayStore, dextermHome, KB_RULE } from "./user-content.js";
24
- import { resolveSpectatorSink } from "./spectator-sink.js";
24
+ import { resolveSpectatorSink, readFollowScreen, FOLLOW_NAV_ACTIONS } from "./spectator-sink.js";
25
+ import { resolvePaperAgentDesk } from "./paper-agent.js";
25
26
  import { loadAppHelp, getAppHelp, loadDocsKb, getDocs, searchHelp } from "./app-help.js";
26
27
  import { assembleTokenDossier, computeBoard, recentNarrativeRows, loadDefaultLexicon, summaryText, buildDigest, tokenRugScorer, h1Model, runBacktest } from '../../packs/src/index.js';
27
- import { exportRecorded, openHistoryReadOnly } from '../../recorder/src/index.js';
28
+ import { creatorOf, devHistoryLine, exportRecorded, openHistoryReadOnly, queryDevHistory, SERIAL_RUGGER_MIN } from '../../recorder/src/index.js';
28
29
  // The default rug-probability scorer (task-1356 Tier 4). H1 is the PRE-REGISTERED heuristic; it is the
29
30
  // honest day-one model because no logistic model can be trained until the recorder has ~2 weeks of
30
31
  // outcome-labelled history (AI-SUITE-DESIGN §4.3). When a trained `rug-lr-v1` model JSON is shipped it is
@@ -227,7 +228,8 @@ export const TOOLS = [
227
228
  { name: 'get_trades', description: 'Recent trades of the most liquid pool (GeckoTerminal tape) with buy/sell aggregates, distinct wallets, and the share of volume from the two most active wallets (wash signal).', inputSchema: { type: 'object', properties: { mint: { type: 'string' }, window: { type: 'string', enum: ['m5', 'h1'] } }, required: ['mint'], additionalProperties: false } },
228
229
  { name: 'get_candles', description: 'Candles plus computed summaries: range, drawdown from high, VWAP, change%. Use the summaries rather than doing arithmetic.', inputSchema: { type: 'object', properties: { mint: { type: 'string' }, interval: { type: 'string', enum: ['1m', '5m', '15m', '1h', '4h', '1d'] }, limit: { type: 'integer', minimum: 1, maximum: 300 } }, required: ['mint'], additionalProperties: false } },
229
230
  { name: 'rugcheck', description: 'RugCheck-style summary: risks[] (firewalled text), normalised score 0..100, lpLockedPct, tokenProgram.', inputSchema: { type: 'object', properties: { mint: { type: 'string' } }, required: ['mint'], additionalProperties: false } },
230
- { name: 'get_dev_history', description: 'Creator address and prior-launch forensics for this mint. Tier-1 fields (prior rugs, bundles, snipers) need a Helius/Birdeye key and are null on the keyless tier; the response says so.', inputSchema: { type: 'object', properties: { mint: { type: 'string' } }, required: ['mint'], additionalProperties: false } },
231
+ { name: 'get_dev_history', description: 'Creator address and prior-launch forensics for this mint. Prior launches and rugs come from dexterm\'s own recorded history when it has the creator (tier "local-history", see dev_history); bundles and snipers need a Helius/Birdeye key and are null on the keyless tier; the response says so.', inputSchema: { type: 'object', properties: { mint: { type: 'string' } }, required: ['mint'], additionalProperties: false } },
232
+ { name: 'dev_history', description: 'DEV HISTORY ("has this deployer rugged before?"): the creator wallet\'s track record from dexterm\'s OWN recorded history, keyless. Prior launches the recorder linked to the wallet, their code-only outcome labels (rug / dead / fade / flat / runner; 72 h preferred; keyless-proxy tier), how fast the dev sold its own bag, the serial_rugger flag (>= 2 labelled rugs). Every number carries its source, sample size and age; "unknown" when there is no record. A risk flag from observed history, never a certificate: small samples are common, say the n. Pass a mint (the creator is resolved from the record, Jupiter `dev`, then GoPlus) or a creator wallet.', inputSchema: { type: 'object', properties: { mint: { type: 'string' }, creator: { type: 'string' } }, additionalProperties: false } },
231
233
  { name: 'narrative_board', description: 'The NARRATIVE BOARD (task-1356): what is running and what is working across the tokens dexterm has recorded. Deterministic lexicon clustering of recorded launches + trending tokens into narratives (dogs, cats, ai-agents, quantum, …), per narrative: tokens, new launches 1h/24h, 1h/24h volume, pace, median change, top runners, and a documented "what is working" score. No model in the hot path, no token cost; names are matched against a versioned lexicon (X/Telegram-only narratives are missed; the honest unclassified share is shown). window: 1h (default) or 24h; set summary:true for the deterministic spoken morning summary.', inputSchema: { type: 'object', properties: { window: { type: 'string', enum: ['1h', '24h'] }, limit: { type: 'integer', minimum: 1, maximum: 50 }, summary: { type: 'boolean' } }, additionalProperties: false } },
232
234
  { name: 'rug_backtest', description: 'Backtest the rug model or the deterministic pack verdicts against RECORDED outcomes (task-1356). Scores the chosen subject over the recorder history at a horizon and reports HONEST metrics: base rate, precision/recall, Brier (vs base-rate Brier), AUC, a 5-bucket calibration table, a per-feed breakdown, and the worst misses/false-alarms with their t0 numbers. kind: "heuristic" (the shipped H1 rug model), "pack_verdicts" (recorded pack rug_p). Leakage is enforced by construction (features are the t0 snapshot, labels are after t0). Returns a note when there is not enough recorded history yet — it never fabricates numbers. Read-only; no model, no token cost.', 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'] } } }, additionalProperties: false } },
233
235
  { name: 'kb_lookup', description: 'Fetch one knowledge-base entry by id (mechanics, patterns, glossary). Entries carry sources, date, confidence and owner. KB never holds facts about a specific live token.', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: false } },
@@ -265,9 +267,26 @@ export const TOOLS = [
265
267
  { name: 'set_intensity', description: 'PROPOSE the visual intensity the user confirms with a keypress (persisted, undoable). `intensity`: "off", "normal" or "fever".', inputSchema: { type: 'object', properties: { intensity: { type: 'string', enum: ['off', 'normal', 'fever'] } }, required: ['intensity'], additionalProperties: false } },
266
268
  // task-1413 r2: voice speed/name are NOT analyst actions. The `/voice` config path is DENIED_FOR_MODELS —
267
269
  // only the keyboard (`<`/`>`, the voice picker) sets it. There is deliberately no set_voice_* tool here.
270
+ // ---- PAPER agent-trading tools (task-1443). They act ONLY on the SIMULATED paper ledger, inside a mandate
271
+ // the USER armed; they cannot sign, send, touch a wallet/key, or change a cap. They refuse unless the running
272
+ // app has armed a mandate AND the user enabled agent paper trading; pause / take-over / stop halts them. Every
273
+ // call is journaled and visible in the spectator view. Figures are SIMULATED — never present them as a result.
274
+ { name: 'paper_open_ticket', description: 'Open a SIMULATED (paper) trade ticket for a mint and side, inside the mandate the user armed. Inert until confirmed; nothing is bought or sold here. Needs agent paper trading enabled in the running app. `side` is "buy" or "sell". Paper only — it can never sign or send a real transaction, or change a cap.', inputSchema: { type: 'object', properties: { mint: { type: 'string' }, symbol: { type: 'string' }, decimals: { type: 'integer', minimum: 0, maximum: 18 }, side: { type: 'string', enum: ['buy', 'sell'] } }, required: ['mint', 'symbol', 'decimals', 'side'], additionalProperties: false } },
275
+ { name: 'paper_size_ticket', description: 'Set the size of an open paper ticket. For a buy, `usd` is the simulated size and is REFUSED if it exceeds the mandate per-trade / per-coin / total caps (the agent can never raise a cap). For a sell, `fraction` is the share of the held paper position in (0, 1]. Paper only.', inputSchema: { type: 'object', properties: { ticketId: { type: 'string' }, usd: { type: 'number' }, fraction: { type: 'number' } }, required: ['ticketId'], additionalProperties: false } },
276
+ { name: 'paper_confirm', description: 'Confirm a sized paper ticket: re-checks the caps and the pause/stop control, fetches a fresh keyless quote and books the fill into the SIMULATED wallet only. It reports the paper fill and the resulting paper position. It never signs or sends a real transaction. Simulated — label the figures PAPER, never as a result.', inputSchema: { type: 'object', properties: { ticketId: { type: 'string' } }, required: ['ticketId'], additionalProperties: false } },
277
+ { name: 'paper_close', description: 'Close a fraction of a held SIMULATED (paper) position in one step. `fraction` is the share in (0, 1] (default 1 = all). Books a paper sell only; never signs or sends. Simulated.', inputSchema: { type: 'object', properties: { mint: { type: 'string' }, fraction: { type: 'number' } }, required: ['mint'], additionalProperties: false } },
278
+ { name: 'paper_positions', description: 'Read the SIMULATED (paper) positions: cost basis, realized paper PnL and token balance per mint. Read-only. Figures are simulated and must be labelled PAPER, never shown as a result.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
279
+ { name: 'paper_pnl', description: 'Read the SIMULATED (paper) PnL snapshot: paper balance, realized paper PnL, cost deployed, per-position rows and the mandate caps in force. Read-only. All figures are simulated — label them PAPER and never show them as a result.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
268
280
  ];
269
- /** The read-only tool surface: every tool except the UI-action ones (task-1313). The only tools a skill may name (task-1377). */
270
- export const READ_ONLY_TOOLS = TOOLS.map((t) => String(t.name)).filter((n) => !ANALYST_ACTION_TYPES.includes(n));
281
+ /** PAPER agent-trading tools (task-1443). The mutating ones act on the simulated ledger inside the armed mandate. */
282
+ export const PAPER_WRITE_TOOLS = ['paper_open_ticket', 'paper_size_ticket', 'paper_confirm', 'paper_close'];
283
+ export const PAPER_READ_TOOLS = ['paper_positions', 'paper_pnl'];
284
+ const PAPER_TOOLS = new Set([...PAPER_WRITE_TOOLS, ...PAPER_READ_TOOLS]);
285
+ /**
286
+ * The read-only tool surface: every tool except the UI-action ones (task-1313) and the paper WRITE tools
287
+ * (task-1443). The only tools a skill may name (task-1377); the paper read tools stay here (they cannot trade).
288
+ */
289
+ export const READ_ONLY_TOOLS = TOOLS.map((t) => String(t.name)).filter((n) => !ANALYST_ACTION_TYPES.includes(n) && !PAPER_WRITE_TOOLS.includes(n));
271
290
  const SKILL_STORE = new OverlayStore({ kind: 'skill', bundled: [{ dir: SKILLS_DIR, layout: 'flat' }], readTools: READ_ONLY_TOOLS, home: () => dextermHome(process.env), onSkip: logSkip('skill') });
272
291
  /** Read the action allow-list the CLI put in screen_state.actions (defensive: any shape → empty lists). */
273
292
  function allowFrom(screenState) {
@@ -290,6 +309,28 @@ const err = (e) => redactSecrets(e instanceof Error ? e.message : String(e));
290
309
  const ageOf = (fetchedAt, source) => ({ fetchedAt: fetchedAt ?? null, ageSec: fetchedAt ? Math.round((Date.now() - fetchedAt) / 1000) : null, source: source ?? null });
291
310
  /** A freshly assembled dossier is reused for a few minutes per mint+interval (task-1314). */
292
311
  const DOSSIER_TTL_MS = 30_000; // was 180 s: a 3-minute-old price/holder count is not what the analyst should reason from
312
+ /** A JSON-safe view of a paper fill (task-1443): the ledger carries lamports as bigint; convert for the wire. */
313
+ function paperFillView(r) {
314
+ return {
315
+ id: r.id, t: r.t, mint: r.mint, symbol: r.symbol, side: r.side, paper: true,
316
+ solDelta: lamportsToSol(r.solDelta), tokens: rawToTokens(r.tokenDelta < 0n ? -r.tokenDelta : r.tokenDelta, r.decimals),
317
+ priceSol: r.priceSol, priceUsd: r.priceUsd, priceImpactPct: r.priceImpactPct, slippageBps: r.slippageBps,
318
+ feeSol: lamportsToSol(r.feeLamports), realizedSol: lamportsToSol(r.realizedLamports), route: r.route,
319
+ };
320
+ }
321
+ /**
322
+ * task-1443: the paper wiring for a `dexterm mcp` process. Agent paper trading is for an EXTERNAL agent the user
323
+ * connected (Claude Code, Cursor, a bot) only: a server the app itself spawned — the in-app analyst or the scan,
324
+ * marked by the UI-action channel or the spectator-file env — never gets a desk, so the in-app analyst's only
325
+ * write-shaped tool stays `open_ticket`. Write calls also need a spectator surface watching.
326
+ */
327
+ export function paperWiring(env, data, spectator) {
328
+ const spawnedByApp = Boolean(env[ACTION_FILE_ENV] || env.DEXTERM_SPECTATOR_FILE);
329
+ return {
330
+ desk: spawnedByApp ? async () => null : () => resolvePaperAgentDesk(data, env),
331
+ watched: () => spectator.active(),
332
+ };
333
+ }
293
334
  export class McpTools {
294
335
  data;
295
336
  screenState;
@@ -298,15 +339,38 @@ export class McpTools {
298
339
  dbPath;
299
340
  narrativeDb = null;
300
341
  actionSeq = 0;
342
+ /** Mints this server returned from screener reads (bounded; task-1443 follow-mode nav targets). */
343
+ surfaced = new Set();
301
344
  /** mint+interval -> last assembled dossier; the data layer caches the underlying fetches too. */
302
345
  dossierMemo = new Map();
303
- constructor(data, screenState, channel = null, dbPath = null) {
346
+ /** Resolve the paper agent desk (task-1443); null unless the app enabled agent paper trading on an armed mandate. */
347
+ paperDesk;
348
+ /** True while a spectator surface is watching this server (task-1443: paper WRITE calls refuse otherwise). */
349
+ watched;
350
+ constructor(data, screenState, channel = null, dbPath = null, paper = null) {
304
351
  this.data = data;
305
352
  this.screenState = screenState;
306
353
  this.channel = channel;
307
354
  this.dbPath = dbPath && dbPath !== ':memory:' ? dbPath : null;
355
+ this.paperDesk = paper?.desk ?? (async () => null);
356
+ this.watched = paper?.watched ?? (() => false);
308
357
  }
309
358
  /** Open (once) a read-only handle on the recorder DB; null when there is none (replay / :memory:). */
359
+ /** 0.1.5 dev history: the recorded track record for a mint's creator (recorder link first, else the hint). */
360
+ localDevHistory(mint, hint) {
361
+ const db = this.historyDb();
362
+ if (!db)
363
+ return null;
364
+ const creator = creatorOf(db, mint)?.creator ?? (hint && BASE58.test(hint) ? hint : null);
365
+ if (!creator)
366
+ return null;
367
+ try {
368
+ return { creator, report: queryDevHistory(db, { creator, mint }) };
369
+ }
370
+ catch {
371
+ return null;
372
+ }
373
+ }
310
374
  historyDb() {
311
375
  if (!this.dbPath)
312
376
  return null;
@@ -328,11 +392,23 @@ export class McpTools {
328
392
  * data layer and never trades. Returns `{ error }` (with no write) when validation fails.
329
393
  */
330
394
  uiAction(type, args) {
331
- const res = validateAnalystAction({ type, ...args }, allowFrom(this.screenState()));
395
+ const ss = this.screenState();
396
+ const allow = allowFrom(ss);
397
+ // task-1443: in follow mode the agent picks coins from its own screener reads, which the terminal may not be
398
+ // showing yet; those surfaced mints are valid view-only targets too (same data source as the on-screen rows).
399
+ if (ss.source === 'follow')
400
+ allow.mints = [...new Set([...allow.mints, ...this.surfaced])];
401
+ const res = validateAnalystAction({ type, ...args }, allow);
332
402
  if (!res.ok)
333
403
  return { error: res.error };
334
- if (!this.channel)
404
+ if (!this.channel) {
405
+ // task-1443: a terminal following this server (`dexterm --spectate-mcp`) applies a VALIDATED nav call from
406
+ // the spectator record itself, and only while the agent still has control — no action channel is needed.
407
+ // View-only moves only: anything that would change user data (watchlist, ticket) or settings stays unavailable.
408
+ if (ss.source === 'follow' && FOLLOW_NAV_ACTIONS.includes(type))
409
+ return { ok: true, requested: res.label, note: 'The following dexterm terminal shows this; the user can pause or take over at any time. You cannot confirm, size, or send a trade.' };
335
410
  return { error: 'UI actions are not available in this context (no action channel)' };
411
+ }
336
412
  try {
337
413
  this.channel.append(serializeActionLine(this.channel.token, ++this.actionSeq, res.action));
338
414
  }
@@ -341,6 +417,51 @@ export class McpTools {
341
417
  }
342
418
  return { ok: true, requested: res.label, note: 'The app applies this and shows "analyst did X". You cannot confirm, size, or send a trade.' };
343
419
  }
420
+ /**
421
+ * task-1443: a PAPER agent-trading tool. Dispatches to the PaperAgentDesk, which books ONLY the simulated
422
+ * ledger inside the user-armed mandate, re-checks the pause/stop control and the caps on every call, and
423
+ * journals everything. Returns `{ error }` when agent paper trading is disabled or a gate/cap refuses it;
424
+ * it can never sign, send, touch a wallet/key, or change a cap.
425
+ */
426
+ async paperTool(name, args) {
427
+ const desk = await this.paperDesk();
428
+ if (!desk)
429
+ return { error: 'agent paper trading is not enabled — the user starts dexterm with `--spectate-mcp --agent-paper <mandate-id>` on a mandate they armed (paper only). No MCP tool can sign or send.', reason: 'not-enabled' };
430
+ // Every agent paper trade must be visible: a WRITE call refuses unless the app's spectator view is watching
431
+ // this server (it is, while `--spectate-mcp` runs and the user has not stopped it).
432
+ if (PAPER_WRITE_TOOLS.includes(name) && !this.watched()) {
433
+ return { error: 'agent paper trading needs the spectator view open in the running dexterm so every call is visible', reason: 'not-watched' };
434
+ }
435
+ try {
436
+ switch (name) {
437
+ case 'paper_open_ticket': {
438
+ const t = desk.open({ mint: String(args.mint ?? ''), symbol: String(args.symbol ?? ''), decimals: Number(args.decimals), side: args.side === 'sell' ? 'sell' : 'buy' });
439
+ return { paper: true, ticket: t, note: 'PAPER ticket opened (simulated). Size it, then confirm. Nothing is signed or sent.' };
440
+ }
441
+ case 'paper_size_ticket': {
442
+ const t = desk.size(String(args.ticketId ?? ''), { usd: typeof args.usd === 'number' ? args.usd : undefined, fraction: typeof args.fraction === 'number' ? args.fraction : undefined });
443
+ return { paper: true, ticket: t };
444
+ }
445
+ case 'paper_confirm': {
446
+ const r = await desk.confirm(String(args.ticketId ?? ''));
447
+ return { paper: true, fill: paperFillView(r.fill), position: r.position, note: 'SIMULATED paper fill — not a result; label it PAPER.' };
448
+ }
449
+ case 'paper_close': {
450
+ const r = await desk.close({ mint: String(args.mint ?? ''), fraction: typeof args.fraction === 'number' ? args.fraction : undefined });
451
+ return { paper: true, fill: paperFillView(r.fill), position: r.position, note: 'SIMULATED paper close.' };
452
+ }
453
+ case 'paper_positions': return { paper: true, positions: desk.positions() };
454
+ case 'paper_pnl': return { ...desk.pnl() };
455
+ default: return { error: `unknown paper tool ${name}` };
456
+ }
457
+ }
458
+ catch (e) {
459
+ // A PaperDeskError carries a stable reason (cap name / paused / stopped / takeover / mandate state); the
460
+ // spectator view sees this as a 'refused' call, and the desk already journaled it.
461
+ const reason = e.reason;
462
+ return { error: err(e), ...(typeof reason === 'string' ? { reason } : {}) };
463
+ }
464
+ }
344
465
  /**
345
466
  * task-1413: a read-only view of what the user currently sees. It only reflects the fields the CLI put in
346
467
  * screen_state (screen, selected coin, and the `app` block of theme/priceMode/voice/scan/calm/alerts). The
@@ -461,6 +582,8 @@ export class McpTools {
461
582
  async call(name, args = {}) {
462
583
  if (ANALYST_ACTION_TYPES.includes(name))
463
584
  return this.uiAction(name, args);
585
+ if (PAPER_TOOLS.has(name))
586
+ return this.paperTool(name, args);
464
587
  switch (name) {
465
588
  case 'screen_state':
466
589
  return this.screenState();
@@ -472,6 +595,12 @@ export class McpTools {
472
595
  if (!disc.mints.length)
473
596
  return { preset, rows: [], errors: disc.errors };
474
597
  const enr = await this.data.store.enrich(disc.mints.slice(0, limit), { price: false });
598
+ for (const t of enr.rows) {
599
+ this.surfaced.delete(t.mint);
600
+ this.surfaced.add(t.mint);
601
+ }
602
+ while (this.surfaced.size > 200)
603
+ this.surfaced.delete(this.surfaced.values().next().value);
475
604
  return {
476
605
  preset, now: Date.now(),
477
606
  rows: enr.rows.map((t) => ({
@@ -724,12 +853,69 @@ export class McpTools {
724
853
  mintAuthority = (await this.data.detail.mintInfo(mint)).value.mintAuthority;
725
854
  }
726
855
  catch { /* ignore */ }
856
+ // 0.1.5 dev history: dexterm's own recorded launches for this creator fill prior launches / rugs keylessly.
857
+ const local = this.localDevHistory(mint, creators[0] ?? null);
858
+ if (local && local.report.labelled.n > 0) {
859
+ return {
860
+ mint, creator: local.creator, creators: creators.length ? creators : [local.creator], mintAuthorityAddress: mintAuthority,
861
+ devHistory: { launches: local.report.launches.n, rugged: local.report.labelled.rug, bestAth: null, labelled: local.report.labelled.n, source: local.report.source, ageSec: local.report.labelled.ageSec },
862
+ snipers: null, bundledPct: null, bundleWallets: null,
863
+ tier: 'local-history', note: `prior launches and rugs from dexterm's recorded history (${devHistoryLine(local.report)}); bundles/snipers need a Helius/Birdeye key. Call dev_history for the detail.`,
864
+ };
865
+ }
727
866
  return {
728
- mint, creator: creators[0] ?? null, creators, mintAuthorityAddress: mintAuthority,
867
+ mint, creator: creators[0] ?? local?.creator ?? null, creators, mintAuthorityAddress: mintAuthority,
729
868
  devHistory: null, snipers: null, bundledPct: null, bundleWallets: null,
730
869
  tier: 'needs-key', note: goplusErr ? `prior-launch forensics need a Helius/Birdeye key; goplus: ${goplusErr}` : 'prior-launch forensics (rugs, bundles, snipers) need a Helius/Birdeye key and are not available on the keyless tier',
731
870
  };
732
871
  }
872
+ case 'dev_history': {
873
+ const mint = typeof args.mint === 'string' ? args.mint.trim() : '';
874
+ let creator = typeof args.creator === 'string' ? args.creator.trim() : '';
875
+ if (mint && !BASE58.test(mint))
876
+ return { error: 'dev_history: mint must be a base58 address' };
877
+ if (creator && !BASE58.test(creator))
878
+ return { error: 'dev_history: creator must be a base58 wallet address' };
879
+ if (!mint && !creator)
880
+ return { error: 'dev_history needs a mint or a creator wallet' };
881
+ const db = this.historyDb();
882
+ let creatorSource = creator ? 'argument' : null;
883
+ if (!creator && mint && db) {
884
+ const l = creatorOf(db, mint);
885
+ if (l) {
886
+ creator = l.creator;
887
+ creatorSource = `recorder (${l.source}, linked ${Math.round((Date.now() - l.firstT) / 1000)}s ago)`;
888
+ }
889
+ }
890
+ if (!creator && mint) {
891
+ try {
892
+ const r = await this.resolve(mint);
893
+ const dev = r.row?.audit?.devAddress;
894
+ if (dev) {
895
+ creator = dev;
896
+ creatorSource = 'jupiter-tokens dev';
897
+ }
898
+ }
899
+ catch { /* next source */ }
900
+ }
901
+ if (!creator && mint) {
902
+ try {
903
+ const c = (await this.data.detail.security(mint)).value.creators ?? [];
904
+ if (c[0] && BASE58.test(c[0])) {
905
+ creator = c[0];
906
+ creatorSource = 'goplus creators';
907
+ }
908
+ }
909
+ catch { /* unknown */ }
910
+ }
911
+ const base = { mint: mint || null, disclaimer: 'a risk flag from observed history, not a certificate; labels are keyless-proxy and samples are small (say the n)' };
912
+ if (!creator)
913
+ return { ...base, creator: null, status: 'unknown', note: 'no source named this coin\'s creator (recorder, Jupiter dev, GoPlus)' };
914
+ if (!db)
915
+ return { ...base, creator, creatorSource, status: 'unknown', note: 'no recorded history on this machine: the history recorder keeps creators and outcome labels in the --db file (dexterm record run keeps it fed)' };
916
+ const report = queryDevHistory(db, { creator, mint: mint || null });
917
+ return { ...base, creatorSource, ...report, summary: devHistoryLine(report), rule: `serial_rugger = labelled rugs >= ${SERIAL_RUGGER_MIN}` };
918
+ }
733
919
  case 'kb_lookup': {
734
920
  const e = kbAll().find((x) => x.id === args.id);
735
921
  if (!e)
@@ -914,8 +1100,13 @@ function packageVersion() {
914
1100
  export const SERVER_INFO = { name: 'dexterm', version: packageVersion() };
915
1101
  function screenStateFromEnv() {
916
1102
  const raw = process.env.DEXTERM_SCREEN_STATE;
917
- if (!raw)
1103
+ if (!raw) {
1104
+ // task-1443: an external server (the user's own agent) reads the snapshot a following terminal publishes.
1105
+ const follow = readFollowScreen(process.env);
1106
+ if (follow)
1107
+ return { ...follow, source: 'follow' };
918
1108
  return { screen: 'unknown', note: 'the TUI did not pass screen context' };
1109
+ }
919
1110
  try {
920
1111
  return JSON.parse(raw);
921
1112
  }
@@ -952,13 +1143,16 @@ export async function runStdioServer(opts = {}) {
952
1143
  const channel = channelFile && channelToken
953
1144
  ? { token: channelToken, append: (line) => fs.appendFileSync(channelFile, line, { mode: 0o600 }) }
954
1145
  : null;
955
- const tools = new McpTools(data, screenStateFromEnv, channel, dbPathForHistory);
956
1146
  // task-1406 + task-1421: when a running dexterm has a spectator surface open, every tool call is emitted
957
1147
  // as a SpectatorEvent-shaped JSONL line the TUI tails into its hub (where redaction + the stream live).
958
1148
  // The sink is active either because the app spawned us with DEXTERM_SPECTATOR_FILE (the in-app analyst) or
959
1149
  // because the app wrote the ~/.dexterm rendezvous (an external `dexterm mcp` client). When neither holds,
960
1150
  // active() is false and nothing is built or written — no cost when nobody is watching.
961
1151
  const spectator = resolveSpectatorSink(process.env);
1152
+ // task-1443: the paper agent desk, resolved lazily per call from the ~/.dexterm rendezvous the running app
1153
+ // writes when the user enabled agent paper trading on a mandate they armed. null (tools refuse) when it is
1154
+ // not enabled; paper WRITE calls also refuse while no spectator surface is watching.
1155
+ const tools = new McpTools(data, screenStateFromEnv, channel, dbPathForHistory, paperWiring(process.env, data, spectator));
962
1156
  function appendTrace(rec) {
963
1157
  if (TRACE) {
964
1158
  try {
@@ -7,11 +7,16 @@
7
7
  // The record carries NOTHING an agent could act on — it only describes a read/navigation call that
8
8
  // already completed. The file is local and token-marked; raw addresses live only on this loopback channel
9
9
  // and are redacted before anything leaves the machine.
10
- import { appendFileSync, readFileSync, statSync } from 'node:fs';
10
+ import { appendFileSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
12
  import { join } from 'node:path';
13
13
  /** The UI-action tools that move the screen (so follow mode can visibly perform them). */
14
- const NAV_TOOLS = new Set(['open_token', 'show_in_screener', 'switch_feed', 'add_to_watchlist', 'open_ticket']);
14
+ const NAV_TOOLS = new Set(['open_token', 'show_in_screener', 'switch_feed', 'switch_screen', 'add_to_watchlist', 'open_ticket']);
15
+ /**
16
+ * task-1443: the view-only UI tools an EXTERNAL agent may use to move a terminal that follows it over MCP. Nothing
17
+ * here changes user data or settings (no watchlist add, no ticket, no theme/workspace): the screen only moves.
18
+ */
19
+ export const FOLLOW_NAV_ACTIONS = ['open_token', 'show_in_screener', 'switch_feed', 'switch_screen'];
15
20
  function navFor(tool, args) {
16
21
  const s = (v) => (typeof v === 'string' ? v : undefined);
17
22
  if (tool === 'open_token')
@@ -19,7 +24,10 @@ function navFor(tool, args) {
19
24
  if (tool === 'switch_feed')
20
25
  return { screen: 'screener', feed: s(args.feed) };
21
26
  if (tool === 'show_in_screener')
22
- return { screen: 'screener', filter: s(args.filter), highlight: s(args.highlight) };
27
+ return { screen: 'screener', feed: s(args.preset), filter: s(args.filter), highlight: s(args.highlight) };
28
+ // task-1443: the task-1413 view-only screen switch moves a following terminal too (e.g. to positions).
29
+ if (tool === 'switch_screen')
30
+ return s(args.screen) ? { screen: s(args.screen) } : null;
23
31
  if (tool === 'add_to_watchlist')
24
32
  return { screen: 'watchlist', mint: s(args.mint) };
25
33
  if (tool === 'open_ticket')
@@ -46,6 +54,29 @@ function shortMint(args) {
46
54
  const m = typeof args.mint === 'string' ? args.mint : typeof args.symbol === 'string' ? args.symbol : '';
47
55
  return m ? ` ${m.length > 10 ? m.slice(0, 6) + '…' : m}` : '';
48
56
  }
57
+ /**
58
+ * task-1443: a readable, PAPER-labelled summary for an agent paper-trading call, so the activity panel says what
59
+ * the agent did to the simulated ledger (side, size, symbol), not just the tool name. null for any other tool.
60
+ */
61
+ export function paperSummary(tool, args, result) {
62
+ if (!tool.startsWith('paper_'))
63
+ return null;
64
+ const s = (v) => (typeof v === 'string' ? v.slice(0, 16) : '');
65
+ const t = (result.ticket ?? {});
66
+ const f = (result.fill ?? {});
67
+ switch (tool) {
68
+ case 'paper_open_ticket': return `PAPER open ${s(args.side)} ticket ${s(args.symbol)}`.trim();
69
+ case 'paper_size_ticket': return typeof t.usd === 'number' ? `PAPER size ${s(t.symbol)} $${t.usd.toFixed(2)}` : typeof t.fraction === 'number' ? `PAPER size ${s(t.symbol)} ${Math.round(t.fraction * 100)}%` : 'PAPER size';
70
+ case 'paper_confirm':
71
+ case 'paper_close': {
72
+ const sol = typeof f.solDelta === 'number' ? ` ${f.solDelta > 0 ? '+' : ''}${f.solDelta.toFixed(4)} SOL` : '';
73
+ return `PAPER ${tool === 'paper_close' ? 'close' : s(f.side) || 'fill'} ${s(f.symbol)}${sol}`;
74
+ }
75
+ case 'paper_positions': return `PAPER positions (${Array.isArray(result.positions) ? result.positions.length : 0})`;
76
+ case 'paper_pnl': return 'PAPER pnl';
77
+ default: return null;
78
+ }
79
+ }
49
80
  /** Build one spectator record for a completed MCP tool call. */
50
81
  export function recordForToolCall(opts) {
51
82
  const isErr = typeof opts.result.error === 'string';
@@ -55,7 +86,7 @@ export function recordForToolCall(opts) {
55
86
  // `nav` phase is the enforcement boundary that prevents a denied MCP call from moving the owner's TUI.
56
87
  v: 1, seq: opts.seq, t: (opts.now ?? Date.now)(), phase: isErr ? 'refused' : nav ? 'nav' : 'result',
57
88
  agent: opts.agent, transport: 'mcp', tool: opts.tool,
58
- summary: isErr ? `error: ${String(opts.result.error).slice(0, 80)}` : `${opts.tool}${shortMint(opts.args)}`,
89
+ summary: isErr ? `error: ${String(opts.result.error).slice(0, 80)}` : paperSummary(opts.tool, opts.args, opts.result) ?? `${opts.tool}${shortMint(opts.args)}`,
59
90
  ms: opts.ms, dataAgeSec: isErr ? null : maxAgeSec(opts.result), nav, ok: !isErr,
60
91
  ...(opts.token ? { token: opts.token } : {}),
61
92
  };
@@ -88,6 +119,44 @@ function spectatorHome(env) {
88
119
  return env.DEXTERM_HOME && env.DEXTERM_HOME.trim() ? env.DEXTERM_HOME : join(homedir(), '.dexterm');
89
120
  }
90
121
  export function rendezvousPath(env) { return join(spectatorHome(env), MCP_SPECTATOR_RENDEZVOUS); }
122
+ // task-1443: an EXTERNAL `dexterm mcp` server has no DEXTERM_SCREEN_STATE (the running app did not spawn it), so it
123
+ // could never validate a navigation call against "the tokens currently on screen" and every nav tool failed — the
124
+ // following terminal never moved. While a terminal follows over MCP it publishes a reduced snapshot of its screen
125
+ // (screen, visible rows, selection, the action allow-list; no wallet, positions, tickets or app settings) here,
126
+ // refreshed about once a second; the server validates nav calls against it exactly as it does for the in-app analyst.
127
+ /** The follow-mode screen snapshot file name under ~/.dexterm. */
128
+ export const MCP_FOLLOW_SCREEN = 'mcp-screen.json';
129
+ /** A snapshot older than this is ignored (the terminal quit or froze): nav calls then fail closed. */
130
+ export const FOLLOW_SCREEN_MAX_AGE_MS = 10_000;
131
+ export function followScreenPath(env) { return join(spectatorHome(env), MCP_FOLLOW_SCREEN); }
132
+ /** Write the follow-mode snapshot (0600). Best-effort: a write failure only means nav calls fail closed. */
133
+ export function writeFollowScreen(env, snapshot) {
134
+ try {
135
+ writeFileSync(followScreenPath(env), JSON.stringify(snapshot) + '\n', { mode: 0o600 });
136
+ }
137
+ catch { /* fail closed */ }
138
+ }
139
+ /** Remove the follow-mode snapshot (the terminal quit or stopped following). */
140
+ export function clearFollowScreen(env) {
141
+ try {
142
+ rmSync(followScreenPath(env), { force: true });
143
+ }
144
+ catch { /* ignore */ }
145
+ }
146
+ /** Read the follow-mode screen snapshot, or null when absent, malformed or stale. Never throws. */
147
+ export function readFollowScreen(env, now = Date.now()) {
148
+ const path = followScreenPath(env);
149
+ try {
150
+ const st = statSync(path);
151
+ if (now - st.mtimeMs > FOLLOW_SCREEN_MAX_AGE_MS)
152
+ return null;
153
+ const o = JSON.parse(readFileSync(path, 'utf8'));
154
+ return o && typeof o === 'object' && !Array.isArray(o) ? o : null;
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
91
160
  /**
92
161
  * Resolve the spectator sink for a `dexterm mcp` server. DEXTERM_SPECTATOR_FILE (the running app spawned us,
93
162
  * e.g. the in-app analyst) wins and is always active; otherwise the sink watches the rendezvous file under
@@ -84,6 +84,7 @@ export function parseEntryFile(file, origin = 'bundled') {
84
84
  const MONEY_WORDS = new Set([
85
85
  'trade', 'swap', 'sign', 'signer', 'send', 'arm', 'ticket', 'order', 'transfer', 'withdraw', 'buy', 'sell',
86
86
  'confirm', 'execute', 'submit', 'panic', 'cap', 'caps', 'mandate', 'mandates', 'approve', 'pay', 'payment', 'fund',
87
+ 'close', // task-1443: paper_close books a (simulated) sell — a skill may never name it
87
88
  ]);
88
89
  /** Tools that exist in dexterm and are money-adjacent by name, refused even though the server knows them. */
89
90
  export const MONEY_TOOLS = ['open_ticket'];
@@ -0,0 +1,129 @@
1
+ // DEV HISTORY (0.1.5, task-1425): "has this deployer rugged before?" from dexterm's OWN recorded history, keyless.
2
+ // The creator links come from the `creators` table (a keyless source naming the dev when dexterm saw the coin), the
3
+ // outcomes from the recorder's code-only labeler (labels.ts: keyless-proxy tier), the dev-exit timing from the
4
+ // recorded `dev_pct` series. Every number carries its source, sample size and age; no recorded prior launch is
5
+ // `unknown`, never "clean". It is a risk flag from observed history, never a certificate.
6
+ //
7
+ // Point in time: with `asOf`, only links recorded before it and labels already known by then count (a label is known
8
+ // once its observation t1, or t0 + horizon, has passed). The backtest uses this so a coin is never judged on the
9
+ // future (the same leakage rule as export.ts).
10
+ /** Label horizons in preference order: the 72 h label when known, else the longest one known. */
11
+ const PREF_HORIZONS = [259200, 86400, 21600, 7200, 3600];
12
+ /** Dev-exit rule: the dev held more than this % of supply, then this little. */
13
+ export const DEV_EXIT = { heldPct: 0.5, exitPct: 0.1 };
14
+ /** Prior rugs at/over this many = the `serial_rugger` flag (same threshold as the token-dossier pack). */
15
+ export const SERIAL_RUGGER_MIN = 2;
16
+ export const DEV_HISTORY_SOURCE = 'dexterm recorder (keyless creator links + code-only outcome labels)';
17
+ const median = (xs) => {
18
+ if (!xs.length)
19
+ return null;
20
+ const s = [...xs].sort((a, b) => a - b);
21
+ const m = Math.floor(s.length / 2);
22
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
23
+ };
24
+ /** The creator dexterm recorded for `mint`, or null. */
25
+ export function creatorOf(db, mint) {
26
+ try {
27
+ const r = db.prepare('SELECT mint, creator, source, first_t, created_at FROM creators WHERE mint = ?').get(mint);
28
+ return r ? { mint: r.mint, creator: r.creator, source: r.source, firstT: Number(r.first_t), createdAt: r.created_at === null ? null : Number(r.created_at) } : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /** The creator's recorded track record. Never throws: a DB without the tables reads as `unknown`. */
35
+ export function queryDevHistory(db, q) {
36
+ const asOf = q.asOf ?? Date.now();
37
+ const empty = {
38
+ creator: q.creator, status: 'unknown', asOf, source: DEV_HISTORY_SOURCE,
39
+ launches: { n: 0, newestT: null, ageSec: null },
40
+ labelled: { n: 0, rug: 0, dead: 0, fade: 0, flat: 0, runner: 0, horizon: '72h preferred', tier: 'keyless-proxy', newestT: null, ageSec: null },
41
+ rugRate: null, badRate: null, devExit: { n: 0, medianMin: null, within60m: 0 }, serialRugger: false, recent: [],
42
+ };
43
+ let links;
44
+ try {
45
+ links = db.prepare('SELECT mint, first_t, created_at FROM creators WHERE creator = ? AND first_t <= ? AND mint != ? ORDER BY first_t DESC LIMIT ?')
46
+ .all(q.creator, asOf, q.mint ?? '', q.limit ?? 300);
47
+ }
48
+ catch {
49
+ return empty;
50
+ }
51
+ if (!links.length)
52
+ return empty;
53
+ const outStmt = db.prepare("SELECT t0, horizon_sec, t1, label FROM outcomes WHERE mint = ? AND label != 'unobserved' ORDER BY t0 ASC");
54
+ const symStmt = db.prepare('SELECT symbol FROM token_snapshots WHERE mint = ? AND symbol IS NOT NULL ORDER BY t ASC LIMIT 1');
55
+ const devStmt = db.prepare('SELECT t, dev_pct FROM token_snapshots WHERE mint = ? AND dev_pct IS NOT NULL AND t <= ? ORDER BY t ASC LIMIT 400');
56
+ const counts = { rug: 0, dead: 0, fade: 0, flat: 0, runner: 0 };
57
+ let labelledN = 0, labelledNewest = null;
58
+ const exits = [];
59
+ const launches = [];
60
+ for (const l of links) {
61
+ const firstT = Number(l.first_t);
62
+ const createdAt = l.created_at === null ? null : Number(l.created_at);
63
+ const rows = outStmt.all(l.mint);
64
+ // the label of the EARLIEST anchor (closest to launch) at the longest horizon already known as of asOf
65
+ const known = rows.filter((r) => (r.t1 !== null ? Number(r.t1) : Number(r.t0) + Number(r.horizon_sec) * 1000) <= asOf);
66
+ let label = null, horizonSec = null, knownAt = null;
67
+ if (known.length) {
68
+ const t0 = Math.min(...known.map((r) => Number(r.t0)));
69
+ const at = known.filter((r) => Number(r.t0) === t0);
70
+ for (const h of PREF_HORIZONS) {
71
+ const hit = at.find((r) => Number(r.horizon_sec) === h);
72
+ if (hit) {
73
+ label = hit.label;
74
+ horizonSec = h;
75
+ knownAt = hit.t1 !== null ? Number(hit.t1) : t0 + h * 1000;
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ if (label) {
81
+ labelledN++;
82
+ counts[label]++;
83
+ if (knownAt !== null)
84
+ labelledNewest = Math.max(labelledNewest ?? 0, knownAt);
85
+ }
86
+ let devExitMin = null;
87
+ if (q.devExit !== false) {
88
+ const series = devStmt.all(l.mint, asOf);
89
+ const held = series.findIndex((r) => r.dev_pct > DEV_EXIT.heldPct);
90
+ if (held >= 0) {
91
+ const out = series.slice(held + 1).find((r) => r.dev_pct <= DEV_EXIT.exitPct);
92
+ if (out) {
93
+ const from = createdAt ?? Number(series[held].t);
94
+ devExitMin = Math.max(0, (Number(out.t) - from) / 60_000);
95
+ exits.push(devExitMin);
96
+ }
97
+ }
98
+ }
99
+ if (launches.length < 10) {
100
+ const sym = symStmt.get(l.mint)?.symbol ?? null;
101
+ launches.push({ mint: l.mint, symbol: sym, firstT, createdAt, label, horizonSec, devExitMin });
102
+ }
103
+ }
104
+ const newestT = Number(links[0].first_t);
105
+ return {
106
+ ...empty,
107
+ status: 'ok',
108
+ launches: { n: links.length, newestT, ageSec: Math.max(0, Math.round((asOf - newestT) / 1000)) },
109
+ labelled: { ...empty.labelled, n: labelledN, ...counts, newestT: labelledNewest, ageSec: labelledNewest === null ? null : Math.max(0, Math.round((asOf - labelledNewest) / 1000)) },
110
+ rugRate: labelledN ? counts.rug / labelledN : null,
111
+ badRate: labelledN ? (counts.rug + counts.dead) / labelledN : null,
112
+ devExit: { n: exits.length, medianMin: median(exits), within60m: exits.filter((m) => m <= 60).length },
113
+ serialRugger: counts.rug >= SERIAL_RUGGER_MIN,
114
+ recent: launches,
115
+ };
116
+ }
117
+ const ago = (sec) => sec === null ? 'age unknown' : sec < 3600 ? `${Math.max(1, Math.round(sec / 60))}m ago` : sec < 172800 ? `${Math.round(sec / 3600)}h ago` : `${Math.round(sec / 86400)}d ago`;
118
+ /**
119
+ * The one-line summary every surface shows (DEV panel, rug-signal row, MCP note): counts with the sample size and
120
+ * the age, or `unknown` with why. Plain wording: a risk flag from observed history, never "rug confirmed" or "safe".
121
+ */
122
+ export function devHistoryLine(r) {
123
+ if (r.status === 'unknown')
124
+ return 'unknown: no prior launch by this wallet in dexterm\'s record';
125
+ const L = r.labelled;
126
+ if (!L.n)
127
+ return `${r.launches.n} prior launch${r.launches.n === 1 ? '' : 'es'} recorded, none labelled yet (newest ${ago(r.launches.ageSec)})`;
128
+ return `rugged ${L.rug} of ${L.n} labelled prior launches${L.dead ? ` (+${L.dead} dead)` : ''} · ${r.launches.n} recorded · labels ${ago(L.ageSec)}`;
129
+ }
@@ -8,6 +8,7 @@ export * from "./export.js";
8
8
  export * from "./open.js";
9
9
  export * from "./verdicts.js";
10
10
  export * from "./schedule.js";
11
+ export * from "./dev-history.js";
11
12
  export { recorderCli, RECORDER_HELP } from "./cli.js";
12
13
  /** `--no-recorder` / `DEXTERM_RECORDER=0` resolution: the recorder is on in live mode unless either says no. */
13
14
  export function recorderEnabled(env, flagOff) {