dexterm-cli 0.2.0 → 0.2.1

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 (138) hide show
  1. package/dist/packages/agents/src/spectator.js +28 -2
  2. package/dist/packages/agents/src/tiers.js +3 -0
  3. package/dist/packages/agents/src/tools.js +3 -0
  4. package/dist/packages/autotrade/src/backtest.js +6 -2
  5. package/dist/packages/autotrade/src/fleet.js +2 -2
  6. package/dist/packages/autotrade/src/index.js +2 -0
  7. package/dist/packages/autotrade/src/rugproof-probe.js +415 -0
  8. package/dist/packages/autotrade/src/rugproof.js +231 -0
  9. package/dist/packages/autotrade/src/runner.js +132 -17
  10. package/dist/packages/autotrade/src/service-install.js +6 -4
  11. package/dist/packages/autotrade/src/sessions.js +16 -7
  12. package/dist/packages/autotrade/src/strategy-stats.js +344 -0
  13. package/dist/packages/autotrade/src/strategy.js +12 -1
  14. package/dist/packages/classifier/src/calibrate.js +138 -0
  15. package/dist/packages/classifier/src/classify.js +107 -0
  16. package/dist/packages/classifier/src/config.js +220 -0
  17. package/dist/packages/classifier/src/dossier.js +95 -0
  18. package/dist/packages/classifier/src/hardware.js +63 -0
  19. package/dist/packages/classifier/src/index.js +18 -0
  20. package/dist/packages/classifier/src/models.js +111 -0
  21. package/dist/packages/classifier/src/providers/heuristic.js +77 -0
  22. package/dist/packages/classifier/src/providers/http.js +63 -0
  23. package/dist/packages/classifier/src/providers/jev-open.js +86 -0
  24. package/dist/packages/classifier/src/providers/llm.js +89 -0
  25. package/dist/packages/classifier/src/providers/systemone-http.js +95 -0
  26. package/dist/packages/classifier/src/questions.js +97 -0
  27. package/dist/packages/classifier/src/registry.js +42 -0
  28. package/dist/packages/classifier/src/snapshot.js +31 -0
  29. package/dist/packages/classifier/src/systemone.js +88 -0
  30. package/dist/packages/classifier/src/types.js +18 -0
  31. package/dist/packages/cli/assets/dash/dash.css +139 -0
  32. package/dist/packages/cli/assets/dash/dash.js +200 -0
  33. package/dist/packages/cli/assets/dash/icon.svg +1 -0
  34. package/dist/packages/cli/assets/dash/index.html +36 -0
  35. package/dist/packages/cli/src/app-help-gen.js +12 -0
  36. package/dist/packages/cli/src/autotrade-cmd/fleet.js +9 -2
  37. package/dist/packages/cli/src/autotrade-cmd/run.js +11 -1
  38. package/dist/packages/cli/src/autotrade-cmd/signal.js +2 -2
  39. package/dist/packages/cli/src/classifier-cmd.js +353 -0
  40. package/dist/packages/cli/src/classifier-live.js +81 -0
  41. package/dist/packages/cli/src/dash/data.js +217 -0
  42. package/dist/packages/cli/src/dash/server.js +92 -0
  43. package/dist/packages/cli/src/dash-cmd.js +113 -0
  44. package/dist/packages/cli/src/doctor.js +34 -0
  45. package/dist/packages/cli/src/live/screens/token-live.js +4 -0
  46. package/dist/packages/cli/src/live/signals.js +8 -0
  47. package/dist/packages/cli/src/main.js +98 -6
  48. package/dist/packages/cli/src/mcp-follow.js +21 -1
  49. package/dist/packages/cli/src/narratives-cmd.js +147 -0
  50. package/dist/packages/cli/src/narratives.js +114 -0
  51. package/dist/packages/cli/src/overlay/model.js +147 -0
  52. package/dist/packages/cli/src/overlay/page.js +146 -0
  53. package/dist/packages/cli/src/overlay/pnl.js +75 -0
  54. package/dist/packages/cli/src/overlay/server.js +170 -0
  55. package/dist/packages/cli/src/overlay-cmd.js +367 -0
  56. package/dist/packages/cli/src/record-cmd.js +82 -11
  57. package/dist/packages/cli/src/rules-cmd.js +2 -1
  58. package/dist/packages/cli/src/rules.js +24 -4
  59. package/dist/packages/cli/src/screens/narrative.js +154 -2
  60. package/dist/packages/cli/src/state.js +1 -0
  61. package/dist/packages/cli/src/version.js +1 -1
  62. package/dist/packages/custom/src/rules.js +5 -1
  63. package/dist/packages/mcp/assets/kb/app-help/classification-models.md +41 -0
  64. package/dist/packages/mcp/assets/kb/app-help/narratives-tracker.md +45 -0
  65. package/dist/packages/mcp/assets/kb/app-help/strategy-dashboard.md +38 -0
  66. package/dist/packages/mcp/assets/kb/app-help/streamer-overlay.md +36 -0
  67. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-rug-proof-entries-experimental.md +52 -0
  68. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +3 -1
  69. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-strategy-dashboard-dexterm-dash.md +37 -0
  70. package/dist/packages/mcp/assets/kb/docs/docs-autotrade.md +3 -1
  71. package/dist/packages/mcp/assets/kb/docs/docs-beginners-before-any-experimental-trading.md +2 -2
  72. package/dist/packages/mcp/assets/kb/docs/docs-beginners-copy-and-paste-without-surprises.md +0 -1
  73. package/dist/packages/mcp/assets/kb/docs/docs-beginners-if-dexterm-says-command-not-found.md +2 -2
  74. package/dist/packages/mcp/assets/kb/docs/docs-beginners-reopen-dexterm-later.md +1 -1
  75. package/dist/packages/mcp/assets/kb/docs/docs-beginners-terminal-shell-prompt-command-output-and-path.md +2 -2
  76. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
  77. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows.md +1 -1
  78. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +2 -0
  79. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +2 -0
  80. package/dist/packages/mcp/assets/kb/docs/docs-classifier-hosted-jev-with-your-own-key.md +34 -0
  81. package/dist/packages/mcp/assets/kb/docs/docs-classifier-how-they-measured-on-dexterm-s-own-history.md +52 -0
  82. package/dist/packages/mcp/assets/kb/docs/docs-classifier-set-up-kev-on-your-own-machine.md +39 -0
  83. package/dist/packages/mcp/assets/kb/docs/docs-classifier-settings.md +35 -0
  84. package/dist/packages/mcp/assets/kb/docs/docs-classifier-the-choices.md +36 -0
  85. package/dist/packages/mcp/assets/kb/docs/docs-classifier-where-it-plugs-in.md +29 -0
  86. package/dist/packages/mcp/assets/kb/docs/docs-classifier-which-model-for-which-machine.md +42 -0
  87. package/dist/packages/mcp/assets/kb/docs/docs-classifier.md +32 -0
  88. package/dist/packages/mcp/assets/kb/docs/docs-cli-invocations.md +1 -1
  89. package/dist/packages/mcp/assets/kb/docs/docs-configuration-headless-recorder-dexterm-record.md +3 -0
  90. package/dist/packages/mcp/assets/kb/docs/docs-copy-trading.md +1 -1
  91. package/dist/packages/mcp/assets/kb/docs/docs-data-sources-recorded-history-and-retention.md +1 -0
  92. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
  93. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +2 -2
  94. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -11
  95. package/dist/packages/mcp/assets/kb/docs/docs-getting-started.md +2 -2
  96. package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +2 -2
  97. package/dist/packages/mcp/assets/kb/docs/docs-larp-paste-a-coin-on-the-web.md +1 -1
  98. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +1 -1
  99. package/dist/packages/mcp/assets/kb/docs/docs-narratives-how-coins-are-grouped.md +39 -0
  100. package/dist/packages/mcp/assets/kb/docs/docs-narratives-limits.md +29 -0
  101. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-is-measured.md +44 -0
  102. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-testing-showed.md +43 -0
  103. package/dist/packages/mcp/assets/kb/docs/docs-narratives-where-to-find-it.md +37 -0
  104. package/dist/packages/mcp/assets/kb/docs/docs-narratives.md +29 -0
  105. package/dist/packages/mcp/assets/kb/docs/docs-overlay-before-you-start.md +33 -0
  106. package/dist/packages/mcp/assets/kb/docs/docs-overlay-limits.md +30 -0
  107. package/dist/packages/mcp/assets/kb/docs/docs-overlay-options.md +36 -0
  108. package/dist/packages/mcp/assets/kb/docs/docs-overlay-security-model.md +36 -0
  109. package/dist/packages/mcp/assets/kb/docs/docs-overlay-set-it-up-in-obs-step-by-step.md +49 -0
  110. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-pnl-scene-paper-and-live.md +34 -0
  111. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-three-scenes.md +35 -0
  112. package/dist/packages/mcp/assets/kb/docs/docs-overlay-what-is-redacted.md +31 -0
  113. package/dist/packages/mcp/assets/kb/docs/docs-overlay.md +25 -0
  114. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading.md +1 -1
  115. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +2 -0
  116. package/dist/packages/mcp/assets/kb/docs/docs-screens-bubble-map.md +1 -1
  117. package/dist/packages/mcp/assets/kb/docs/docs-spectator-streaming-it.md +1 -0
  118. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +1 -1
  119. package/dist/packages/mcp/assets/kb/docs/docs-voice-linux-and-windows.md +2 -2
  120. package/dist/packages/mcp/assets/kb/entries/narratives-tracker.md +45 -0
  121. package/dist/packages/mcp/src/server.js +104 -0
  122. package/dist/packages/mcp/src/spectator-sink.js +27 -0
  123. package/dist/packages/narratives/src/cluster.js +193 -0
  124. package/dist/packages/narratives/src/embed.js +184 -0
  125. package/dist/packages/narratives/src/export.js +60 -0
  126. package/dist/packages/narratives/src/index.js +10 -0
  127. package/dist/packages/narratives/src/label.js +48 -0
  128. package/dist/packages/narratives/src/normalize.js +179 -0
  129. package/dist/packages/narratives/src/recorder.js +93 -0
  130. package/dist/packages/narratives/src/tracker.js +328 -0
  131. package/dist/packages/recorder/src/exit-probes.js +215 -0
  132. package/dist/packages/recorder/src/index.js +1 -0
  133. package/dist/packages/recorder/src/recorder.js +11 -1
  134. package/dist/packages/recorder/src/retention.js +4 -0
  135. package/dist/packages/recorder/src/schema.js +20 -1
  136. package/dist/packages/tui/src/keymap.js +5 -0
  137. package/package.json +3 -1
  138. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +0 -39
