dexterm-cli 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) 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/chat/connector.js +5 -4
  6. package/dist/packages/autotrade/src/chat/control.js +3 -0
  7. package/dist/packages/autotrade/src/chat/hub.js +37 -2
  8. package/dist/packages/autotrade/src/chat/index.js +3 -2
  9. package/dist/packages/autotrade/src/chat/readonly.js +530 -0
  10. package/dist/packages/autotrade/src/chat/service.js +12 -1
  11. package/dist/packages/autotrade/src/engine.js +75 -29
  12. package/dist/packages/autotrade/src/fleet.js +2 -2
  13. package/dist/packages/autotrade/src/index.js +4 -0
  14. package/dist/packages/autotrade/src/rugproof-probe.js +415 -0
  15. package/dist/packages/autotrade/src/rugproof.js +231 -0
  16. package/dist/packages/autotrade/src/runner.js +203 -30
  17. package/dist/packages/autotrade/src/service-install.js +6 -4
  18. package/dist/packages/autotrade/src/sessions.js +16 -7
  19. package/dist/packages/autotrade/src/store.js +1 -1
  20. package/dist/packages/autotrade/src/strategy-stats.js +344 -0
  21. package/dist/packages/autotrade/src/strategy.js +29 -1
  22. package/dist/packages/autotrade/src/tiers.js +194 -0
  23. package/dist/packages/classifier/src/calibrate.js +138 -0
  24. package/dist/packages/classifier/src/classify.js +107 -0
  25. package/dist/packages/classifier/src/config.js +298 -0
  26. package/dist/packages/classifier/src/dossier.js +95 -0
  27. package/dist/packages/classifier/src/hardware.js +63 -0
  28. package/dist/packages/classifier/src/index.js +19 -0
  29. package/dist/packages/classifier/src/models.js +111 -0
  30. package/dist/packages/classifier/src/providers/heuristic.js +77 -0
  31. package/dist/packages/classifier/src/providers/http.js +63 -0
  32. package/dist/packages/classifier/src/providers/jev-open.js +86 -0
  33. package/dist/packages/classifier/src/providers/llm.js +89 -0
  34. package/dist/packages/classifier/src/providers/systemone-http.js +95 -0
  35. package/dist/packages/classifier/src/questions.js +97 -0
  36. package/dist/packages/classifier/src/registry.js +42 -0
  37. package/dist/packages/classifier/src/snapshot.js +31 -0
  38. package/dist/packages/classifier/src/systemone.js +88 -0
  39. package/dist/packages/classifier/src/types.js +18 -0
  40. package/dist/packages/classifier/src/win-acl.js +71 -0
  41. package/dist/packages/cli/assets/dash/dash.css +139 -0
  42. package/dist/packages/cli/assets/dash/dash.js +200 -0
  43. package/dist/packages/cli/assets/dash/icon.svg +1 -0
  44. package/dist/packages/cli/assets/dash/index.html +36 -0
  45. package/dist/packages/cli/src/app-help-gen.js +13 -1
  46. package/dist/packages/cli/src/autotrade-cmd/fleet.js +12 -3
  47. package/dist/packages/cli/src/autotrade-cmd/run.js +14 -2
  48. package/dist/packages/cli/src/autotrade-cmd/service.js +3 -1
  49. package/dist/packages/cli/src/autotrade-cmd/signal.js +2 -2
  50. package/dist/packages/cli/src/autotrade-cmd/strategies.js +1 -1
  51. package/dist/packages/cli/src/autotrade-cmd/telegram-host.js +2 -0
  52. package/dist/packages/cli/src/classifier-cmd.js +361 -0
  53. package/dist/packages/cli/src/classifier-live.js +81 -0
  54. package/dist/packages/cli/src/dash/data.js +217 -0
  55. package/dist/packages/cli/src/dash/server.js +92 -0
  56. package/dist/packages/cli/src/dash-cmd.js +113 -0
  57. package/dist/packages/cli/src/doctor.js +34 -0
  58. package/dist/packages/cli/src/live/screens/token-live.js +4 -0
  59. package/dist/packages/cli/src/live/signals.js +8 -0
  60. package/dist/packages/cli/src/main.js +98 -6
  61. package/dist/packages/cli/src/mcp-follow.js +21 -1
  62. package/dist/packages/cli/src/narratives-cmd.js +147 -0
  63. package/dist/packages/cli/src/narratives.js +114 -0
  64. package/dist/packages/cli/src/overlay/model.js +147 -0
  65. package/dist/packages/cli/src/overlay/page.js +146 -0
  66. package/dist/packages/cli/src/overlay/pnl.js +75 -0
  67. package/dist/packages/cli/src/overlay/server.js +170 -0
  68. package/dist/packages/cli/src/overlay-cmd.js +367 -0
  69. package/dist/packages/cli/src/record-cmd.js +82 -11
  70. package/dist/packages/cli/src/rules-cmd.js +2 -1
  71. package/dist/packages/cli/src/rules.js +24 -4
  72. package/dist/packages/cli/src/screens/narrative.js +154 -2
  73. package/dist/packages/cli/src/skill-eval-build.js +7 -2
  74. package/dist/packages/cli/src/state.js +1 -0
  75. package/dist/packages/cli/src/version.js +1 -1
  76. package/dist/packages/custom/src/rules.js +5 -1
  77. package/dist/packages/mcp/assets/kb/app-help/autotrade-telegram.md +1 -1
  78. package/dist/packages/mcp/assets/kb/app-help/classification-models.md +41 -0
  79. package/dist/packages/mcp/assets/kb/app-help/narratives-tracker.md +45 -0
  80. package/dist/packages/mcp/assets/kb/app-help/strategy-dashboard.md +38 -0
  81. package/dist/packages/mcp/assets/kb/app-help/streamer-overlay.md +36 -0
  82. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-confidence-tiers-experimental-unproven-paper-only.md +56 -0
  83. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-rug-proof-entries-experimental.md +52 -0
  84. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +3 -1
  85. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-strategy-dashboard-dexterm-dash.md +37 -0
  86. package/dist/packages/mcp/assets/kb/docs/docs-autotrade.md +3 -1
  87. package/dist/packages/mcp/assets/kb/docs/docs-beginners-before-any-experimental-trading.md +2 -2
  88. package/dist/packages/mcp/assets/kb/docs/docs-beginners-copy-and-paste-without-surprises.md +0 -1
  89. package/dist/packages/mcp/assets/kb/docs/docs-beginners-if-dexterm-says-command-not-found.md +2 -2
  90. package/dist/packages/mcp/assets/kb/docs/docs-beginners-reopen-dexterm-later.md +1 -1
  91. package/dist/packages/mcp/assets/kb/docs/docs-beginners-terminal-shell-prompt-command-output-and-path.md +2 -2
  92. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
  93. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows.md +1 -1
  94. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +2 -0
  95. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +2 -0
  96. package/dist/packages/mcp/assets/kb/docs/docs-classifier-hosted-jev-with-your-own-key.md +36 -0
  97. package/dist/packages/mcp/assets/kb/docs/docs-classifier-how-they-measured-on-dexterm-s-own-history.md +52 -0
  98. package/dist/packages/mcp/assets/kb/docs/docs-classifier-set-up-kev-on-your-own-machine.md +39 -0
  99. package/dist/packages/mcp/assets/kb/docs/docs-classifier-settings.md +35 -0
  100. package/dist/packages/mcp/assets/kb/docs/docs-classifier-the-choices.md +36 -0
  101. package/dist/packages/mcp/assets/kb/docs/docs-classifier-where-it-plugs-in.md +29 -0
  102. package/dist/packages/mcp/assets/kb/docs/docs-classifier-which-model-for-which-machine.md +42 -0
  103. package/dist/packages/mcp/assets/kb/docs/docs-classifier.md +32 -0
  104. package/dist/packages/mcp/assets/kb/docs/docs-cli-invocations.md +1 -1
  105. package/dist/packages/mcp/assets/kb/docs/docs-configuration-headless-recorder-dexterm-record.md +3 -0
  106. package/dist/packages/mcp/assets/kb/docs/docs-copy-trading.md +1 -1
  107. package/dist/packages/mcp/assets/kb/docs/docs-data-sources-recorded-history-and-retention.md +1 -0
  108. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
  109. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +2 -2
  110. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -11
  111. package/dist/packages/mcp/assets/kb/docs/docs-getting-started.md +2 -2
  112. package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +2 -2
  113. package/dist/packages/mcp/assets/kb/docs/docs-larp-paste-a-coin-on-the-web.md +1 -1
  114. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +1 -1
  115. package/dist/packages/mcp/assets/kb/docs/docs-narratives-how-coins-are-grouped.md +39 -0
  116. package/dist/packages/mcp/assets/kb/docs/docs-narratives-limits.md +29 -0
  117. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-is-measured.md +44 -0
  118. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-testing-showed.md +43 -0
  119. package/dist/packages/mcp/assets/kb/docs/docs-narratives-where-to-find-it.md +37 -0
  120. package/dist/packages/mcp/assets/kb/docs/docs-narratives.md +29 -0
  121. package/dist/packages/mcp/assets/kb/docs/docs-overlay-before-you-start.md +33 -0
  122. package/dist/packages/mcp/assets/kb/docs/docs-overlay-limits.md +30 -0
  123. package/dist/packages/mcp/assets/kb/docs/docs-overlay-options.md +36 -0
  124. package/dist/packages/mcp/assets/kb/docs/docs-overlay-security-model.md +36 -0
  125. package/dist/packages/mcp/assets/kb/docs/docs-overlay-set-it-up-in-obs-step-by-step.md +49 -0
  126. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-pnl-scene-paper-and-live.md +34 -0
  127. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-three-scenes.md +35 -0
  128. package/dist/packages/mcp/assets/kb/docs/docs-overlay-what-is-redacted.md +31 -0
  129. package/dist/packages/mcp/assets/kb/docs/docs-overlay.md +25 -0
  130. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading.md +1 -1
  131. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +2 -0
  132. package/dist/packages/mcp/assets/kb/docs/docs-screens-bubble-map.md +1 -1
  133. package/dist/packages/mcp/assets/kb/docs/docs-spectator-streaming-it.md +1 -0
  134. package/dist/packages/mcp/assets/kb/docs/docs-telegram-read-only-data-check-top-and-signals-experimental.md +48 -0
  135. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +3 -1
  136. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-will-not-do.md +1 -1
  137. package/dist/packages/mcp/assets/kb/docs/docs-voice-linux-and-windows.md +2 -2
  138. package/dist/packages/mcp/assets/kb/entries/narratives-tracker.md +45 -0
  139. package/dist/packages/mcp/src/server.js +106 -1
  140. package/dist/packages/mcp/src/spectator-sink.js +27 -0
  141. package/dist/packages/narratives/src/cluster.js +193 -0
  142. package/dist/packages/narratives/src/embed.js +184 -0
  143. package/dist/packages/narratives/src/export.js +60 -0
  144. package/dist/packages/narratives/src/index.js +10 -0
  145. package/dist/packages/narratives/src/label.js +48 -0
  146. package/dist/packages/narratives/src/normalize.js +179 -0
  147. package/dist/packages/narratives/src/recorder.js +93 -0
  148. package/dist/packages/narratives/src/tracker.js +328 -0
  149. package/dist/packages/packs/src/skill-care.js +10 -2
  150. package/dist/packages/recorder/src/exit-probes.js +215 -0
  151. package/dist/packages/recorder/src/index.js +1 -0
  152. package/dist/packages/recorder/src/recorder.js +11 -1
  153. package/dist/packages/recorder/src/retention.js +4 -0
  154. package/dist/packages/recorder/src/schema.js +20 -1
  155. package/dist/packages/tui/src/keymap.js +5 -0
  156. package/package.json +3 -1
  157. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +0 -39
@@ -0,0 +1,179 @@
1
+ // dexterm narratives (task-1818): text normalisation for meme coins. A coin needs no real project to run, so the
2
+ // signal is the THEME its name/ticker carries: ticker puns, leetspeak, glued words, copy-cat affixes ("BABY X",
3
+ // "X 2.0", "X INU"), emoji. Everything here is UNTRUSTED token text: it is firewalled (control/bidi/zero-width
4
+ // stripped, length-capped) before use, only ever matched and hashed, and never interpreted as instructions.
5
+ import { clean, classify, loadDefaultLexicon } from '../../packs/src/index.js';
6
+ /** Copy-cat / filler affixes: kept as weak tokens (they say "spawn of X", not what X is). */
7
+ export const AFFIXES = new Set([
8
+ 'baby', 'mini', 'mega', 'super', 'giga', 'ultra', 'little', 'lil', 'big', 'real', 'official', 'og', 'the', 'of', 'a', 'an',
9
+ 'on', 'in', 'and', 'my', 'your', 'coin', 'token', 'sol', 'solana', 'classic', 'ceo', 'dao', 'v2', 'v3', 'new', 'x', 'mr',
10
+ 'mrs', 'king', 'queen', 'lord', 'sir', 'is', 'it', 'to', 'for', 'with', 'wif', 'inu', 'pump', 'fun', 'meme', 'memecoin',
11
+ 'ai', 'gpt', 'bot', 'agent', 'jr', 'junior', 'pro', 'max', 'plus', 'black', 'white', 'red', 'blue', 'green', 'gold', 'golden',
12
+ ]);
13
+ /** Affixes stripped from glued tickers without a vocabulary (longest first). */
14
+ const PREFIX_AFFIXES = ['official', 'little', 'super', 'baby', 'mini', 'mega', 'giga', 'real', 'lil', 'the', 'mr', 'og'];
15
+ const SUFFIX_AFFIXES = ['coin', 'inu']; // not ai / sol: they end real words (bonsai, consol)
16
+ /** Tokens that carry no theme at all (dropped). */
17
+ const STOP = new Set(['', 'de', 'la', 'le', 'el', 'by', 'at', 'or', 'no', 'so', 'be', 'we', 'us', 'me', 'he', 'she']);
18
+ const EMOJI = [
19
+ [/[🐸]/gu, ' frog '], [/[🐶🐕🦮🐩]/gu, ' dog '], [/[🐱🐈😺😸]/gu, ' cat '], [/[🐧]/gu, ' penguin '], [/[🦛]/gu, ' hippo '],
20
+ [/[🤖]/gu, ' robot '], [/[🧠]/gu, ' brain '], [/[🎃]/gu, ' pumpkin '], [/[👻]/gu, ' ghost '], [/[🚀]/gu, ' rocket '],
21
+ [/[🐒🐵🦍]/gu, ' monkey '], [/[🐿]/gu, ' squirrel '], [/[🦈]/gu, ' shark '], [/[🐋🐳]/gu, ' whale '], [/[🍌]/gu, ' banana '],
22
+ [/[🐂🐮🐄]/gu, ' cow '], [/[🐷🐖]/gu, ' pig '], [/[🦆]/gu, ' duck '], [/[🐻]/gu, ' bear '], [/[🔥]/gu, ' fire '],
23
+ [/[💎]/gu, ' diamond '], [/[🌙🌕]/gu, ' moon '], [/[🇺🇸]/gu, ' usa '], [/[🎄🎅]/gu, ' christmas '], [/[🐉🐲]/gu, ' dragon '],
24
+ ];
25
+ const LEET = { '0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '7': 't', '8': 'b' };
26
+ /** A base vocabulary used to split glued tickers ("MOODENG" -> moo deng); grows from names seen (learnWords). */
27
+ const BASE_VOCAB = [
28
+ 'dog', 'cat', 'frog', 'pepe', 'doge', 'shib', 'moo', 'deng', 'pnut', 'nut', 'peanut', 'hat', 'wif', 'trump', 'elon', 'musk',
29
+ 'moon', 'sun', 'pump', 'chill', 'guy', 'girl', 'boy', 'man', 'king', 'cash', 'gold', 'baby', 'ai', 'agent', 'bot', 'brain',
30
+ 'rot', 'gork', 'grok', 'pig', 'cow', 'ape', 'monkey', 'penguin', 'hippo', 'bear', 'bull', 'duck', 'goat', 'fish', 'shark',
31
+ 'whale', 'banana', 'pizza', 'coffee', 'beer', 'fire', 'water', 'god', 'jesus', 'devil', 'ghost', 'pumpkin', 'santa', 'pop',
32
+ 'jelly', 'fart', 'butt', 'coin', 'stonk', 'rich', 'poor', 'happy', 'sad', 'mad', 'cute', 'tiny', 'fat', 'big', 'lil',
33
+ 'bonk', 'wojak', 'chad', 'sigma', 'giga', 'troll', 'meme', 'tard', 'retard', 'degen', 'ser', 'gm', 'based', 'usa', 'china',
34
+ 'japan', 'neko', 'inu', 'kitty', 'puppy', 'mew', 'meow', 'woof', 'bark', 'quack', 'quantum', 'robot', 'cyber', 'dark',
35
+ ];
36
+ /** Collapse every run of a repeated letter to one ("doggg" -> "dog", "moodeng" -> "modeng"): the fuzzy key form. */
37
+ export const squash = (s) => s.replace(/(.)\1+/gu, '$1');
38
+ function deLeet(w) {
39
+ if (!/\p{L}/u.test(w) || !/\d/.test(w))
40
+ return w; // pure words and pure numbers stay
41
+ return w.replace(/[0134578]/g, (d) => LEET[d] ?? d);
42
+ }
43
+ function splitCamel(s) {
44
+ // DogWifHat -> Dog Wif Hat, but leave ALLCAPS and lowercase runs alone
45
+ return s.replace(/(\p{Ll})(\p{Lu})/gu, '$1 $2').replace(/(\p{Lu})(\p{Lu}\p{Ll})/gu, '$1 $2');
46
+ }
47
+ /** Words of a firewalled string: emoji mapped to words, camelCase split, lowercased, leet decoded, runs of 3+ cut to 2. */
48
+ export function words(raw) {
49
+ let s = raw;
50
+ for (const [re, w] of EMOJI)
51
+ s = s.replace(re, w);
52
+ // apostrophes join ("don't" -> dont, not "don" + "t": the audit's "Don't Nut" joined a "don" narrative)
53
+ s = splitCamel(s).normalize('NFKC').toLowerCase().replace(/^\$+/, '').replace(/\$/g, ' ').replace(/(\p{L})['’](\p{L})/gu, '$1$2');
54
+ return s.split(/[^\p{L}\p{N}]+/u).map(deLeet).map((w) => w.replace(/(.)\1{2,}/gu, '$1$1')).filter((w) => !STOP.has(w));
55
+ }
56
+ /** Learned vocabulary (words seen standalone in >= 2 coin names) + the base list; used to split glued tickers. */
57
+ export class Vocab {
58
+ seen = new Map();
59
+ words = new Set(BASE_VOCAB);
60
+ constructor(extra = []) { for (const w of extra)
61
+ if (w.length >= 3 && /^\p{L}+$/u.test(w))
62
+ this.words.add(w); }
63
+ learn(ws) {
64
+ for (const w of new Set(ws)) {
65
+ if (w.length < 3 || w.length > 12 || !/^\p{L}+$/u.test(w) || AFFIXES.has(w))
66
+ continue;
67
+ const n = (this.seen.get(w) ?? 0) + 1;
68
+ this.seen.set(w, n);
69
+ if (n === 2)
70
+ this.words.add(w);
71
+ }
72
+ }
73
+ has(w) { return this.words.has(w) || AFFIXES.has(w); }
74
+ get size() { return this.words.size; }
75
+ /** Split a glued word into <= 3 known pieces (each >= 2 chars, at least one >= 3), longest-first DP; null if none.
76
+ * Fallback with no vocabulary: a leading or trailing copy-cat affix ("BABYFROGGO" -> baby + froggo, "FROGINU"
77
+ * -> frog + inu) even when the meme word has not been seen standalone yet (a copy can launch before the vocab knows it). */
78
+ segment(w) {
79
+ if (w.length < 5 || w.length > 24 || this.words.has(w))
80
+ return null;
81
+ const dp = this.segmentDp(w);
82
+ if (dp)
83
+ return dp;
84
+ for (const p of PREFIX_AFFIXES)
85
+ if (w.startsWith(p) && w.length - p.length >= 3)
86
+ return [p, w.slice(p.length)];
87
+ for (const x of SUFFIX_AFFIXES)
88
+ if (w.endsWith(x) && w.length - x.length >= 3)
89
+ return [w.slice(0, -x.length), x];
90
+ return null;
91
+ }
92
+ segmentDp(w) {
93
+ const n = w.length;
94
+ const best = new Array(n + 1).fill(null);
95
+ best[0] = [];
96
+ for (let i = 0; i < n; i++) {
97
+ const cur = best[i];
98
+ if (!cur || cur.length >= 3)
99
+ continue;
100
+ for (let j = Math.min(n, i + 12); j >= i + 2; j--) {
101
+ const piece = w.slice(i, j);
102
+ if (!(piece.length >= 3 ? this.has(piece) : piece === 'ai' || piece === 'gm' || piece === 'og'))
103
+ continue;
104
+ const cand = [...cur, piece];
105
+ const prev = best[j];
106
+ if (!prev || cand.length < prev.length)
107
+ best[j] = cand;
108
+ }
109
+ }
110
+ const r = best[n];
111
+ return r && r.length >= 2 && r.some((p) => p.length >= 3) ? r : null;
112
+ }
113
+ }
114
+ const lexicon = () => loadDefaultLexicon();
115
+ /**
116
+ * Normalise one coin's text. `description` is optional (most launches carry none in the recorder).
117
+ * Weights: ticker 1.2, name 1.0, description 0.4, glued-word pieces 0.7; affixes x0.25.
118
+ */
119
+ export function normalize(input, vocab) {
120
+ const symbol = clean(input.symbol, 16);
121
+ const name = clean(input.name, 64);
122
+ const desc = clean(input.description, 512);
123
+ const out = [];
124
+ const seen = new Map();
125
+ const add = (t, w, from) => {
126
+ if (!t || t.length > 24 || /^\d+$/.test(t) && t.length > 4)
127
+ return;
128
+ const affix = AFFIXES.has(t) || /^\d+$/.test(t);
129
+ const ww = affix ? w * 0.25 : w;
130
+ const e = seen.get(t);
131
+ if (e) {
132
+ e.w = Math.max(e.w, ww);
133
+ return;
134
+ }
135
+ const tok = { t, w: ww, affix, from };
136
+ seen.set(t, tok);
137
+ out.push(tok);
138
+ };
139
+ const fields = [[symbol, 1.2, 'symbol'], [name, 1.0, 'name'], [desc, 0.4, 'description']];
140
+ for (const [text, w, from] of fields) {
141
+ if (!text)
142
+ continue;
143
+ const ws = from === 'description' ? words(text).slice(0, 40) : words(text);
144
+ // glued words are split only where gluing is the norm: the ticker, or a name that is one word ("#BOYCOTTPUMPFUN");
145
+ // splitting ordinary name words pulled strangers in ("HEY EVERYONE" via "ever")
146
+ const glued = from === 'symbol' || (from === 'name' && ws.length === 1);
147
+ for (const x of ws) {
148
+ add(x, w, from);
149
+ if (vocab && glued) {
150
+ const seg = vocab.segment(x);
151
+ if (seg) {
152
+ for (const p of seg)
153
+ add(p, 0.7, 'split');
154
+ // "babymoodeng" -> also "moodeng" at full weight: the copy-cat affix is not part of the meme
155
+ const rest = seg.filter((p) => !AFFIXES.has(p));
156
+ if (rest.length && rest.length < seg.length)
157
+ add(rest.join(''), w, from);
158
+ }
159
+ }
160
+ }
161
+ }
162
+ const strong = out.filter((t) => !t.affix && t.from !== 'description' && t.t.length >= 2);
163
+ // the core meme word: a ticker word also in the name; else the ticker (a glued "BABYPNUT" -> its non-affix piece);
164
+ // else the longest name word
165
+ const nameWords = words(name);
166
+ const symWords = symbol ? words(symbol) : [];
167
+ const inBoth = strong.find((t) => t.from === 'symbol' && nameWords.includes(t.t));
168
+ let core = inBoth?.t ?? null;
169
+ if (!core && symWords.length === 1 && symWords[0].length >= 3 && !AFFIXES.has(symWords[0])) {
170
+ const seg = vocab?.segment(symWords[0]);
171
+ const piece = seg?.some((p) => AFFIXES.has(p)) ? seg.filter((p) => !AFFIXES.has(p)).join('') || undefined : undefined;
172
+ core = piece ?? symWords[0];
173
+ }
174
+ if (!core)
175
+ core = [...strong].filter((t) => t.from === 'name').sort((a, b) => b.t.length - a.t.length || b.w - a.w)[0]?.t ?? strong[0]?.t ?? null;
176
+ const keys = [...new Set(strong.map((t) => squash(t.t)).filter((k) => k.length >= 3))];
177
+ const cls = classify({ mint: '', symbol, name, description: desc || null }, lexicon());
178
+ return { tokens: out, keys, core, themes: cls.labels.map((l) => l.theme), symbol, name };
179
+ }
@@ -0,0 +1,93 @@
1
+ import { NarrativeTracker } from "./tracker.js";
2
+ const SQL = `SELECT mint, t, kind, symbol, name, price_usd, mcap, liquidity_usd, vol_m5, vol_h1, vol_h6, vol_h24,
3
+ num_traders_h1, net_buyers_h1, holder_count FROM token_snapshots WHERE t > ? AND t <= ? ORDER BY t`;
4
+ const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
5
+ export function rowToSample(r) {
6
+ return { mint: String(r.mint), t: Number(r.t), price: n(r.price_usd), mcap: n(r.mcap), liq: n(r.liquidity_usd), vol5m: n(r.vol_m5), vol1h: n(r.vol_h1), vol6h: n(r.vol_h6), vol24h: n(r.vol_h24), traders1h: n(r.num_traders_h1), netBuyers1h: n(r.net_buyers_h1), holders: n(r.holder_count) };
7
+ }
8
+ /** Replay [from, to] through a tracker (created if not given). Returns it after the last tick. */
9
+ export async function replayRecorder(db, opts, hooks = {}) {
10
+ const tickMs = opts.tickMs ?? 300_000;
11
+ const tr = opts.tracker ?? new NarrativeTracker({ ...opts, tickMs });
12
+ const stmt = db.prepare(SQL);
13
+ let t = opts.from;
14
+ let slice = performance.now();
15
+ while (t < opts.to) {
16
+ if (opts.yieldMs && performance.now() - slice >= opts.yieldMs) {
17
+ await new Promise((r) => setImmediate(r));
18
+ slice = performance.now();
19
+ }
20
+ const end = Math.min(opts.to, t + tickMs);
21
+ const rows = stmt.all(t, end).filter((r) => !opts.liveOnly || r.kind === 'live');
22
+ const fresh = [];
23
+ const seen = new Set();
24
+ for (const r of rows) {
25
+ const m = String(r.mint);
26
+ if (tr.coins.has(m) || seen.has(m))
27
+ continue;
28
+ seen.add(m);
29
+ fresh.push({ mint: m, t: Number(r.t), symbol: r.symbol ?? null, name: r.name ?? null });
30
+ }
31
+ if (hooks.beforeInterval)
32
+ await hooks.beforeInterval(t, end, tr, fresh);
33
+ await tr.addCoins(fresh);
34
+ if (hooks.afterCoins)
35
+ await hooks.afterCoins(t, end, tr, fresh);
36
+ for (const r of rows)
37
+ tr.addSample(rowToSample(r));
38
+ tr.tick(end, true);
39
+ if (hooks.afterTick)
40
+ await hooks.afterTick(end, tr);
41
+ t = end;
42
+ }
43
+ return tr;
44
+ }
45
+ /** The live board: replay the last `lookbackMs` (default 26 h) of the recorder into a fresh tracker. */
46
+ export async function trackerFromRecorder(db, opts = {}) {
47
+ const now = opts.now ?? Date.now();
48
+ const from = now - (opts.lookbackMs ?? 26 * 3_600_000);
49
+ const fine = opts.tickMs ?? 300_000;
50
+ // older history at 15-min ticks (lifecycle only needs "an hour ago" and the 24 h max), the last 2 h at the fine tick
51
+ const split = Math.max(from, now - 2 * 3_600_000);
52
+ const tracker = opts.tracker ?? new NarrativeTracker({ ...opts, tickMs: fine });
53
+ if (split > from)
54
+ await replayRecorder(db, { ...opts, from, to: split, tickMs: Math.max(fine, 900_000), tracker });
55
+ return replayRecorder(db, { ...opts, from: split, to: now, tickMs: fine, tracker });
56
+ }
57
+ /**
58
+ * A cached tracker over a recorder DB for the app surfaces (TUI panel, MCP tools, coin pane, watch rules).
59
+ * The first refresh replays the last `lookbackMs` (26 h); later refreshes replay only the rows since the last
60
+ * tick onto the same tracker, so ids stay stable for the life of the process and a refresh costs one interval.
61
+ */
62
+ export class RecorderNarratives {
63
+ tr = null;
64
+ lastT = 0;
65
+ busy = null;
66
+ db;
67
+ opts;
68
+ constructor(db, opts = {}) { this.db = db; this.opts = opts; }
69
+ get tracker() { return this.tr; }
70
+ async refresh(now = Date.now()) {
71
+ if (this.busy)
72
+ return this.busy;
73
+ if (this.tr && now - this.lastT < (this.opts.minRefreshMs ?? 60_000))
74
+ return this.tr;
75
+ this.busy = (async () => {
76
+ const from = this.tr ? this.lastT : now - (this.opts.lookbackMs ?? 26 * 3_600_000);
77
+ const tickMs = this.opts.tickMs ?? 300_000;
78
+ // replay whole ticks, then one partial tick up to now
79
+ const yieldMs = this.opts.yieldMs ?? 40; // keep the app's render loop alive during the first 26 h build
80
+ this.tr = this.tr
81
+ ? await replayRecorder(this.db, { ...this.opts, from, to: now, tickMs, tracker: this.tr, yieldMs })
82
+ : await trackerFromRecorder(this.db, { ...this.opts, now, lookbackMs: this.opts.lookbackMs, tickMs, yieldMs });
83
+ this.lastT = now;
84
+ return this.tr;
85
+ })();
86
+ try {
87
+ return await this.busy;
88
+ }
89
+ finally {
90
+ this.busy = null;
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,328 @@
1
+ // dexterm narratives (task-1818): the tracker. Coins come in (first sighting: name/ticker/description), market
2
+ // samples come in (price, liquidity, rolling volume, traders), and on each tick it recomputes, per narrative and
3
+ // per window (5m / 1h / 6h / 24h): coin count, new-coin rate, volume share, unique-trader growth, the top runner
4
+ // and its drawdown, a lifecycle state, a copy-cat saturation flag and a memetic traction score.
5
+ //
6
+ // Everything is MEASURED HEAT from recorded/live market data, never advice. Point-in-time by construction: a tick
7
+ // at `now` only reads samples with t <= now that were already ingested (the replay feeds rows in time order).
8
+ // Lifecycle rules (LIFECYCLE_RULES below, also in the docs page) are evaluated in order; the first match wins.
9
+ import { Clusterer, clusterLabel } from "./cluster.js";
10
+ import { LexicalEmbedder } from "./embed.js";
11
+ import { normalize, Vocab } from "./normalize.js";
12
+ export const WINDOWS = { '5m': 300_000, '1h': 3_600_000, '6h': 21_600_000, '24h': 86_400_000 };
13
+ /** Thresholds of the lifecycle and saturation rules (owner: maintainers; changing one changes the docs page). */
14
+ export const RULES = {
15
+ deadVol1h: 1_000, // narrative 1 h volume under this and no new coin in 6 h -> dead
16
+ sparkAgeMs: 3_600_000, // a narrative younger than 1 h is a spark
17
+ ignitionVol1h: 5_000, // ignition needs at least this much 1 h volume ...
18
+ ignitionGrowth: 2, // ... at least 2x its 1 h volume an hour ago (floored at deadVol1h) ...
19
+ ignitionTraders: 0.5, // ... and a new coin in the last hour OR +50% unique traders (1 h)
20
+ fadeFromMax: 0.4, // 1 h volume <= 40% of its 24 h max -> fading
21
+ fadeGrowth: 0.6, // or <= 60% of an hour ago
22
+ saturationMembers: 5, // copy-cat saturation: >= 5 coins,
23
+ saturationNew6h: 3, // >= 3 of them new in 6 h,
24
+ saturationCopyShare: 0.25, // and the coins after the 3 oldest hold < 25% of the 1 h volume
25
+ runnerMinLiq: 10_000, // a top runner must have a sellable pool
26
+ staleMs: 6 * 3_600_000, // a coin sample older than this does not count as live volume
27
+ };
28
+ export const LIFECYCLE_RULES = [
29
+ ['dead', 'no member sampled in 6 h, or 1 h volume < $1k with no new coin in 6 h'],
30
+ ['spark', 'first coin < 1 h ago (just formed)'],
31
+ ['ignition', '1 h volume >= $5k and >= 2x the 1 h volume an hour ago, with a new coin in the hour or unique traders (1 h) +50%'],
32
+ ['fading', '1 h volume <= 40% of its 24 h max, or <= 60% of an hour ago'],
33
+ ['peak', 'otherwise: hot, near its 24 h high, growth under 2x'],
34
+ ];
35
+ const clip01 = (x) => (Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0);
36
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
37
+ export class NarrativeTracker {
38
+ clusterer;
39
+ embedder;
40
+ vocab = new Vocab();
41
+ coins = new Map();
42
+ hist = new Map();
43
+ snap = new Map();
44
+ lastTick = -Infinity;
45
+ tickMs;
46
+ listeners = [];
47
+ recent = new Set(); // coins sampled in the last 24 h
48
+ stats = { coins: 0, samples: 0, ticks: 0, embedMs: 0, tickMs: 0 };
49
+ constructor(opts = {}) {
50
+ this.embedder = opts.embedder ?? new LexicalEmbedder();
51
+ // the lexical embedder only links coins that share a word, so the "hot clusters" candidate set is wasted work there
52
+ this.clusterer = new Clusterer({ ...opts, hotMax: opts.hotMax ?? (this.embedder.id.startsWith('lexical') ? 0 : 300) });
53
+ this.tickMs = opts.tickMs ?? 60_000;
54
+ }
55
+ onTransition(fn) { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter((f) => f !== fn); }; }
56
+ /** First sightings, in time order. Social links are used only as extra theme words (their handle), never fetched. */
57
+ async addCoins(inputs) {
58
+ const fresh = inputs.filter((c) => !this.coins.has(c.mint));
59
+ if (!fresh.length)
60
+ return;
61
+ const norms = fresh.map((c) => {
62
+ const handle = c.socials ? Object.values(c.socials).filter(Boolean).map((u) => String(u).split(/[/?#@]/).filter(Boolean).pop() ?? '').join(' ') : '';
63
+ const desc = [c.description ?? '', handle].filter(Boolean).join(' ') || null;
64
+ const n = normalize({ symbol: c.symbol, name: c.name, description: desc }, this.vocab);
65
+ this.vocab.learn(n.tokens.filter((t) => t.from === 'name' || t.from === 'symbol').map((t) => t.t));
66
+ return n;
67
+ });
68
+ const t0 = performance.now();
69
+ const vecs = await this.embedder.embed(norms);
70
+ this.stats.embedMs += performance.now() - t0;
71
+ fresh.forEach((c, i) => {
72
+ if (this.coins.has(c.mint))
73
+ return;
74
+ const a = this.clusterer.assign(c.mint, c.t, norms[i], vecs[i]);
75
+ this.coins.set(c.mint, { mint: c.mint, firstT: c.t, narrative: a.id, spawn: a.spawn, symbol: norms[i].symbol, name: norms[i].name, core: norms[i].core, last: null, firstLiqPrice: null, peakPrice: null, lastT: c.t });
76
+ this.stats.coins++;
77
+ });
78
+ }
79
+ /** A market sample for a known coin (unknown mints are ignored: call addCoins first). */
80
+ addSample(s) {
81
+ const c = this.coins.get(s.mint);
82
+ if (!c)
83
+ return;
84
+ this.stats.samples++;
85
+ c.last = s;
86
+ c.lastT = s.t;
87
+ this.recent.add(s.mint);
88
+ const p = s.price ?? null;
89
+ if (p != null && p > 0 && num(s.liq) >= RULES.runnerMinLiq) {
90
+ if (c.firstLiqPrice == null)
91
+ c.firstLiqPrice = p;
92
+ c.peakPrice = Math.max(c.peakPrice ?? 0, p);
93
+ }
94
+ }
95
+ /** Recompute every narrative's metrics if at least tickMs passed (or force). Returns true when it ran. */
96
+ tick(now, force = false) {
97
+ if (!force && now - this.lastTick < this.tickMs)
98
+ return false;
99
+ const t0 = performance.now();
100
+ this.lastTick = now;
101
+ this.stats.ticks++;
102
+ const acc = new Map();
103
+ const tot = { '5m': 0, '1h': 0, '6h': 0, '24h': 0 };
104
+ const vkey = { '5m': 'vol5m', '1h': 'vol1h', '6h': 'vol6h', '24h': 'vol24h' };
105
+ const get = (id) => {
106
+ let a = acc.get(id);
107
+ if (!a)
108
+ acc.set(id, (a = { w: { '5m': { coins: 0, newCoins: 0, volume: 0 }, '1h': { coins: 0, newCoins: 0, volume: 0 }, '6h': { coins: 0, newCoins: 0, volume: 0 }, '24h': { coins: 0, newCoins: 0, volume: 0 } }, traders: 0, vol1hByMember: [], runner: null, runnerX: 0, repeated: 0, liveMembers: 0, newest6h: 0 }));
109
+ return a;
110
+ };
111
+ for (const mint of this.recent) {
112
+ const c = this.coins.get(mint);
113
+ if (now - c.lastT > WINDOWS['24h']) {
114
+ this.recent.delete(mint);
115
+ continue;
116
+ }
117
+ const s = c.last;
118
+ const a = get(c.narrative);
119
+ const age = now - s.t;
120
+ const live = age <= RULES.staleMs;
121
+ if (live)
122
+ a.liveMembers++;
123
+ for (const k of Object.keys(WINDOWS)) {
124
+ if (age <= Math.max(WINDOWS[k], 900_000))
125
+ a.w[k].coins++;
126
+ if (live) {
127
+ const v = num(s[vkey[k]]);
128
+ a.w[k].volume += v;
129
+ tot[k] += v;
130
+ }
131
+ }
132
+ if (live) {
133
+ a.traders += num(s.traders1h);
134
+ a.vol1hByMember.push([c, num(s.vol1h)]);
135
+ }
136
+ if (live && c.firstLiqPrice && s.price && num(s.liq) >= RULES.runnerMinLiq) {
137
+ const x = s.price / c.firstLiqPrice;
138
+ if (x > a.runnerX) {
139
+ a.runnerX = x;
140
+ a.runner = c;
141
+ }
142
+ }
143
+ }
144
+ // new-coin counts and repeated-name velocity come from the member lists (first sightings), not samples
145
+ for (const [id, a] of acc) {
146
+ const cl = this.clusterer.clusters.get(id);
147
+ const main = mainCore(cl);
148
+ for (let i = cl.members.length - 1; i >= 0; i--) {
149
+ const m = cl.members[i];
150
+ const age = now - m.t;
151
+ if (age < 0)
152
+ continue;
153
+ if (age > WINDOWS['24h'])
154
+ break;
155
+ for (const k of Object.keys(WINDOWS))
156
+ if (age <= WINDOWS[k])
157
+ a.w[k].newCoins++;
158
+ if (age <= WINDOWS['1h'] && main && m.core && squashEq(m.core, main))
159
+ a.repeated++;
160
+ if (age <= WINDOWS['6h'])
161
+ a.newest6h++;
162
+ }
163
+ }
164
+ const next = new Map();
165
+ const ranked = [];
166
+ const newCoinVol1h = [...this.recent].reduce((s, m) => { const c = this.coins.get(m); return s + (now - c.firstT <= WINDOWS['24h'] && now - c.lastT <= RULES.staleMs ? num(c.last?.vol1h) : 0); }, 0);
167
+ for (const [id, a] of acc) {
168
+ const cl = this.clusterer.clusters.get(id);
169
+ // history only for real narratives (>= 2 coins): a singleton's 24 h history was 2/3 of the replay's memory
170
+ const h = cl.members.length > 1 ? this.pushHist(id, now, a.w['1h'].volume, a.traders) : [];
171
+ const ago = histAt(h, now - WINDOWS['1h']);
172
+ const max24 = h.reduce((m, x) => (now - x.t <= WINDOWS['24h'] ? Math.max(m, x.vol1h) : m), 0);
173
+ const V1 = a.w['1h'].volume;
174
+ const windows = {};
175
+ for (const k of Object.keys(WINDOWS)) {
176
+ const tr = histAt(h, now - WINDOWS[k]);
177
+ // history points are 15 min apart, so a 5-min trader growth would really be ~15-20 min: null there, never mislabelled
178
+ windows[k] = { coins: a.w[k].coins, newCoins: a.w[k].newCoins, newPerHour: a.w[k].newCoins / (WINDOWS[k] / 3_600_000), volume: a.w[k].volume, volumeShare: tot[k] > 0 ? a.w[k].volume / tot[k] : null, traders: a.traders, tradersGrowth: k !== '5m' && tr && tr.traders > 0 ? a.traders / tr.traders - 1 : null };
179
+ }
180
+ const growth = ago ? V1 / Math.max(ago.vol1h, RULES.deadVol1h) : null;
181
+ const age = now - cl.firstT;
182
+ let lc, rule;
183
+ if (a.liveMembers === 0 || (V1 < RULES.deadVol1h && a.newest6h === 0))
184
+ [lc, rule] = LIFECYCLE_RULES[0];
185
+ else if (age < RULES.sparkAgeMs)
186
+ [lc, rule] = LIFECYCLE_RULES[1];
187
+ else if (V1 >= RULES.ignitionVol1h && growth != null && growth >= RULES.ignitionGrowth && (windows['1h'].newCoins >= 1 || (windows['1h'].tradersGrowth ?? 0) >= RULES.ignitionTraders))
188
+ [lc, rule] = LIFECYCLE_RULES[2];
189
+ else if ((max24 > 0 && V1 <= RULES.fadeFromMax * max24) || (growth != null && growth <= RULES.fadeGrowth))
190
+ [lc, rule] = LIFECYCLE_RULES[3];
191
+ else
192
+ [lc, rule] = LIFECYCLE_RULES[4];
193
+ // copy-cat saturation: many recent spawns, and the spawns after the first three are not getting the volume
194
+ const byAge = [...a.vol1hByMember].sort((x, y) => x[0].spawn - y[0].spawn);
195
+ const copyVol = byAge.slice(3).reduce((s, x) => s + x[1], 0);
196
+ const saturated = cl.members.length >= RULES.saturationMembers && a.newest6h >= RULES.saturationNew6h && V1 > 0 && copyVol / V1 < RULES.saturationCopyShare;
197
+ const comp = {
198
+ growth: growth == null ? null : clip01(Math.log2(Math.max(growth, 1e-6)) / 2 + 0.5),
199
+ spawnRate: clip01(windows['1h'].newCoins / 5),
200
+ nameVelocity: clip01(a.repeated / 5),
201
+ newCoinVolumeShare: newCoinVol1h > 0 ? clip01((V1 / newCoinVol1h) * 10) : null,
202
+ breadth: clip01(a.vol1hByMember.filter(([c]) => num(c.last?.liq) >= RULES.runnerMinLiq).length / 5),
203
+ holderOverlap: null,
204
+ };
205
+ const W = { growth: 0.3, spawnRate: 0.2, nameVelocity: 0.15, newCoinVolumeShare: 0.25, breadth: 0.1, holderOverlap: 0.1 };
206
+ let sw = 0, sv = 0;
207
+ for (const [k, v] of Object.entries(comp))
208
+ if (v != null) {
209
+ sw += W[k];
210
+ sv += W[k] * v;
211
+ }
212
+ const score = lc === 'dead' ? 0 : Math.round((sw > 0 ? sv / sw : 0) * 100);
213
+ const r = a.runner;
214
+ const snap = {
215
+ id, label: cl.members.length > 1 ? clusterLabel(cl) : (cl.members[0].symbol || cl.seedCore || '?'), modelLabel: cl.modelLabel ?? null, theme: this.clusterer.topTheme(cl), members: cl.members.length, firstT: cl.firstT,
216
+ ageMin: Math.round(age / 60_000), lifecycle: lc, lifecycleRule: rule, saturated, score, components: comp, rank: null, windows,
217
+ vol1hAgo: ago ? ago.vol1h : null, vol1hMax24h: max24 || null,
218
+ topRunner: r && r.last?.price && r.peakPrice ? { mint: r.mint, symbol: r.symbol, x: a.runnerX, drawdownPct: (r.last.price / r.peakPrice - 1) * 100, liq: num(r.last.liq) } : null,
219
+ repeatedName1h: a.repeated, holderOverlap: null,
220
+ };
221
+ next.set(id, snap);
222
+ if (cl.members.length >= 2 && lc !== 'dead')
223
+ ranked.push(snap);
224
+ }
225
+ ranked.sort((x, y) => y.score - x.score || y.windows['1h'].volume - x.windows['1h'].volume || (x.id < y.id ? -1 : 1));
226
+ ranked.forEach((s, i) => { s.rank = i + 1; });
227
+ for (const [id, s] of next) {
228
+ const prev = this.snap.get(id)?.lifecycle ?? null;
229
+ if (prev !== s.lifecycle && s.members >= 2)
230
+ for (const fn of this.listeners)
231
+ fn({ id, label: s.label, from: prev, to: s.lifecycle, t: now, rank: s.rank, members: s.members });
232
+ }
233
+ // narratives with no recent member keep their last state as dead
234
+ for (const [id, s] of this.snap)
235
+ if (!next.has(id) && s.lifecycle !== 'dead')
236
+ next.set(id, { ...s, lifecycle: 'dead', lifecycleRule: LIFECYCLE_RULES[0][1], rank: null, score: 0 });
237
+ else if (!next.has(id))
238
+ next.set(id, s);
239
+ this.snap = next;
240
+ if (this.stats.ticks % 12 === 0)
241
+ for (const [id, h] of this.hist)
242
+ if (!h.length || now - h[h.length - 1].t > WINDOWS['24h'] + 3_600_000)
243
+ this.hist.delete(id);
244
+ this.stats.tickMs += performance.now() - t0;
245
+ return true;
246
+ }
247
+ pushHist(id, t, vol1h, traders) {
248
+ let h = this.hist.get(id);
249
+ if (!h)
250
+ this.hist.set(id, (h = []));
251
+ // one point per 15 min is enough for "an hour ago" and the 24 h max (the newest point is overwritten in between)
252
+ const last = h[h.length - 1];
253
+ if (last && h.length > 1 && t - h[h.length - 2].t < 900_000) {
254
+ last.t = t;
255
+ last.vol1h = vol1h;
256
+ last.traders = traders;
257
+ }
258
+ else
259
+ h.push({ t, vol1h, traders });
260
+ while (h.length && t - h[0].t > WINDOWS['24h'] + 3_600_000)
261
+ h.shift();
262
+ return h;
263
+ }
264
+ /** Narratives by rank (ranked first, then by 1 h volume). */
265
+ top(limit = 20, opts = {}) {
266
+ const min = opts.minMembers ?? 2;
267
+ return [...this.snap.values()].filter((s) => s.members >= min && (opts.includeDead || s.lifecycle !== 'dead'))
268
+ .sort((a, b) => (a.rank ?? 1e9) - (b.rank ?? 1e9) || b.windows['1h'].volume - a.windows['1h'].volume).slice(0, limit);
269
+ }
270
+ detail(id) {
271
+ const cl = this.clusterer.clusters.get(id);
272
+ if (!cl)
273
+ return null;
274
+ const s = this.snap.get(id) ?? this.emptySnap(cl);
275
+ const memberList = cl.members.map((m) => {
276
+ const c = this.coins.get(m.mint);
277
+ const p = c.last?.price ?? null;
278
+ return { mint: m.mint, symbol: m.symbol, name: m.name, spawn: m.spawn, firstT: m.t, price: p, liq: c.last?.liq ?? null, vol1h: c.last?.vol1h ?? null, x: p && c.firstLiqPrice ? p / c.firstLiqPrice : null };
279
+ });
280
+ return { ...s, memberList };
281
+ }
282
+ /** Narrative of one coin at the last tick (point-in-time in a replay). */
283
+ narrativeOf(mint) {
284
+ const c = this.coins.get(mint);
285
+ if (!c)
286
+ return null;
287
+ const cl = this.clusterer.clusters.get(c.narrative);
288
+ const s = this.snap.get(c.narrative) ?? null;
289
+ return {
290
+ mint, narrative: c.narrative, label: s?.label ?? clusterLabel(cl), theme: this.clusterer.topTheme(cl), spawn: c.spawn,
291
+ members: cl.members.filter((m) => m.t <= this.lastTick || this.lastTick === -Infinity).length,
292
+ lifecycle: s?.lifecycle ?? null, rank: s?.rank ?? null, score: s?.score ?? null, saturated: s?.saturated ?? false,
293
+ isTopRunner: s?.topRunner?.mint === mint, vol1hShare: s?.windows['1h'].volumeShare ?? null, newPerHour1h: s?.windows['1h'].newPerHour ?? null,
294
+ growth1h: s && s.vol1hAgo != null ? s.windows['1h'].volume / Math.max(s.vol1hAgo, RULES.deadVol1h) : null,
295
+ };
296
+ }
297
+ setModelLabel(id, label) { const c = this.clusterer.clusters.get(id); if (c)
298
+ c.modelLabel = label; }
299
+ get lastTickAt() { return Number.isFinite(this.lastTick) ? this.lastTick : null; }
300
+ emptySnap(cl) {
301
+ const z = () => ({ coins: 0, newCoins: 0, newPerHour: 0, volume: 0, volumeShare: null, traders: 0, tradersGrowth: null });
302
+ return { id: cl.id, label: clusterLabel(cl), modelLabel: cl.modelLabel ?? null, theme: this.clusterer.topTheme(cl), members: cl.members.length, firstT: cl.firstT, ageMin: 0, lifecycle: 'spark', lifecycleRule: LIFECYCLE_RULES[1][1], saturated: false, score: 0, components: {}, rank: null, windows: { '5m': z(), '1h': z(), '6h': z(), '24h': z() }, vol1hAgo: null, vol1hMax24h: null, topRunner: null, repeatedName1h: 0, holderOverlap: null };
303
+ }
304
+ }
305
+ function mainCore(c) {
306
+ let best = null, n = 0;
307
+ for (const [k, v] of c.cores)
308
+ if (v > n) {
309
+ best = k;
310
+ n = v;
311
+ }
312
+ return best;
313
+ }
314
+ const squashEq = (a, b) => a.replace(/(.)\1+/gu, '$1') === b;
315
+ /** The newest history point at or before t (null if none within 2 ticks' tolerance of the window start). */
316
+ function histAt(h, t) {
317
+ let lo = 0, hi = h.length - 1, r = null;
318
+ while (lo <= hi) {
319
+ const mid = (lo + hi) >> 1;
320
+ if (h[mid].t <= t) {
321
+ r = h[mid];
322
+ lo = mid + 1;
323
+ }
324
+ else
325
+ hi = mid - 1;
326
+ }
327
+ return r;
328
+ }
@@ -19,6 +19,14 @@ export const SKILL_CARE_SCHEMA = 'dexterm/packs/skill-care/1';
19
19
  export const DEFAULT_MIN_NEW_COINS = 150;
20
20
  /** Tolerance (absolute MCC) for calling the frozen canary "regressed" or "held". */
21
21
  export const DEFAULT_CANARY_NOISE = 0.05;
22
+ /**
23
+ * A split cutoff as ISO time for the split_rule text (task-1982). An empty split leaves the MAX_SAFE_INTEGER
24
+ * sentinel, which is outside the Date range: `toISOString()` would throw RangeError, so it reads as "none" instead.
25
+ */
26
+ export function cutoffIso(cutoff) {
27
+ const d = new Date(cutoff);
28
+ return Number.isNaN(d.getTime()) ? 'none (empty test split)' : d.toISOString();
29
+ }
22
30
  /**
23
31
  * Re-split an eval set so the NEWEST coins are a held-out TEST window the tuning never sees. By default (and
24
32
  * whenever `windowDays` is not given) the newest `testFrac` of coins by t0 are TEST (count-based, so TRAIN stays
@@ -31,14 +39,14 @@ export function rollingSplit(set, opts = {}) {
31
39
  if (opts.windowDays !== undefined) {
32
40
  const anchor = opts.anchor ?? set.rows.reduce((m, r) => Math.max(m, r.t0), 0);
33
41
  cutoff = anchor - opts.windowDays * 86_400_000;
34
- rule = `rolling window: coins with t0 in the newest ${opts.windowDays} day(s) (t0 ≥ ${new Date(cutoff).toISOString()}) are the held-out TEST window; never tuned on`;
42
+ rule = `rolling window: coins with t0 in the newest ${opts.windowDays} day(s) (t0 ≥ ${cutoffIso(cutoff)}) are the held-out TEST window; never tuned on`;
35
43
  }
36
44
  else {
37
45
  const frac = opts.testFrac ?? 0.3;
38
46
  const sorted = [...set.rows].sort((a, b) => a.t0 - b.t0);
39
47
  const idx = Math.min(sorted.length, Math.floor(sorted.length * (1 - frac)));
40
48
  cutoff = sorted[idx]?.t0 ?? Number.MAX_SAFE_INTEGER;
41
- rule = `rolling window: the newest ${Math.round(frac * 100)}% of coins by t0 (t0 ≥ ${new Date(cutoff).toISOString()}) are the held-out TEST window; never tuned on`;
49
+ rule = `rolling window: the newest ${Math.round(frac * 100)}% of coins by t0 (t0 ≥ ${cutoffIso(cutoff)}) are the held-out TEST window; never tuned on`;
42
50
  }
43
51
  const rows = set.rows.map((r) => ({ ...r, split: r.t0 >= cutoff ? 'test' : 'train' }));
44
52
  const train = rows.filter((r) => r.split === 'train');