@@ -1,3 +1,11 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
1
9
  // dexterm — `dexterm record` (task-1414): a HEADLESS collector that keeps the history recorder fed when
2
10
  // nobody has the TUI open. The recorder (task-1329) only grows while the interactive app runs; the backtest
3
11
  // (task-1401) is only worth re-running on fresh data, so this command runs the exact same recorder jobs
@@ -20,13 +28,17 @@
20
28
  // re-installed with them: `--retention-mb N` replaces that pause with eviction of the oldest history (observation
21
29
  // never pauses), `--retention-days D` is the age rule for snapshot rows, and `--focus-ticks` runs the autotrade
22
30
  // tick lane (5–10 s DexScreener samples of watchlist / held / candidate / open coins, plus 1m candles).
31
+ // task-1786 (winner-recall DATA-GAPS items 1-2), opt-in the same way: `--focus-module PATH` loads an ESM module whose
32
+ // `focus(now)` adds focus coins to the tick lane (the challenge / control coins live outside the app; the module that
33
+ // reads them lives with them), and `--exit-probes` adds a keyless Jupiter sell quote for every held focus coin once a
34
+ // minute (exit-probes.ts). Both imply --focus-ticks.
23
35
  import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
24
36
  import { spawnSync } from 'node:child_process';
25
37
  import { dirname, join, resolve } from 'node:path';
26
- import { fileURLToPath } from 'node:url';
38
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
39
  import { defaultDbPath, resolveRpcUrl, extraRpcUrls, redactSecrets } from '../../data/src/index.js';
28
40
  import { PumpPortalClient } from '../../data/src/index.js';
29
- import { Recorder, recorderEnabled, dbFocus, focusFilePath, readFocusFile, DEFAULT_TICKS } from '../../recorder/src/index.js';
41
+ import { Recorder, recorderEnabled, dbFocus, focusFilePath, readFocusFile, exitProbeDeps, DEFAULT_TICKS } from '../../recorder/src/index.js';
30
42
  import { dextermHome } from "./boot-state.js";
31
43
  import { createLiveFeed } from "./feed.js";
32
44
  import { NewPairsLive } from "./live/index.js";
@@ -50,13 +62,30 @@ export function buildLiveEngine(env, opts) {
50
62
  // Focus coins for the tick lane: the watchlist + paper positions in this DB, and whatever the autotrade runner /
51
63
  // the app publish in the focus file. Read on every poll (two indexed reads + one small file).
52
64
  const focusFile = focusFilePath(dextermHome(env));
65
+ // task-1786: the focus module's coins. A throwing module costs its own coins only (logged at most every 10 min).
66
+ let extraFailedAt = -Infinity;
67
+ const extra = () => {
68
+ if (!opts.extraFocus)
69
+ return [];
70
+ try {
71
+ return [...opts.extraFocus()];
72
+ }
73
+ catch (e) {
74
+ if (Date.now() - extraFailedAt > 600_000) {
75
+ extraFailedAt = Date.now();
76
+ opts.onLog?.(`record: focus module failed: ${redactSecrets(e.message)}\n`);
77
+ }
78
+ return [];
79
+ }
80
+ };
53
81
  const recorder = new Recorder({
54
82
  path: opts.dbPath, onLog: opts.verbose ? opts.onLog : undefined,
55
83
  // The cap reclaims the data layer's expired cache entries (its own rule: a day past their TTL) before it evicts
56
84
  // any history; `feed` is built above, so the hook can reach it.
57
85
  ...(opts.retentionBytes ? { cap: { maxBytes: opts.retentionBytes, reclaim: () => feed.dl.cache.prune() } } : {}),
58
86
  ...(opts.retentionDays ? { retention: { columnsDays: opts.retentionDays } } : {}),
59
- ...(opts.focusTicks ? { ticks: { focus: () => [...dbFocus(recorder.database), ...readFocusFile(focusFile, Date.now())] } } : {}),
87
+ ...(opts.focusTicks ? { ticks: { focus: () => [...dbFocus(recorder.database), ...readFocusFile(focusFile, Date.now()), ...extra()] } } : {}),
88
+ ...(opts.focusTicks && opts.exitProbes ? { exitProbes: {} } : {}),
60
89
  });
61
90
  recorder.attach(feed.dl.store);
62
91
  let newpairs = null;
@@ -71,6 +100,7 @@ export function buildLiveEngine(env, opts) {
71
100
  feed.extraMints = () => [...np.rows.keys()];
72
101
  }
73
102
  const dl = feed.dl;
103
+ const probeDeps = exitProbeDeps(dl);
74
104
  return {
75
105
  async observe() {
76
106
  const before = recorder.counters.written;
@@ -83,7 +113,14 @@ export function buildLiveEngine(env, opts) {
83
113
  }
84
114
  catch { /* logged inside; never fatal */ } },
85
115
  enforceCap() { return recorder.enforceCap(); },
86
- ...(opts.focusTicks ? { pollTicks: () => recorder.pollTicks(dl) } : {}),
116
+ ...(opts.focusTicks ? { pollTicks: async () => {
117
+ const r = await recorder.pollTicks(dl);
118
+ // Detached: a quote waiting on the Jupiter bucket must never delay the next 5 s tick (the lane's own in-flight
119
+ // guard keeps one probe pass at a time; it never throws).
120
+ if (opts.exitProbes)
121
+ void recorder.probeExits(probeDeps).catch(() => { });
122
+ return r;
123
+ } } : {}),
87
124
  dbBytes() {
88
125
  try {
89
126
  const db = recorder.database;
@@ -261,6 +298,19 @@ function parseRetention(t, next, a) {
261
298
  a.focusTicks = true;
262
299
  return null;
263
300
  }
301
+ if (t === '--exit-probes') {
302
+ a.exitProbes = true;
303
+ a.focusTicks = true;
304
+ return null;
305
+ }
306
+ if (t === '--focus-module') {
307
+ const v = next();
308
+ if (!v || v.startsWith('--'))
309
+ return '--focus-module needs a PATH';
310
+ a.focusModule = resolve(v);
311
+ a.focusTicks = true;
312
+ return null;
313
+ }
264
314
  if (t === '--retention-mb') {
265
315
  const n = Number(next());
266
316
  if (!Number.isFinite(n) || n < 1)
@@ -278,7 +328,7 @@ function parseRetention(t, next, a) {
278
328
  return undefined;
279
329
  }
280
330
  export function parseRun(argv) {
281
- const a = { intervalMs: DEFAULT_INTERVAL_MS, ticks: Infinity, maintainMs: DEFAULT_MAINTAIN_MS, maxDbBytes: DEFAULT_MAX_DB_MB * 1_000_000, noNewpairs: false, db: null, verbose: false, retentionBytes: null, retentionDays: null, focusTicks: false };
331
+ const a = { intervalMs: DEFAULT_INTERVAL_MS, ticks: Infinity, maintainMs: DEFAULT_MAINTAIN_MS, maxDbBytes: DEFAULT_MAX_DB_MB * 1_000_000, noNewpairs: false, db: null, verbose: false, retentionBytes: null, retentionDays: null, focusTicks: false, focusModule: null, exitProbes: false };
282
332
  for (let i = 0; i < argv.length; i++) {
283
333
  const t = argv[i];
284
334
  const r = parseRetention(t, () => argv[++i], a);
@@ -329,8 +379,9 @@ export function parseRun(argv) {
329
379
  export const RECORD_USAGE = `dexterm record: keep the history recorder fed with no TUI open (task-1414).
330
380
 
331
381
  dexterm record [run] [--interval S] [--ticks N | --once] [--maintain S] [--max-db-mb N] [--retention-mb N] [--retention-days D]
332
- [--focus-ticks] [--no-newpairs] [--db PATH] [--verbose]
333
- dexterm record install [--interval S] [--max-db-mb N] [--retention-mb N] [--retention-days D] [--focus-ticks] [--load] [--dry-run]
382
+ [--focus-ticks] [--focus-module PATH] [--exit-probes] [--no-newpairs] [--db PATH] [--verbose]
383
+ dexterm record install [--interval S] [--max-db-mb N] [--retention-mb N] [--retention-days D] [--focus-ticks]
384
+ [--focus-module PATH] [--exit-probes] [--load] [--dry-run]
334
385
  dexterm record uninstall [--unload]
335
386
  dexterm record status [--db PATH]
336
387
 
@@ -341,7 +392,10 @@ run the collector loop: one keyless feed refresh per --interval (default
341
392
  to stay under N MB and observation never pauses. --retention-days D drops snapshot rows (sweep rows
342
393
  too) older than D days (default 90). --focus-ticks samples watchlist / held / candidate / open coins
343
394
  every 5-10 s (DexScreener batches of 30, a daily call ceiling) with 1m candles; autotrade backtests
344
- read them. --no-newpairs drops the PumpPortal stream. Ctrl-C stops cleanly
395
+ read them. --focus-module PATH adds the coins an ESM module's focus(now) returns
396
+ ([{ mint, role }], role held | open | candidate | watch) to that lane. --exit-probes asks Jupiter for
397
+ a keyless $50 sell quote of every held focus coin once a minute (exit_probes; quotes only).
398
+ Both imply --focus-ticks. --no-newpairs drops the PumpPortal stream. Ctrl-C stops cleanly
345
399
  (labels whatever is due). Requests honour the data layer's rate limits, health gate and sweep budgets.
346
400
  install write a user-level launchd LaunchAgent (no sudo) that runs \`record run\`; prints the launchctl
347
401
  command. --load also runs launchctl (off by default). --dry-run prints the plist, writes nothing.
@@ -404,7 +458,22 @@ async function run(argv, io, deps) {
404
458
  };
405
459
  })();
406
460
  const log = (s) => io.stderr.write(s);
407
- const engine = deps.engine ?? buildLiveEngine(io.env, { dbPath, noNewpairs: parsed.noNewpairs, verbose: parsed.verbose, onLog: log, retentionBytes: parsed.retentionBytes, retentionDays: parsed.retentionDays, focusTicks: parsed.focusTicks });
461
+ // task-1786: the focus module is loaded once, up front; a module that does not load is a startup error (exit 2).
462
+ let extraFocus;
463
+ if (parsed.focusModule) {
464
+ try {
465
+ const mod = await import(__rewriteRelativeImportExtension(pathToFileURL(parsed.focusModule).href));
466
+ if (typeof mod.focus !== 'function')
467
+ throw new Error('it exports no focus(now) function');
468
+ const fn = mod.focus;
469
+ extraFocus = () => fn(Date.now());
470
+ }
471
+ catch (e) {
472
+ io.stderr.write(`dexterm record: --focus-module ${parsed.focusModule}: ${redactSecrets(e.message)}\n`);
473
+ return 2;
474
+ }
475
+ }
476
+ const engine = deps.engine ?? buildLiveEngine(io.env, { dbPath, noNewpairs: parsed.noNewpairs, verbose: parsed.verbose, onLog: log, retentionBytes: parsed.retentionBytes, retentionDays: parsed.retentionDays, focusTicks: parsed.focusTicks, extraFocus, exitProbes: parsed.exitProbes });
408
477
  const own = !deps.engine;
409
478
  const onSig = () => { io.stderr.write('\ndexterm record: stopping…\n'); stop.abort(); };
410
479
  if (own) {
@@ -415,7 +484,7 @@ async function run(argv, io, deps) {
415
484
  // alive between passes and node would exit mid-run (task-1504 smoke). Hold it open until the loops finish.
416
485
  const hold = own ? setInterval(() => { }, 1 << 30) : null;
417
486
  if (parsed.verbose || parsed.ticks !== Infinity)
418
- io.stderr.write(`dexterm record: db ${dbPath} · interval ${Math.round(parsed.intervalMs / 1000)}s · maintain ${Math.round(parsed.maintainMs / 1000)}s · cap ${parsed.maxDbBytes === null ? 'off' : `${Math.round(parsed.maxDbBytes / 1e6)} MB`} · newpairs ${parsed.noNewpairs ? 'off' : 'on'}${parsed.retentionBytes !== null ? ` · retention ${Math.round(parsed.retentionBytes / 1e6)} MB (evict, never pause)` : ''}${parsed.retentionDays !== null ? ` · rows ${parsed.retentionDays} d` : ''} · focus ticks ${parsed.focusTicks ? 'on' : 'off'}\n`);
487
+ io.stderr.write(`dexterm record: db ${dbPath} · interval ${Math.round(parsed.intervalMs / 1000)}s · maintain ${Math.round(parsed.maintainMs / 1000)}s · cap ${parsed.maxDbBytes === null ? 'off' : `${Math.round(parsed.maxDbBytes / 1e6)} MB`} · newpairs ${parsed.noNewpairs ? 'off' : 'on'}${parsed.retentionBytes !== null ? ` · retention ${Math.round(parsed.retentionBytes / 1e6)} MB (evict, never pause)` : ''}${parsed.retentionDays !== null ? ` · rows ${parsed.retentionDays} d` : ''} · focus ticks ${parsed.focusTicks ? 'on' : 'off'}${parsed.focusModule ? ` · focus module ${parsed.focusModule}` : ''}${parsed.exitProbes ? ' · exit probes on' : ''}\n`);
419
488
  let result;
420
489
  let tickRes = { polls: 0, calls: 0, written: 0 };
421
490
  try {
@@ -446,7 +515,7 @@ async function run(argv, io, deps) {
446
515
  }
447
516
  function install(argv, io) {
448
517
  let intervalMs = DEFAULT_INTERVAL_MS, maxDbMb = DEFAULT_MAX_DB_MB, load = false, dry = false;
449
- const ret = { retentionBytes: null, retentionDays: null, focusTicks: false };
518
+ const ret = { retentionBytes: null, retentionDays: null, focusTicks: false, focusModule: null, exitProbes: false };
450
519
  for (let i = 0; i < argv.length; i++) {
451
520
  const t = argv[i];
452
521
  const r = parseRetention(t, () => argv[++i], ret);
@@ -488,6 +557,8 @@ function install(argv, io) {
488
557
  ...(ret.retentionBytes !== null ? ['--retention-mb', String(Math.round(ret.retentionBytes / 1e6))] : []),
489
558
  ...(ret.retentionDays !== null ? ['--retention-days', String(ret.retentionDays)] : []),
490
559
  ...(ret.focusTicks ? ['--focus-ticks'] : []),
560
+ ...(ret.focusModule ? ['--focus-module', ret.focusModule] : []),
561
+ ...(ret.exitProbes ? ['--exit-probes'] : []),
491
562
  ];
492
563
  const plist = renderCollectorPlist({ label: COLLECTOR_LABEL, nodePath: process.execPath, binPath: bin, intervalMs, maxDbMb, extraArgs, logPath, workingDir: resolve(dirname(bin), '..', '..', '..') });
493
564
  if (dry) {
@@ -119,8 +119,9 @@ export async function rulesCommand(argv, io) {
119
119
  case 'fields': {
120
120
  io.stdout.write(`fields: ${FIELD_NAMES.join(' ')}\n`);
121
121
  io.stdout.write(`aliases: vol volume liquidity cap mc holder organic top locked cluster% clusterpct\n`);
122
+ io.stdout.write(`narrative (EXPERIMENTAL, measured heat, not advice): narrative_rank narrative_score narrative:spark|ignition|peak|fading|dead|saturated (null until N → n builds the tracker)\n`);
122
123
  io.stdout.write(`signals: <field>.velocity (current ÷ mean of earlier samples in the window) · <field>.delta (current − oldest in the window)\n`);
123
- io.stdout.write(`tags: ${TAG_NAMES.map((t) => t + ':').join(' ')} (e.g. launchpad:pump flag:mint flag:clean verified:yes sus:no; prefix ! to negate)\n`);
124
+ io.stdout.write(`tags: ${TAG_NAMES.map((t) => t + ':').join(' ')} (e.g. launchpad:pump flag:mint flag:clean verified:yes sus:no narrative:ignition narrative:saturated; prefix ! to negate)\n`);
124
125
  io.stdout.write(`values: $1.5M 25K 2B 20% 2h 30m 3d · ops > >= < <= = != · and or not ( )\n`);
125
126
  io.stdout.write(`actions: ${ACTIONS.join(' ')} alert(level, "text") ticket(buy|sell, size ≤ ${TICKET_SIZE_CAP}, sol|usd|pct) ask(skill) popup(${RULE_POPUP_KINDS.join('|')}) tag("label") sound(name) watch\n`);
126
127
  io.stdout.write(`limits: cooldown (default 10m) · max n/h (default 12, cap ${MAX_PER_HOUR_CAP}) · scope ${SCOPES.join('|')}|mint:<addr> · window (default 10m)\n`);
@@ -1,4 +1,16 @@
1
+ // dexterm — the rules engine's app adapter (task-1358 Tier 3). Pure helpers over @dexterm/custom's RulesEngine
2
+ // that the key handler, the interactive loop and the Rules screen call: load `config.rules` into an engine on
3
+ // `state.rulesUi`, feed it the screener rows every few seconds (`rulesTick`), turn its outputs into things the
4
+ // app can show (toasts, the screen's log, PROPOSAL cards, pending UI actions the loop performs), and persist edits
5
+ // through the config journal (so `dexterm config undo` / the journal cover rules too).
6
+ //
7
+ // SAFETY: a rule's outputs are alerts, annotations (watch/tag on the user's own watchlist), UI actions (popup /
8
+ // terminal bell) and PROPOSALS. Acting on a ticket proposal opens the PAPER ticket in its editing phase (the user
9
+ // sizes and confirms there, signer caps apply); acting on an ask proposal sends a question to the analyst. Nothing
10
+ // in this module can arm, sign, confirm, open a live ticket or touch a mandate — there is no code path for it.
11
+ import { classifierLiveTick } from "./classifier-live.js";
1
12
  import { devBookOf } from "./live/dev-history.js";
13
+ import { narrativeRuleFields } from "./narratives.js";
2
14
  import { RulesEngine, ruleToJson, ruleToText, slug, applyAndJournal, describeActions, } from '../../custom/src/index.js';
3
15
  import { alertToDelivery, proposalToDelivery } from "./alerts/format.js";
4
16
  export const RULE_INPUT_MAX = 400;
@@ -56,6 +68,11 @@ export function larpGrade(verdict) {
56
68
  * `mintable > 0` fires on the real streaming values, not the on-open snapshot. A token opened by `--mint` that is
57
69
  * not in the screener feed is appended as its own row so rules still see it.
58
70
  */
71
+ /** task-1822: the classifier's drain estimate for the `rugp` field (null when unscored or the scoring failed). */
72
+ function rugpOf(state, mint) {
73
+ const p = state.classifierByMint?.get(mint)?.p;
74
+ return typeof p === 'number' && Number.isFinite(p) ? p : null;
75
+ }
59
76
  export function ruleRowsOf(state) {
60
77
  const out = [];
61
78
  const seen = new Set();
@@ -70,11 +87,13 @@ export function ruleRowsOf(state) {
70
87
  // 0.1.5 dev history: the creator's recorded rugs as the `devrugs` field (local recorder only; null = unknown).
71
88
  const book = devBookOf(state);
72
89
  const devRugsOf = (mint) => book ? book.rugsFor(mint) : null;
90
+ // task-1818: narrative_rank / narrative_score / narrative:<state> from the ALREADY-built narratives tracker (never builds one)
91
+ const narr = (mint) => narrativeRuleFields(state, mint);
73
92
  for (const r of state.screener.rows) {
74
93
  if (!r.mint || r.skeleton || seen.has(r.mint))
75
94
  continue;
76
95
  seen.add(r.mint);
77
- const row = Object.assign(Object.create(null), r, { cluster: clusterOf(r.mint), larp: larpGrade(state.larpByMint?.get(r.mint)?.verdict), devRugs: devRugsOf(r.mint) });
96
+ const row = Object.assign(Object.create(null), r, { cluster: clusterOf(r.mint), larp: larpGrade(state.larpByMint?.get(r.mint)?.verdict), devRugs: devRugsOf(r.mint), rugP: rugpOf(state, r.mint) }, narr(r.mint));
78
97
  if (r.mint === openMint)
79
98
  overlaySignalFields(row, state, r.mint);
80
99
  out.push(row);
@@ -85,9 +104,9 @@ export function ruleRowsOf(state) {
85
104
  const row = Object.assign(Object.create(null), {
86
105
  mint: v.mint, symbol: v.symbol, price: v.price, mcap: v.mcap, fdv: v.fdv, liq: v.liq, vol24: v.vol24,
87
106
  holders: v.holders, org: v.org, top10: v.top10, dev: v.dev, lpLocked: v.lpLocked,
88
- cluster: clusterOf(v.mint), larp: larpGrade(state.larpByMint?.get(v.mint)?.verdict), devRugs: devRugsOf(v.mint),
107
+ cluster: clusterOf(v.mint), larp: larpGrade(state.larpByMint?.get(v.mint)?.verdict), devRugs: devRugsOf(v.mint), rugP: rugpOf(state, v.mint),
89
108
  ageMin: v.ageMin, chg: v.chg, launchpad: v.launchpad, flags: v.flags, verified: v.verified, sus: v.sus,
90
- });
109
+ }, narr(v.mint));
91
110
  overlaySignalFields(row, state, v.mint);
92
111
  out.push(row);
93
112
  }
@@ -107,7 +126,7 @@ export function ruleRowsOf(state) {
107
126
  out.push(Object.assign(Object.create(null), {
108
127
  mint: p.mint, symbol: p.symbol, mcap: p.mcap, liq: p.liq, holders: p.holders, launchpad: p.launchpad,
109
128
  ageMin: p.createdAt ? Math.max(0, (now - p.createdAt) / 60_000) : null, bonding: p.bonding, devRugs,
110
- }));
129
+ }, narr(p.mint)));
111
130
  }
112
131
  }
113
132
  return out;
@@ -153,6 +172,7 @@ export function rulesTick(state, now) {
153
172
  const rows = ruleRowsOf(state);
154
173
  if (!rows.length)
155
174
  return null;
175
+ classifierLiveTick(state, rows, now); // task-1822: fills classifierByMint for `rugp` rules (a no-op without one)
156
176
  ui.engine.observe(rows, now);
157
177
  const watch = state.live?.watch.store ?? null;
158
178
  const positions = state.live?.positions ?? null;
@@ -3,8 +3,13 @@
3
3
  // new launches / runners / pace / "what is working" score. No model in the hot path; the board carries
4
4
  // its own limits (name-based, X/TG-only narratives missed, honest unclassified share). w flips 1h/24h,
5
5
  // M opens the HUD popup. The board is computed in state.ts (computeNarrativeView) on entry and the tick.
6
+ // task-1818: `n` flips to the EXPERIMENTAL heat table (@dexterm/narratives: clustered by what the name says,
7
+ // lifecycle + traction score per narrative); ⏎ drills into one narrative's member coins, esc returns to the table.
6
8
  import { Canvas, header, footer, badge, breakpoint } from '../../../tui/src/index.js';
7
9
  import { headerModel } from "../state.js";
10
+ import { clean } from '../../../packs/src/index.js';
11
+ import { HEAT_NOTE } from '../../../narratives/src/index.js';
12
+ import { heatRows, drillDetail, currentTracker, narrativesSource, UNTRUSTED_NOTE } from "../narratives.js";
8
13
  const fmtUsd = (v) => { if (v == null)
9
14
  return '—'; const a = Math.abs(v); if (a >= 1e9)
10
15
  return (v / 1e9).toFixed(2) + 'B'; if (a >= 1e6)
@@ -12,6 +17,13 @@ const fmtUsd = (v) => { if (v == null)
12
17
  return (v / 1e3).toFixed(1) + 'K'; return v.toFixed(0); };
13
18
  const fmtPct = (v) => (v == null ? '—' : (v > 0 ? '+' : '') + v.toFixed(1) + '%');
14
19
  export function renderNarrative(c, state) {
20
+ if (state.narrativesUi?.mode === 'heat') {
21
+ if (state.narrativesUi.drill)
22
+ renderDrill(c, state);
23
+ else
24
+ renderHeat(c, state);
25
+ return;
26
+ }
15
27
  const t = c.theme;
16
28
  const bp = breakpoint(c.w);
17
29
  header(c, headerModel(state));
@@ -27,7 +39,7 @@ export function renderNarrative(c, state) {
27
39
  if (!board || board.narratives.every((n) => n.tokens === 0)) {
28
40
  c.textC(box.x, box.y + 1, box.w, 'collecting narratives — run the app with the recorder on, or open the screener first', { fg: t.muted, bg, i: true });
29
41
  c.textC(box.x, box.y + 3, box.w, view?.error ? `(${view.error})` : 'clusters come from token names against a lexicon (owner: maintainers); X/Telegram-only narratives are missed', { fg: t.dim, bg });
30
- footer(c, [['w', '1h/24h'], ['esc', 'back'], ['q', 'quit']], t.name);
42
+ footer(c, [['w', '1h/24h'], ['n', 'narratives (exp)'], ['esc', 'back'], ['q', 'quit']], t.name);
31
43
  return;
32
44
  }
33
45
  const shown = board.narratives.filter((n) => n.tokens > 0);
@@ -86,5 +98,145 @@ export function renderNarrative(c, state) {
86
98
  const ly = box.y + box.h - 1;
87
99
  if (wide)
88
100
  c.textC(box.x, ly, box.w, `model output: none (deterministic lexicon v${board.meta.lexicon_version}) · ${board.meta.copycat_tokens} copycat symbols · name-based · pace '*' = all tokens · compute ${board.meta.compute_ms}ms`, { fg: t.dim, bg });
89
- footer(c, [['↑↓', 'move'], ['w', win === '1h' ? '→24h' : '→1h'], ['M', 'popup'], ['esc', 'back'], ['q', 'quit']], t.name);
101
+ footer(c, [['↑↓', 'move'], ['w', win === '1h' ? '→24h' : '→1h'], ['n', 'narratives (exp)'], ['M', 'popup'], ['esc', 'back'], ['q', 'quit']], t.name);
102
+ }
103
+ // ---- task-1818 EXPERIMENTAL heat table + drill-down ------------------------------------------------------------
104
+ const heatUsd = (v) => (v == null ? '—' : '$' + fmtUsd(v));
105
+ const fmtShare = (v) => (v == null ? '—' : (v * 100).toFixed(1) + '%');
106
+ const fmtX = (v) => (v == null || !Number.isFinite(v) ? '—' : v.toFixed(2) + 'x');
107
+ function lcColor(t, lc) {
108
+ // states are measured heat, so none is painted as a market up/down colour (no green "go")
109
+ return lc === 'ignition' ? t.accent : lc === 'peak' ? t.text : lc === 'fading' ? t.warn : lc === 'dead' ? t.dim : t.muted;
110
+ }
111
+ /** The limits line: the full HEAT_NOTE when it fits, else the short form (the note must never be the part that is cut). */
112
+ function limitsLine(w, extra) {
113
+ const full = `${HEAT_NOTE} · ${UNTRUSTED_NOTE} · ${extra}`;
114
+ return full.length <= w ? full : `measured heat, not advice · ${UNTRUSTED_NOTE} · ${extra}`;
115
+ }
116
+ /** The status line under an empty heat table: building (with the one-time stall warning), failed, or nothing yet. */
117
+ function heatPlaceholder(state) {
118
+ const ui = state.narrativesUi;
119
+ const rec = narrativesSource(state) === 'recorded';
120
+ if (ui?.error)
121
+ return [`narratives failed: ${clean(ui.error, 120)}`, 'the theme board (v) still works'];
122
+ if (!currentTracker(state))
123
+ return [rec ? 'building narratives from recorded history…' : 'building narratives from the visible rows…',
124
+ rec ? 'first build reads the last 26 h of the recorder: tens of seconds, the screen stays live (once per run)' : 'no recorder DB here (fixtures / replay): no history, so every narrative starts as a spark'];
125
+ return ['no narrative with 2+ coins yet', 'a narrative needs at least two coins whose names share a theme · measured heat, not advice'];
126
+ }
127
+ function renderHeat(c, state) {
128
+ const t = c.theme;
129
+ const bp = breakpoint(c.w);
130
+ header(c, headerModel(state));
131
+ const ui = state.narrativesUi;
132
+ const rows = heatRows(state);
133
+ const tr = currentTracker(state);
134
+ const src = narrativesSource(state) === 'recorded' ? 'recorded history' : 'visible rows';
135
+ const top = 2, bottom = c.h - 1;
136
+ const title = `NARRATIVES · EXPERIMENTAL · ${tr ? `${rows.length} narratives · ${tr.coins.size} coins` : 'building'} · ${src}${ui.busy && tr ? ' · refreshing' : ''}`;
137
+ const caption = bp === 'compact' ? undefined : 'measured heat, not advice';
138
+ const box = c.box(0, top, c.w, bottom - top, { title, caption, focus: true });
139
+ const bg = box.bg;
140
+ const keys = [['↑↓', 'move'], ['⏎', 'drill'], ['n', 'theme board'], ['esc', 'back'], ['q', 'quit']];
141
+ if (!rows.length) {
142
+ const [a, b] = heatPlaceholder(state);
143
+ c.textC(box.x, box.y + 1, box.w, a, { fg: t.muted, bg, i: true });
144
+ c.textC(box.x, box.y + 3, box.w, b, { fg: t.dim, bg });
145
+ c.textC(box.x, box.y + 5, box.w, HEAT_NOTE, { fg: t.dim, bg });
146
+ footer(c, keys, t.name);
147
+ return;
148
+ }
149
+ const sel = Math.max(0, Math.min(rows.length - 1, ui.selected));
150
+ // columns: [x, header]; a column that does not fit the box is dropped (the runner first)
151
+ const cols = [[1, '#', 3], [5, 'NARRATIVE', 18], [24, 'THEME', 12], [37, 'STATE', 10], [48, 'COINS', 5], [54, 'NEW/h', 5], [60, 'VOL 1h', 9], [70, 'SHARE', 6], [77, 'TRADERS', 7], [85, 'SCORE', 5], [91, 'TOP RUNNER (x · drawdown)', 28]];
152
+ const fits = (i) => cols[i][0] + Math.min(cols[i][2], 12) <= box.w - 1;
153
+ let cy = box.y;
154
+ cols.forEach(([x, h], i) => { if (fits(i))
155
+ c.text(box.x + x, cy, h, { fg: t.dim, bg }, box.w - x - 1); });
156
+ cy += 1;
157
+ c.hline(box.x, cy, box.w, { fg: t.border, bg });
158
+ cy += 1;
159
+ const scroll = Math.max(0, sel - (box.h - 6));
160
+ for (let i = scroll; i < rows.length; i++) {
161
+ if (cy >= box.y + box.h - 2)
162
+ break;
163
+ const s = rows[i];
164
+ const w1 = s.windows['1h'];
165
+ const selected = i === sel;
166
+ const rb = selected ? t.selBg : bg;
167
+ c.fill(box.x, cy, box.w, 1, ' ', { bg: rb });
168
+ if (selected)
169
+ c.put(box.x, cy, '▎', { fg: t.accent, bg: rb, soft: true });
170
+ const cell = (k, v, st) => { if (fits(k))
171
+ c.text(box.x + cols[k][0], cy, v, { ...st, bg: rb }, Math.min(cols[k][2], box.w - cols[k][0] - 1)); };
172
+ cell(0, s.rank == null ? '·' : String(s.rank), { fg: s.rank == null ? t.dim : t.accent, b: true });
173
+ cell(1, clean(s.label, 18), { fg: selected ? t.borderFocus : t.text, b: s.rank != null });
174
+ cell(2, s.theme ? clean(s.theme, 12) : '—', { fg: t.muted });
175
+ cell(3, s.lifecycle + (s.saturated ? '*' : ''), { fg: lcColor(t, s.lifecycle), b: s.lifecycle === 'ignition' });
176
+ cell(4, String(s.members), { fg: t.text });
177
+ cell(5, w1.newPerHour.toFixed(0), { fg: t.muted });
178
+ cell(6, heatUsd(w1.volume), { fg: t.text });
179
+ cell(7, fmtShare(w1.volumeShare), { fg: t.muted });
180
+ cell(8, String(w1.traders), { fg: t.muted });
181
+ cell(9, String(s.score), { fg: t.accent, b: true });
182
+ const r = s.topRunner;
183
+ cell(10, r ? `${clean(r.symbol, 10)} ${fmtX(r.x)} · ${r.drawdownPct.toFixed(0)}%` : '—', { fg: r ? t.text : t.dim });
184
+ cy += 1;
185
+ }
186
+ c.textC(box.x, box.y + box.h - 1, box.w, limitsLine(box.w, '* = copy-cat saturated'), { fg: t.dim, bg });
187
+ footer(c, keys, t.name);
188
+ }
189
+ function renderDrill(c, state) {
190
+ const t = c.theme;
191
+ header(c, headerModel(state));
192
+ const ui = state.narrativesUi;
193
+ const d = drillDetail(state);
194
+ const top = 2, bottom = c.h - 1;
195
+ const keys = [['↑↓', 'move'], ['⏎', 'open coin'], ['esc', 'table'], ['q', 'quit']];
196
+ if (!d) {
197
+ const box = c.box(0, top, c.w, bottom - top, { title: 'NARRATIVE · EXPERIMENTAL', focus: true });
198
+ c.textC(box.x, box.y + 1, box.w, 'this narrative is no longer tracked (esc returns to the table)', { fg: t.muted, bg: box.bg, i: true });
199
+ footer(c, keys, t.name);
200
+ return;
201
+ }
202
+ const w1 = d.windows['1h'];
203
+ const title = `NARRATIVE · ${clean(d.label, 24)} · ${d.lifecycle}${d.saturated ? ' · copy-cat saturated' : ''} · ${d.memberList.length} coin${d.memberList.length === 1 ? '' : 's'} · EXPERIMENTAL`;
204
+ const box = c.box(0, top, c.w, bottom - top, { title, caption: 'measured heat, not advice · members in spawn order', focus: true });
205
+ const bg = box.bg;
206
+ let cy = box.y;
207
+ c.text(box.x + 1, cy, `lifecycle rule (${d.lifecycle}): ${d.lifecycleRule}`, { fg: lcColor(t, d.lifecycle), bg }, box.w - 2);
208
+ cy += 1;
209
+ c.text(box.x + 1, cy, `1h: vol ${heatUsd(w1.volume)} · share ${fmtShare(w1.volumeShare)} · new/h ${w1.newPerHour.toFixed(0)} · traders ${w1.traders} · score ${d.score}${d.rank ? ` · rank ${d.rank}` : ''}${d.theme ? ` · theme ${clean(d.theme, 16)}` : ''}`, { fg: t.muted, bg }, box.w - 2);
210
+ cy += 1;
211
+ const cols = [[1, '#', 4], [6, 'SYMBOL', 12], [19, 'NAME', 26], [46, 'X SINCE 1ST', 11], [58, 'LIQ', 9], [68, 'VOL 1h', 9]];
212
+ const fits = (i) => cols[i][0] + Math.min(cols[i][2], 8) <= box.w - 1;
213
+ cols.forEach(([x, h], i) => { if (fits(i))
214
+ c.text(box.x + x, cy, h, { fg: t.dim, bg }); });
215
+ cy += 1;
216
+ c.hline(box.x, cy, box.w, { fg: t.border, bg });
217
+ cy += 1;
218
+ const n = d.memberList.length;
219
+ const sel = Math.max(0, Math.min(n - 1, ui.drill.selected));
220
+ const scroll = Math.max(0, sel - (box.h - 8));
221
+ for (let i = scroll; i < n; i++) {
222
+ if (cy >= box.y + box.h - 1)
223
+ break;
224
+ const m = d.memberList[i];
225
+ const selected = i === sel;
226
+ const rb = selected ? t.selBg : bg;
227
+ c.fill(box.x, cy, box.w, 1, ' ', { bg: rb });
228
+ if (selected)
229
+ c.put(box.x, cy, '▎', { fg: t.accent, bg: rb, soft: true });
230
+ const cell = (k, v, fg) => { if (fits(k))
231
+ c.text(box.x + cols[k][0], cy, v, { fg, bg: rb }, Math.min(cols[k][2], box.w - cols[k][0] - 1)); };
232
+ cell(0, `#${m.spawn}`, t.dim);
233
+ cell(1, clean(m.symbol, 12), selected ? t.borderFocus : t.text);
234
+ cell(2, clean(m.name, 26), t.muted);
235
+ cell(3, fmtX(m.x), m.x == null ? t.dim : m.x >= 1 ? t.up : t.down);
236
+ cell(4, heatUsd(m.liq), t.text);
237
+ cell(5, heatUsd(m.vol1h), t.text);
238
+ cy += 1;
239
+ }
240
+ c.textC(box.x, box.y + box.h - 1, box.w, limitsLine(box.w, 'x = now vs first $10K+ pool price'), { fg: t.dim, bg });
241
+ footer(c, keys, t.name);
90
242
  }
@@ -301,6 +301,7 @@ export function makeFixtureState(opts = {}) {
301
301
  ai: null,
302
302
  analystByMint: new Map(),
303
303
  larpByMint: new Map(),
304
+ classifierByMint: new Map(),
304
305
  colorDepth: opts.colorDepth ?? 'truecolor',
305
306
  };
306
307
  if (opts.priceMode)
@@ -1,3 +1,3 @@
1
1
  // The one place the app's version lives (task-1397). --version, the help banner and `dexterm update` read it;
2
2
  // cli.test.ts keeps it equal to packages/cli/package.json (release.yml refuses a tag that differs).
3
- export const VERSION = '0.2.0';
3
+ export const VERSION = '0.2.1';
@@ -3,7 +3,8 @@ export const FIELDS = {
3
3
  vol5m: (r) => r.vol5m, vol1h: (r) => r.vol1h, vol24: (r) => r.vol24,
4
4
  holders: (r) => r.holders, org: (r) => r.org, top10: (r) => r.top10, dev: (r) => r.dev,
5
5
  lplocked: (r) => r.lpLocked, bundled: (r) => r.bundled, snipers: (r) => r.snipers,
6
- cluster: (r) => r.cluster, larp: (r) => r.larp, devrugs: (r) => r.devRugs,
6
+ cluster: (r) => r.cluster, larp: (r) => r.larp, devrugs: (r) => r.devRugs, rugp: (r) => r.rugP,
7
+ narrative_rank: (r) => r.narrativeRank, narrative_score: (r) => r.narrativeScore,
7
8
  fresh: (r) => r.fresh, mintable: (r) => r.mintable, freezable: (r) => r.freezable,
8
9
  age: (r) => r.ageMin, m5: (r) => r.chg?.m5, h1: (r) => r.chg?.h1, h6: (r) => r.chg?.h6, h24: (r) => r.chg?.h24,
9
10
  buys: (r) => r.buys, sells: (r) => r.sells, bonding: (r) => r.bonding,
@@ -16,6 +17,9 @@ const TAGS = {
16
17
  flag: (r, v) => (v === 'clean' ? (r.flags ?? []).length === 0 : (r.flags ?? []).map((f) => f.toLowerCase()).includes(v.toLowerCase())),
17
18
  verified: (r, v) => (v === 'yes') === !!r.verified,
18
19
  sus: (r, v) => (v === 'yes') === !!r.sus,
20
+ // task-1818: `narrative:ignition` (or spark / peak / fading / dead) = the coin's narrative is in that lifecycle state;
21
+ // `narrative:saturated` = copy-cat saturated. An untracked coin matches nothing (so `!narrative:dead` is true for it).
22
+ narrative: (r, v) => { const k = v.toLowerCase(); return k === 'saturated' ? r.narrativeSaturated === true : !!r.narrativeLifecycle && r.narrativeLifecycle === k; },
19
23
  };
20
24
  export const TAG_NAMES = Object.keys(TAGS);
21
25
  export const ACTIONS = ['alert', 'ticket', 'ask', 'popup', 'tag', 'sound', 'watch'];
@@ -0,0 +1,41 @@
1
+ ---
2
+ id: classification-models
3
+ title: Pick a classification model (Kev, hosted Jev, or the built-in scores)
4
+ kind: app-help
5
+ screen: all
6
+ feature: classifier
7
+ tags:
8
+ - app-help
9
+ - classifier
10
+ - classification
11
+ - kev
12
+ - jev
13
+ - model
14
+ - probability
15
+ - rug
16
+ - drain
17
+ - llama
18
+ - calibrated
19
+ - typesafe
20
+ - experimental
21
+ commands:
22
+ - classifier
23
+ tools:
24
+ - classify_coin
25
+ - classify_batch
26
+ - classifier_status
27
+ confidence: high
28
+ owner: maintainers
29
+ origin: app-help-generated
30
+ reviewed_by: generated@2026-10-16
31
+ generated_from: dexterm/app keymap + overlay + mcp tools + cli (task-1413)
32
+ sources:
33
+ - packages/tui/src/keymap.ts
34
+ - packages/cli/src/main.ts
35
+ - packages/mcp/src/server.ts
36
+ date: 2026-10-16
37
+ ---
38
+
39
+ EXPERIMENTAL. A classification model answers typed questions about one coin with a probability per answer: p(drain within 6 h) and RUG / WIN / DUD over 6 h, or a narrative category. `dexterm classifier list` shows the choices: `heuristic` (the built-in H1 rug score and LARP link score; the default, no download), `kev-local` (an open Kev decision model on your own machine through llama.cpp: `dexterm classifier pull kev-4b-q4` shows the size and source and asks first, verifies the sha256, then `dexterm classifier pick kev-local kev-4b-q4` and `dexterm classifier serve`), `jev-hosted` (TypeSafe's hosted Jev with your own API key: `dexterm classifier key set`; per-call cost and a daily call cap), `jev-open` (the open JEV students on a vLLM server), and `claude` / `ollama-llm` zero-shot baselines. `dexterm classifier doctor` reads RAM, CPU and GPU/VRAM and recommends a tier. If the picked model is unreachable the built-in scores answer and say so. Agents use the `classify_coin`, `classify_batch` (triage: flag / pass / escalate) and `classifier_status` MCP tools; a watch rule can use `rugp` (e.g. `when rugp > 0.8 then alert(warn, "...")`). How each model scored on dexterm's own labelled history is on the docs page. Every answer is a model estimate with the model id, never advice. Docs: https://dexterm.xyz/docs/classifier/
40
+
41
+ CLI: `dexterm classifier`.
@@ -0,0 +1,45 @@
1
+ ---
2
+ id: narratives-tracker
3
+ title: Track memes and narratives (dexterm narratives, experimental)
4
+ kind: app-help
5
+ screen: narrative
6
+ feature: narratives
7
+ tags:
8
+ - app-help
9
+ - narratives
10
+ - narrative
11
+ - memes
12
+ - meme
13
+ - copycat
14
+ - lifecycle
15
+ - ignition
16
+ - heat
17
+ - saturated
18
+ - experimental
19
+ keys:
20
+ - n
21
+ - enter
22
+ actions:
23
+ - narrative.view
24
+ - narrative.drill
25
+ commands:
26
+ - narratives
27
+ tools:
28
+ - narratives_top
29
+ - narrative_detail
30
+ - narrative_of
31
+ confidence: high
32
+ owner: maintainers
33
+ origin: app-help-generated
34
+ reviewed_by: generated@2026-10-16
35
+ generated_from: dexterm/app keymap + overlay + mcp tools + cli (task-1413)
36
+ sources:
37
+ - packages/tui/src/keymap.ts
38
+ - packages/cli/src/main.ts
39
+ - packages/mcp/src/server.ts
40
+ date: 2026-10-16
41
+ ---
42
+
43
+ EXPERIMENTAL. The narratives tracker groups coins by the theme their name and ticker carry (ticker puns, animals, celebrities, AI agents, seasonal events, copy-cats of a runner) and measures each theme per window: coins, new-coin rate, volume share, unique-trader growth, top runner and drawdown, a lifecycle state (spark / ignition / peak / fading / dead, with the rule that set it) and a copy-cat saturation flag. On the N screen, n flips the board into the NARRATIVES table (the first switch reads the last 26 h of recorded history and takes tens of seconds), enter to see a narrative's coins in spawn order, esc to go back. `dexterm narratives [top|detail ID|of MINT|export]` prints the same from the recorder; the analyst uses narratives_top, narrative_detail and narrative_of. Rules can use narrative_rank, narrative_score and the tags narrative:ignition / narrative:saturated. Keyless and local; measured heat, not advice.
44
+
45
+ Keys: `n` (narrative), `⏎` (narrative). CLI: `dexterm narratives`.
@@ -0,0 +1,38 @@
1
+ ---
2
+ id: strategy-dashboard
3
+ title: Track how your autotrade strategies perform in a browser, on this machine only (dexterm dash)
4
+ kind: app-help
5
+ screen: all
6
+ tags:
7
+ - app-help
8
+ - dash
9
+ - dashboard
10
+ - performance
11
+ - strategies
12
+ - track
13
+ - equity
14
+ - drawdown
15
+ - compare
16
+ - baseline
17
+ - rugs
18
+ - stuck
19
+ - browser
20
+ - web
21
+ - phone
22
+ commands:
23
+ - dash
24
+ confidence: high
25
+ owner: maintainers
26
+ origin: app-help-generated
27
+ reviewed_by: generated@2026-10-16
28
+ generated_from: dexterm/app keymap + overlay + mcp tools + cli (task-1413)
29
+ sources:
30
+ - packages/tui/src/keymap.ts
31
+ - packages/cli/src/main.ts
32
+ - packages/mcp/src/server.ts
33
+ date: 2026-10-16
34
+ ---
35
+
36
+ `dexterm dash` reads your own autotrade run ledgers ($DEXTERM_HOME/autotrade/runs) and serves a dashboard on http://127.0.0.1:4317/ (loopback only; no account, no cloud, nothing leaves the machine; read-only). Views: Overview (every strategy side by side with an equity sparkline), Strategy (equity curve, drawdown, exit reasons, open positions with the engine mark and the realizable value from the last cached pool read, every trade), Rugs & stuck (closed at -80% or worse and stuck positions written off at what they actually sold), Compare (each strategy minus a --baseline with the 95% interval of the difference) and Live status; it refreshes every 30 s. Every number carries its sample size; under 20 closed trades a strategy is exploratory. Flags: --port, --home (repeat for several homes), --baseline ID, --books FILE (your own script's books), --allow-host NAME (a private proxy in front), --json, --export DIR [--redact-mints] (a static read-only snapshot). PAPER results are simulated. Not financial advice.
37
+
38
+ CLI: `dexterm dash`.