dexterm-cli 0.1.1 → 0.1.3

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 (155) hide show
  1. package/dist/packages/agents/src/bus.js +57 -6
  2. package/dist/packages/agents/src/executor.js +25 -0
  3. package/dist/packages/agents/src/follow.js +105 -0
  4. package/dist/packages/agents/src/gate.js +3 -0
  5. package/dist/packages/agents/src/identity.js +41 -2
  6. package/dist/packages/agents/src/index.js +5 -0
  7. package/dist/packages/agents/src/spectator-server.js +153 -0
  8. package/dist/packages/agents/src/spectator.js +212 -0
  9. package/dist/packages/agents/src/tiers.js +5 -0
  10. package/dist/packages/agents/src/tools.js +3 -0
  11. package/dist/packages/cli/assets/claude/analyst-system.md +6 -1
  12. package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +11 -3
  13. package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +13 -3
  14. package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +3 -3
  15. package/dist/packages/cli/src/agent-cmd.js +107 -10
  16. package/dist/packages/cli/src/agent-spectator.js +37 -0
  17. package/dist/packages/cli/src/alerts/adapters/desktop.js +40 -0
  18. package/dist/packages/cli/src/alerts/adapters/discord.js +36 -0
  19. package/dist/packages/cli/src/alerts/adapters/telegram.js +43 -0
  20. package/dist/packages/cli/src/alerts/adapters/toast.js +16 -0
  21. package/dist/packages/cli/src/alerts/creds.js +83 -0
  22. package/dist/packages/cli/src/alerts/format.js +66 -0
  23. package/dist/packages/cli/src/alerts/index.js +10 -0
  24. package/dist/packages/cli/src/alerts/notifier.js +53 -0
  25. package/dist/packages/cli/src/alerts/route.js +19 -0
  26. package/dist/packages/cli/src/alerts/types.js +1 -0
  27. package/dist/packages/cli/src/alerts-cmd.js +212 -0
  28. package/dist/packages/cli/src/app-help-gen.js +365 -0
  29. package/dist/packages/cli/src/backtest-cmd.js +62 -3
  30. package/dist/packages/cli/src/coin-target.js +2 -0
  31. package/dist/packages/cli/src/doctor.js +21 -6
  32. package/dist/packages/cli/src/export.js +235 -0
  33. package/dist/packages/cli/src/follow-cmd.js +107 -1
  34. package/dist/packages/cli/src/help-cmd.js +48 -0
  35. package/dist/packages/cli/src/live/index.js +66 -3
  36. package/dist/packages/cli/src/live/screens/token-live.js +24 -0
  37. package/dist/packages/cli/src/live/signals.js +109 -0
  38. package/dist/packages/cli/src/live/trade.js +24 -1
  39. package/dist/packages/cli/src/main.js +596 -49
  40. package/dist/packages/cli/src/mcp-cmd.js +321 -0
  41. package/dist/packages/cli/src/pnl-app.js +221 -0
  42. package/dist/packages/cli/src/pnl-card.js +615 -0
  43. package/dist/packages/cli/src/record-cmd.js +455 -0
  44. package/dist/packages/cli/src/rules.js +69 -2
  45. package/dist/packages/cli/src/screens/follows.js +91 -2
  46. package/dist/packages/cli/src/screens/overlay.js +3 -0
  47. package/dist/packages/cli/src/screens/positions.js +1 -1
  48. package/dist/packages/cli/src/screens/rules.js +1 -1
  49. package/dist/packages/cli/src/skill-eval-build.js +109 -0
  50. package/dist/packages/cli/src/spectate-cmd.js +104 -0
  51. package/dist/packages/cli/src/state.js +1 -1
  52. package/dist/packages/cli/src/update.js +294 -0
  53. package/dist/packages/cli/src/version.js +3 -0
  54. package/dist/packages/cli/src/voice-cmd.js +194 -0
  55. package/dist/packages/cli/src/voice-hud.js +1 -1
  56. package/dist/packages/cli/src/watch-cmd.js +190 -0
  57. package/dist/packages/custom/src/rules.js +3 -2
  58. package/dist/packages/custom/src/schema.js +24 -1
  59. package/dist/packages/data/src/analyst-actions.js +130 -3
  60. package/dist/packages/data/src/coin-target.js +82 -0
  61. package/dist/packages/data/src/fast-lane.js +16 -2
  62. package/dist/packages/data/src/index.js +4 -0
  63. package/dist/packages/data/src/intel/helius-history.js +12 -1
  64. package/dist/packages/data/src/intel/live-signals.js +185 -0
  65. package/dist/packages/data/src/larp/link-baseline.js +196 -0
  66. package/dist/packages/data/src/sources/dexscreener.js +14 -0
  67. package/dist/packages/data/src/sources/pumpportal.js +6 -1
  68. package/dist/packages/data/src/trade/copy/index.js +1 -1
  69. package/dist/packages/data/src/trade/copy/stats.js +36 -2
  70. package/dist/packages/data/src/trade/copy/tracker.js +3 -2
  71. package/dist/packages/mcp/assets/kb/app-help/app-help-command.md +35 -0
  72. package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +43 -0
  73. package/dist/packages/mcp/assets/kb/app-help/backtest.md +33 -0
  74. package/dist/packages/mcp/assets/kb/app-help/bubble-map.md +35 -0
  75. package/dist/packages/mcp/assets/kb/app-help/calm-mode.md +31 -0
  76. package/dist/packages/mcp/assets/kb/app-help/change-voice-speed.md +31 -0
  77. package/dist/packages/mcp/assets/kb/app-help/change-voice.md +30 -0
  78. package/dist/packages/mcp/assets/kb/app-help/chart-popup-selftest.md +32 -0
  79. package/dist/packages/mcp/assets/kb/app-help/coin-images.md +29 -0
  80. package/dist/packages/mcp/assets/kb/app-help/configure-rpc-key.md +28 -0
  81. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent-registry.md +35 -0
  82. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +39 -0
  83. package/dist/packages/mcp/assets/kb/app-help/custom-keymap.md +32 -0
  84. package/dist/packages/mcp/assets/kb/app-help/edit-kb-or-skill.md +36 -0
  85. package/dist/packages/mcp/assets/kb/app-help/edit-rules.md +34 -0
  86. package/dist/packages/mcp/assets/kb/app-help/export-a-view.md +36 -0
  87. package/dist/packages/mcp/assets/kb/app-help/follow-a-wallet.md +33 -0
  88. package/dist/packages/mcp/assets/kb/app-help/getting-started.md +37 -0
  89. package/dist/packages/mcp/assets/kb/app-help/kol-lists.md +34 -0
  90. package/dist/packages/mcp/assets/kb/app-help/larp-check.md +36 -0
  91. package/dist/packages/mcp/assets/kb/app-help/link-a-wallet.md +41 -0
  92. package/dist/packages/mcp/assets/kb/app-help/login-with-x.md +33 -0
  93. package/dist/packages/mcp/assets/kb/app-help/narrative-board.md +32 -0
  94. package/dist/packages/mcp/assets/kb/app-help/open-a-coin.md +43 -0
  95. package/dist/packages/mcp/assets/kb/app-help/open-chart-popup.md +33 -0
  96. package/dist/packages/mcp/assets/kb/app-help/order-plans.md +33 -0
  97. package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +36 -0
  98. package/dist/packages/mcp/assets/kb/app-help/price-or-marketcap.md +38 -0
  99. package/dist/packages/mcp/assets/kb/app-help/record-headless.md +34 -0
  100. package/dist/packages/mcp/assets/kb/app-help/recorder-history.md +39 -0
  101. package/dist/packages/mcp/assets/kb/app-help/rug-check.md +39 -0
  102. package/dist/packages/mcp/assets/kb/app-help/run-doctor.md +32 -0
  103. package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +37 -0
  104. package/dist/packages/mcp/assets/kb/app-help/screen-analyst.md +181 -0
  105. package/dist/packages/mcp/assets/kb/app-help/screen-kol.md +190 -0
  106. package/dist/packages/mcp/assets/kb/app-help/screen-newpairs.md +181 -0
  107. package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +180 -0
  108. package/dist/packages/mcp/assets/kb/app-help/screen-screener.md +199 -0
  109. package/dist/packages/mcp/assets/kb/app-help/screen-token.md +240 -0
  110. package/dist/packages/mcp/assets/kb/app-help/screen-trending.md +192 -0
  111. package/dist/packages/mcp/assets/kb/app-help/screen-watchlist.md +181 -0
  112. package/dist/packages/mcp/assets/kb/app-help/set-a-filter.md +36 -0
  113. package/dist/packages/mcp/assets/kb/app-help/setup-neural-voice.md +36 -0
  114. package/dist/packages/mcp/assets/kb/app-help/share-packs.md +34 -0
  115. package/dist/packages/mcp/assets/kb/app-help/sort-the-screener.md +34 -0
  116. package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +36 -0
  117. package/dist/packages/mcp/assets/kb/app-help/standing-mandates.md +33 -0
  118. package/dist/packages/mcp/assets/kb/app-help/switch-theme.md +42 -0
  119. package/dist/packages/mcp/assets/kb/app-help/turn-on-alerts.md +37 -0
  120. package/dist/packages/mcp/assets/kb/app-help/update-dexterm.md +30 -0
  121. package/dist/packages/mcp/assets/kb/app-help/use-presets.md +42 -0
  122. package/dist/packages/mcp/assets/kb/app-help/use-voice-mode.md +36 -0
  123. package/dist/packages/mcp/assets/kb/app-help/watch-a-coin.md +34 -0
  124. package/dist/packages/mcp/assets/kb/app-help/watch-headless.md +35 -0
  125. package/dist/packages/mcp/assets/kb/app-help/what-dexterm-shows-agents.md +31 -0
  126. package/dist/packages/mcp/assets/kb/app-help/workspaces.md +38 -0
  127. package/dist/packages/mcp/src/app-help.js +135 -0
  128. package/dist/packages/mcp/src/index.js +3 -0
  129. package/dist/packages/mcp/src/server.js +179 -5
  130. package/dist/packages/mcp/src/spectator-sink.js +72 -0
  131. package/dist/packages/packs/src/index.js +3 -0
  132. package/dist/packages/packs/src/skill-backtest.js +300 -0
  133. package/dist/packages/packs/src/skill-care.js +206 -0
  134. package/dist/packages/popup/src/pages/spectator.css +45 -0
  135. package/dist/packages/popup/src/pages/spectator.html +25 -0
  136. package/dist/packages/popup/src/pages/spectator.js +74 -0
  137. package/dist/packages/popup/src/selftest.js +19 -0
  138. package/dist/packages/popup/src/server.js +7 -1
  139. package/dist/packages/tui/src/index.js +3 -0
  140. package/dist/packages/tui/src/keymap.js +12 -0
  141. package/dist/packages/tui/src/raster-font.js +195 -0
  142. package/dist/packages/tui/src/raster.js +213 -0
  143. package/dist/packages/voice/src/controller.js +57 -4
  144. package/dist/packages/voice/src/doctor.js +96 -11
  145. package/dist/packages/voice/src/download.js +184 -0
  146. package/dist/packages/voice/src/engines.js +55 -8
  147. package/dist/packages/voice/src/index.js +9 -3
  148. package/dist/packages/voice/src/speakable.js +111 -0
  149. package/dist/packages/voice/src/stt/whisper.js +14 -4
  150. package/dist/packages/voice/src/tts/espeak.js +131 -0
  151. package/dist/packages/voice/src/tts/piper-setup.js +229 -0
  152. package/dist/packages/voice/src/tts/piper.js +449 -0
  153. package/dist/packages/voice/src/tts/say.js +72 -35
  154. package/dist/packages/voice/src/tts/voices.js +209 -0
  155. package/package.json +5 -9
@@ -0,0 +1,185 @@
1
+ // dexterm — LIVE holder / rug SIGNALS engine (task-1404). Pure + deterministic.
2
+ //
3
+ // The token screen used to read holders and safety ONCE, when you opened it. This engine turns a stream of
4
+ // repeated OBSERVATIONS of a token's holder/rug state into SIGNAL events — the changes worth telling a trader
5
+ // about while the token is open or on the watchlist: a dev/creator sell, a top-holder / top-10 concentration
6
+ // shift, LP removal or an unlock change, a mint/freeze authority flip, a new bundle / sniper cluster, a
7
+ // fresh-wallet inflow, bonding-curve progress, a migration.
8
+ //
9
+ // Every signal is a RISK FLAG, never a certificate, and carries its SOURCE and the AGE of the data it read, with
10
+ // an honest `stale` flag. No I/O, no clock, no randomness: the caller feeds `now` and already-fetched readings,
11
+ // so a recording replays to the same signals and the tests can assert each one. Nothing here signs or trades.
12
+ //
13
+ // Baseline rule: a signal only fires on a comparison between two FRESH readings of the same field. A reading that
14
+ // is stale (its panel kept the last value after a failed refresh, or it is older than `staleAfterMs`) updates the
15
+ // latest-known value but never fires a signal off it — we cannot honestly timestamp a change we did not see land.
16
+ // The first observation of a mint sets the baseline and fires nothing (so opening a token never floods).
17
+ export const DEFAULT_SIGNAL_THRESHOLDS = {
18
+ devSellPct: 1,
19
+ concentrationPct: 3,
20
+ lpDropPct: 1,
21
+ liqDropFrac: 0.25,
22
+ bundlePct: 1,
23
+ sniperCount: 1,
24
+ freshPct: 5,
25
+ bondingStepPct: 5,
26
+ staleAfterMs: 60_000,
27
+ };
28
+ /** Every signal kind, for docs / UI / the alerts trigger catalogue. */
29
+ export const SIGNAL_KINDS = [
30
+ 'dev-sell', 'concentration', 'lp-unlock', 'liquidity', 'authority', 'bundle', 'sniper', 'fresh-wallet', 'bonding', 'migrated',
31
+ ];
32
+ const CUMULATIVE = new Set(['bonding', 'bundled', 'snipers', 'fresh']);
33
+ const r1 = (n) => Math.round(n * 10) / 10;
34
+ export class LiveSignalsEngine {
35
+ thresholds;
36
+ state = new Map();
37
+ constructor(opts = {}) {
38
+ this.thresholds = { ...DEFAULT_SIGNAL_THRESHOLDS, ...opts.thresholds };
39
+ }
40
+ /** Whether a reading is too stale to fire a signal from (still updates the latest-known baseline). */
41
+ stale(reading, now) {
42
+ return reading.stale || now - reading.fetchedAt > this.thresholds.staleAfterMs;
43
+ }
44
+ /**
45
+ * Record one observation and return the signals it produced (empty for the first observation of a mint, or
46
+ * when nothing crossed a threshold). Baseline discipline (see module note):
47
+ * - the first reading of a field sets the baseline and fires nothing;
48
+ * - a STALE reading neither fires nor advances the baseline (so when it is fresh again we compare to the last
49
+ * value we actually saw and never miss a change that happened while stale, nor fire off stale data);
50
+ * - a fresh LEVEL field (dev / top10 / lpLocked / liq / authorities) is compared to the previous value and the
51
+ * baseline then advances to the current value;
52
+ * - a fresh CUMULATIVE field (bonding / bundled / snipers / fresh) advances its baseline ONLY when it fires, so
53
+ * gradual drift between fast pushes accumulates until it crosses the threshold instead of being lost.
54
+ */
55
+ observe(obs) {
56
+ const t = this.thresholds;
57
+ const now = obs.at;
58
+ const prev = this.state.get(obs.mint) ?? { latest: {}, latestBool: {}, anchor: {} };
59
+ const next = { latest: { ...prev.latest }, latestBool: { ...prev.latestBool }, anchor: { ...prev.anchor } };
60
+ const out = [];
61
+ const mk = (kind, level, title, text, reading, value, delta) => {
62
+ out.push({ kind, mint: obs.mint, level, title, text, source: reading.source, dataAgeMs: Math.max(0, now - reading.fetchedAt), stale: false, value, delta, at: now });
63
+ return true;
64
+ };
65
+ const num = (key, reading, fire) => {
66
+ if (!reading || reading.value === null || !Number.isFinite(reading.value))
67
+ return;
68
+ const cur = reading.value;
69
+ const prevLatest = prev.latest[key];
70
+ const state = { value: cur, source: reading.source, fetchedAt: reading.fetchedAt };
71
+ const cumulative = CUMULATIVE.has(key);
72
+ if (!prevLatest) {
73
+ next.latest[key] = state;
74
+ if (cumulative)
75
+ next.anchor[key] = cur;
76
+ return;
77
+ } // first: baseline only
78
+ if (this.stale(reading, now))
79
+ return; // stale: keep the last fresh value, fire nothing, do not advance
80
+ next.latest[key] = state; // latest always tracks the newest fresh value
81
+ const anchor = cumulative ? (prev.anchor[key] ?? prevLatest.value) : prevLatest.value;
82
+ const fired = fire(anchor, cur, reading) === true;
83
+ if (cumulative && fired)
84
+ next.anchor[key] = cur; // cumulative anchor advances only when a signal fires
85
+ };
86
+ num('dev', obs.dev, (was, cur, reading) => was - cur >= t.devSellPct && mk('dev-sell', 'warn', 'dev sold', `creator sold — dev holding ${r1(was)}% → ${r1(cur)}%`, reading, r1(cur), r1(cur - was)));
87
+ num('top10', obs.top10, (was, cur, reading) => {
88
+ const d = cur - was;
89
+ return Math.abs(d) >= t.concentrationPct && mk('concentration', d > 0 ? 'warn' : 'info', d > 0 ? 'top-10 up' : 'top-10 down', `top-10 concentration ${r1(was)}% → ${r1(cur)}% (pools excluded)`, reading, r1(cur), r1(d));
90
+ });
91
+ num('lpLocked', obs.lpLocked, (was, cur, reading) => was - cur >= t.lpDropPct && mk('lp-unlock', 'down', 'LP unlocked', `LP locked dropped ${r1(was)}% → ${r1(cur)}%`, reading, r1(cur), r1(cur - was)));
92
+ num('liq', obs.liq, (was, cur, reading) => {
93
+ if (was > 0 && cur < was && (was - cur) / was >= t.liqDropFrac) {
94
+ const pct = Math.round(((was - cur) / was) * 100);
95
+ return mk('liquidity', 'down', 'liquidity fell', `liquidity fell ~${pct}% ($${fmtUsd(was)} → $${fmtUsd(cur)})`, reading, Math.round(cur), Math.round(cur - was));
96
+ }
97
+ return false;
98
+ });
99
+ num('bundled', obs.bundled, (was, cur, reading) => cur - was >= t.bundlePct && mk('bundle', 'warn', 'bundle up', `bundled supply ${r1(was)}% → ${r1(cur)}% of the top sample`, reading, r1(cur), r1(cur - was)));
100
+ num('snipers', obs.snipers, (was, cur, reading) => cur - was >= t.sniperCount && mk('sniper', 'warn', 'snipers up', `snipers ${Math.round(was)} → ${Math.round(cur)} in the launch window`, reading, Math.round(cur), Math.round(cur - was)));
101
+ num('fresh', obs.fresh, (was, cur, reading) => cur - was >= t.freshPct && mk('fresh-wallet', 'warn', 'fresh wallets', `fresh-at-buy wallets ${r1(was)}% → ${r1(cur)}% of the top sample`, reading, r1(cur), r1(cur - was)));
102
+ num('cluster', obs.cluster, () => false); // tracked for the rules field only; concentration covers the UI
103
+ num('bonding', obs.bonding, (was, cur, reading) => cur - was >= t.bondingStepPct && mk('bonding', 'info', 'bonding', `bonding curve ${r1(was)}% → ${r1(cur)}%`, reading, r1(cur), r1(cur - was)));
104
+ // --- authorities: a false→true flip is the risk (minting / freezing re-enabled); true→false is good news.
105
+ const auth = (key, reading, label, risk) => {
106
+ if (!reading || reading.present === null)
107
+ return;
108
+ const cur = reading.present;
109
+ const was = prev.latestBool[key];
110
+ const state = { value: cur, source: reading.source, fetchedAt: reading.fetchedAt };
111
+ if (!was) {
112
+ next.latestBool[key] = state;
113
+ return;
114
+ }
115
+ if (this.stale(reading, now))
116
+ return;
117
+ if (was.value !== cur) {
118
+ if (cur)
119
+ mk('authority', 'warn', `${label} on`, `${label} re-enabled — ${risk}`, reading, 1, 1);
120
+ else
121
+ mk('authority', 'up', `${label} revoked`, `${label} revoked`, reading, 0, -1);
122
+ }
123
+ next.latestBool[key] = state;
124
+ };
125
+ auth('mintAuth', obs.mintAuth, 'mint authority', 'new supply can be minted');
126
+ auth('freezeAuth', obs.freezeAuth, 'freeze authority', 'transfers can be frozen');
127
+ // --- migration: once, on the false→true flip.
128
+ if (obs.migrated && obs.migrated.value !== null) {
129
+ const cur = obs.migrated.value;
130
+ const was = prev.latestBool.migrated;
131
+ const state = { value: cur, source: obs.migrated.source, fetchedAt: obs.migrated.fetchedAt };
132
+ if (!was) {
133
+ next.latestBool.migrated = state;
134
+ }
135
+ else if (!this.stale(obs.migrated, now)) {
136
+ if (!was.value && cur)
137
+ mk('migrated', 'info', 'migrated', 'migrated to a DEX pool — bonding curve closed', obs.migrated, 1, 1);
138
+ next.latestBool.migrated = state;
139
+ }
140
+ }
141
+ this.state.set(obs.mint, next);
142
+ return out;
143
+ }
144
+ /** The latest-known numeric fields for a mint, for the rules engine (null when never observed). */
145
+ fields(mint) {
146
+ const s = this.state.get(mint);
147
+ const n = (x) => (x ? x.value : null);
148
+ const b = (x) => (x ? (x.value ? 1 : 0) : null);
149
+ return {
150
+ top10: n(s?.latest.top10), dev: n(s?.latest.dev), bundled: n(s?.latest.bundled), snipers: n(s?.latest.snipers), fresh: n(s?.latest.fresh),
151
+ cluster: n(s?.latest.cluster), lpLocked: n(s?.latest.lpLocked), bonding: n(s?.latest.bonding),
152
+ mintable: b(s?.latestBool.mintAuth), freezable: b(s?.latestBool.freezeAuth),
153
+ };
154
+ }
155
+ /** A per-field snapshot with age + stale for the UI (null source = never observed). */
156
+ snapshot(mint, now) {
157
+ const s = this.state.get(mint);
158
+ const fromNum = (x) => this.snap(x, now);
159
+ const fromBool = (x) => (x ? { value: x.value ? 1 : 0, source: x.source, ageMs: Math.max(0, now - x.fetchedAt), stale: now - x.fetchedAt > this.thresholds.staleAfterMs } : { value: null, source: null, ageMs: null, stale: false });
160
+ return {
161
+ top10: fromNum(s?.latest.top10), dev: fromNum(s?.latest.dev), bundled: fromNum(s?.latest.bundled), snipers: fromNum(s?.latest.snipers),
162
+ fresh: fromNum(s?.latest.fresh), cluster: fromNum(s?.latest.cluster), lpLocked: fromNum(s?.latest.lpLocked), bonding: fromNum(s?.latest.bonding),
163
+ liq: fromNum(s?.latest.liq), mintable: fromBool(s?.latestBool.mintAuth), freezable: fromBool(s?.latestBool.freezeAuth), migrated: fromBool(s?.latestBool.migrated),
164
+ };
165
+ }
166
+ snap(x, now) {
167
+ if (!x)
168
+ return { value: null, source: null, ageMs: null, stale: false };
169
+ return { value: x.value, source: x.source, ageMs: Math.max(0, now - x.fetchedAt), stale: now - x.fetchedAt > this.thresholds.staleAfterMs };
170
+ }
171
+ /** Drop a mint's baseline (closed token the UI no longer tracks). */
172
+ forget(mint) { this.state.delete(mint); }
173
+ /** Known mints (for hygiene / tests). */
174
+ tracked() { return [...this.state.keys()]; }
175
+ }
176
+ function fmtUsd(n) {
177
+ const a = Math.abs(n);
178
+ if (a >= 1e9)
179
+ return `${r1(n / 1e9)}B`;
180
+ if (a >= 1e6)
181
+ return `${r1(n / 1e6)}M`;
182
+ if (a >= 1e3)
183
+ return `${r1(n / 1e3)}K`;
184
+ return String(Math.round(n));
185
+ }
@@ -0,0 +1,196 @@
1
+ // LARP check — links-only baseline grade (task-1401). Pure, offline, deterministic.
2
+ //
3
+ // The LARP check (skill `larp-check`) judges a coin's product from fetched pages, the domain and GitHub. That
4
+ // model stage cannot be replayed on history: the pages are gone or have changed since. What CAN be replayed is
5
+ // the part of the evidence the recorder keeps point-in-time: the website / X / Telegram links a coin advertised
6
+ // (DexScreener `info` + Jupiter metadata, the same fields `get_links` reads). This module turns those three links
7
+ // into named signals and a baseline grade, so the same function scores the backtest eval set and the live
8
+ // `get_links` result the skill starts from.
9
+ //
10
+ // What the grade is: a RISK FLAG from link shape alone, never a verdict on the product. It uses only the skill's
11
+ // verdict names it can honestly reach: `LARP` (the link shape contradicts a product claim: a social or launchpad
12
+ // page posing as the website, a placeholder host, a tweet instead of an account), `UNVERIFIABLE` (no website at
13
+ // all, nothing to vet) and `EARLY_BUT_REAL` (an own-domain site with nothing contradicting it). It never says
14
+ // `REAL_PRODUCT`: links alone cannot show a reachable product. Weights are tuned on the TRAIN split of the
15
+ // recorded eval set only (packages/packs/eval/README.md); the measured test-split numbers ship with the rubric.
16
+ import { parseHttpUrl } from "./links.js";
17
+ /** The named signals a rubric weighs. All are LARP-direction (a weight > 0 pushes toward `LARP`). */
18
+ export const LINK_SIGNALS = [
19
+ 'site_is_social', 'site_is_launchpad', 'site_linktree', 'site_free_host', 'site_no_name_match',
20
+ 'cheap_tld', 'x_not_profile', 'no_x', 'no_tg',
21
+ ];
22
+ /**
23
+ * v0: the pre-tuning rubric, written from the larp-check skill text BEFORE any backtest (task-1401): a social or
24
+ * launchpad page posing as the website and a tweet/community link instead of an account are the link-visible
25
+ * LARP signals the skill names; a placeholder host, a link hub, and a site whose domain does not name the coin
26
+ * count one point each. This is the "before" the tuned rubric is compared with.
27
+ */
28
+ export const LINK_RUBRIC_V0 = {
29
+ id: 'larp-links-v0',
30
+ weights: { site_is_social: 2, site_is_launchpad: 2, site_linktree: 1, site_free_host: 1, site_no_name_match: 1, cheap_tld: 0, x_not_profile: 1, no_x: 0, no_tg: 0 },
31
+ threshold: 2,
32
+ provenance: 'hand-written from the larp-check skill text before the backtest (task-1401)',
33
+ };
34
+ /**
35
+ * v1: tuned on the TRAIN split of packages/packs/eval/skills-eval.json (grid search over weights 0..2 and
36
+ * threshold 1..5, objective MCC of LARP→bad, ties to the smaller weight sum). Test-split numbers:
37
+ * packages/packs/eval/skills-backtest.json. Regenerate with `dexterm backtest --subject skills --tune`.
38
+ */
39
+ export const LINK_RUBRIC_V1 = {
40
+ id: 'larp-links-v1',
41
+ weights: { site_is_social: 0, site_is_launchpad: 0, site_linktree: 0, site_free_host: 1, site_no_name_match: 1, cheap_tld: 1, x_not_profile: 2, no_x: 1, no_tg: 1 },
42
+ threshold: 4,
43
+ provenance: 'tuned on the train split of the task-1401 eval set (see packages/packs/eval/README.md)',
44
+ };
45
+ /**
46
+ * The held-out TEST-split counts for LINK_RUBRIC_V1 (packages/packs/eval/skills-backtest.json `larp.after.test`).
47
+ * A test asserts these match the committed report, so a re-tune cannot ship stale numbers.
48
+ */
49
+ export const LINK_RUBRIC_MEASURED = {
50
+ rubric: 'larp-links-v1',
51
+ eval: 'task-1401 eval set, 2026-10-16',
52
+ horizon: '72h',
53
+ test: {
54
+ n: 328, bad: 61,
55
+ by_grade: { LARP: { n: 14, bad: 4 }, UNVERIFIABLE: { n: 130, bad: 38 }, EARLY_BUT_REAL: { n: 184, bad: 19 } },
56
+ },
57
+ };
58
+ /** The rubric the live `get_links` baseline uses. */
59
+ export const DEFAULT_LINK_RUBRIC = LINK_RUBRIC_V1;
60
+ const SOCIAL_HOSTS = new Set([
61
+ 'x.com', 'twitter.com', 'mobile.twitter.com', 't.me', 'telegram.me', 'telegram.dog', 'discord.gg', 'discord.com',
62
+ 'discordapp.com', 'instagram.com', 'tiktok.com', 'youtube.com', 'm.youtube.com', 'youtu.be', 'facebook.com', 'fb.com',
63
+ 'reddit.com', 'threads.net', 'warpcast.com', 'truthsocial.com', 'kick.com', 'twitch.tv',
64
+ ]);
65
+ const LAUNCHPAD_HOSTS = new Set([
66
+ 'pump.fun', 'dexscreener.com', 'birdeye.so', 'solscan.io', 'jup.ag', 'raydium.io', 'meteora.ag', 'letsbonk.fun',
67
+ 'bonk.fun', 'moonshot.money', 'believe.app', 'bags.fm', 'gmgn.ai', 'axiom.trade', 'dextools.io', 'coingecko.com',
68
+ 'coinmarketcap.com', 'photon-sol.tinyastro.io', 'geckoterminal.com', 'rugcheck.xyz', 'solana.fm', 'heaven.xyz',
69
+ ]);
70
+ const LINKHUB_HOSTS = new Set(['linktr.ee', 'linktree.com', 'link3.to', 'bio.link', 'beacons.ai', 'campsite.bio', 'solo.to', 'lnk.bio', 'hoo.be', 'allmylinks.com']);
71
+ const FREE_HOST_SUFFIXES = [
72
+ 'vercel.app', 'netlify.app', 'github.io', 'carrd.co', 'wixsite.com', 'webflow.io', 'framer.website', 'framer.app',
73
+ 'framer.ai', 'notion.site', 'gitbook.io', 'substack.com', 'pages.dev', 'web.app', 'firebaseapp.com', 'herokuapp.com',
74
+ 'replit.app', 'repl.co', 'glitch.me', 'weebly.com', 'wordpress.com', 'blogspot.com', 'mystrikingly.com',
75
+ 'godaddysites.com', 'squarespace.com', 'medium.com', 'durable.co', 'lovable.app', 'onrender.com', 'surge.sh',
76
+ 'webnode.page', 'site123.me', 'tilda.ws', 'super.site', 'my.canva.site', 'canva.site', 'bolt.host', 'netlify.com',
77
+ ];
78
+ const FREE_HOST_EXACT = new Set(['sites.google.com', 'docs.google.com', 'drive.google.com', 'telegra.ph', 'pastebin.com', 'medium.com']);
79
+ const CHEAP_TLDS = new Set(['fun', 'xyz', 'lol', 'meme', 'site', 'online', 'club', 'top', 'vip', 'wtf', 'space', 'icu', 'buzz', 'cfd', 'sbs', 'click', 'live', 'world', 'today', 'pw', 'cc', 'monster', 'rest', 'bond', 'cyou']);
80
+ const X_RESERVED = new Set(['home', 'search', 'explore', 'i', 'hashtag', 'intent', 'share', 'messages', 'notifications', 'settings', 'compose']);
81
+ const hostOf = (u) => u.hostname.toLowerCase().replace(/^www\./, '').replace(/\.$/, '');
82
+ const endsWithHost = (host, suffix) => host === suffix || host.endsWith(`.${suffix}`);
83
+ const norm = (s) => s.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]/g, '');
84
+ function webKind(raw) {
85
+ if (raw === null || raw === undefined || String(raw).trim() === '')
86
+ return { kind: 'none', url: null };
87
+ const u = parseHttpUrl(raw);
88
+ if (!u)
89
+ return { kind: 'invalid', url: null };
90
+ const host = hostOf(u);
91
+ if ([...SOCIAL_HOSTS].some((h) => endsWithHost(host, h)))
92
+ return { kind: 'social', url: u };
93
+ if ([...LAUNCHPAD_HOSTS].some((h) => endsWithHost(host, h)))
94
+ return { kind: 'launchpad', url: u };
95
+ if (LINKHUB_HOSTS.has(host))
96
+ return { kind: 'linktree', url: u };
97
+ if (FREE_HOST_EXACT.has(host) || FREE_HOST_SUFFIXES.some((s) => host.endsWith(`.${s}`)))
98
+ return { kind: 'free-host', url: u };
99
+ return { kind: 'own-domain', url: u };
100
+ }
101
+ function xKind(raw) {
102
+ if (raw === null || raw === undefined || String(raw).trim() === '')
103
+ return 'none';
104
+ const u = parseHttpUrl(raw);
105
+ if (!u)
106
+ return 'other';
107
+ const host = hostOf(u);
108
+ if (!['x.com', 'twitter.com', 'mobile.twitter.com'].includes(host))
109
+ return 'other';
110
+ const parts = u.pathname.split('/').filter(Boolean).map((p) => p.toLowerCase());
111
+ if (parts[0] === 'i' && parts[1] === 'communities')
112
+ return 'community';
113
+ if (!parts.length || X_RESERVED.has(parts[0]))
114
+ return 'other';
115
+ if (parts[1] === 'status' || parts[1] === 'statuses')
116
+ return 'status';
117
+ if (parts.length === 1 && /^[a-z0-9_]{1,15}$/.test(parts[0]))
118
+ return 'profile';
119
+ return 'other';
120
+ }
121
+ /** Does the site's domain carry the token's symbol or name? Symbol ≥ 3 chars, name compacted or a ≥ 4-char word. */
122
+ function domainMatches(u, symbol, name) {
123
+ const sym = norm(String(symbol ?? '').replace(/^\$/, ''));
124
+ const nm = norm(String(name ?? ''));
125
+ const words = String(name ?? '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4);
126
+ if (!sym && !nm)
127
+ return null;
128
+ const host = hostOf(u);
129
+ const labels = host.split('.');
130
+ const body = norm(labels.length > 1 ? labels.slice(0, -1).join('') : host);
131
+ if (!body)
132
+ return false;
133
+ if (sym.length >= 3 && body.includes(sym))
134
+ return true;
135
+ if (nm.length >= 3 && body.includes(nm))
136
+ return true;
137
+ return words.some((w) => body.includes(norm(w)));
138
+ }
139
+ /**
140
+ * Derive the URL-free features from the three primary links (the store's merge: Jupiter first, then DexScreener's
141
+ * first website / first X / first Telegram). `symbol`/`name` enable the domain-match signal; they are not kept.
142
+ */
143
+ export function linkFeatures(input) {
144
+ const w = webKind(input.web);
145
+ return {
146
+ web: w.kind,
147
+ x: xKind(input.x),
148
+ tg: !!(input.tg && String(input.tg).trim()),
149
+ webMatchesToken: w.url && (w.kind === 'own-domain' || w.kind === 'free-host') ? domainMatches(w.url, input.symbol, input.name) : null,
150
+ cheapTld: w.url && w.kind === 'own-domain' ? CHEAP_TLDS.has(hostOf(w.url).split('.').pop() ?? '') : null,
151
+ };
152
+ }
153
+ /** The signals present in a feature set. */
154
+ export function linkSignals(f) {
155
+ const s = [];
156
+ if (f.web === 'social')
157
+ s.push('site_is_social');
158
+ if (f.web === 'launchpad')
159
+ s.push('site_is_launchpad');
160
+ if (f.web === 'linktree')
161
+ s.push('site_linktree');
162
+ if (f.web === 'free-host' || f.web === 'invalid')
163
+ s.push('site_free_host');
164
+ if (f.webMatchesToken === false)
165
+ s.push('site_no_name_match');
166
+ if (f.cheapTld === true)
167
+ s.push('cheap_tld');
168
+ if (f.x === 'status' || f.x === 'community' || f.x === 'other')
169
+ s.push('x_not_profile');
170
+ if (f.x === 'none')
171
+ s.push('no_x');
172
+ if (!f.tg)
173
+ s.push('no_tg');
174
+ return s;
175
+ }
176
+ /** Grade one coin's links with a rubric. No website at all is `UNVERIFIABLE` (fixed rule, never tuned). */
177
+ export function gradeLinks(f, rubric = DEFAULT_LINK_RUBRIC) {
178
+ const signals = linkSignals(f);
179
+ const score = signals.reduce((a, s) => a + (rubric.weights[s] ?? 0), 0);
180
+ const grade = f.web === 'none' ? 'UNVERIFIABLE' : score >= rubric.threshold ? 'LARP' : 'EARLY_BUT_REAL';
181
+ return { rubric: rubric.id, grade, score, signals };
182
+ }
183
+ /**
184
+ * The store's primary-link merge (packages/data/src/store.ts: Jupiter first, then DexScreener), so the live
185
+ * baseline reads the same three links the recorder's cache holds for the eval set.
186
+ */
187
+ export function primaryLinks(src) {
188
+ const info = src.dexscreener?.info;
189
+ const dsX = info?.socials?.find((s) => s?.type === 'twitter' || s?.type === 'x')?.url ?? null;
190
+ const dsTg = info?.socials?.find((s) => s?.type === 'telegram')?.url ?? null;
191
+ return {
192
+ web: src.jupiter?.website ?? info?.websites?.[0]?.url ?? null,
193
+ x: src.jupiter?.twitter ?? dsX,
194
+ tg: src.jupiter?.telegram ?? dsTg,
195
+ };
196
+ }
@@ -126,6 +126,20 @@ export class DexScreener {
126
126
  }
127
127
  return out;
128
128
  }
129
+ /**
130
+ * task-1401: the coin a pair (pool) trades. A dexscreener.com/solana/<address> link usually names the POOL, not
131
+ * the mint, so a pasted link is resolved here before the token screen opens. Null when DexScreener has no Solana
132
+ * pair at that address (the caller then treats it as a mint). The side that is not wrapped SOL wins.
133
+ */
134
+ async pairCoin(pairAddress) {
135
+ const r = await this.http.getJson(`${this.base}/latest/dex/pairs/${CHAIN}/${encodeURIComponent(pairAddress)}`, FAST);
136
+ const list = r.data?.pairs ?? (r.data?.pair ? [r.data.pair] : []);
137
+ const p = (Array.isArray(list) ? list : []).find((x) => x?.chainId === CHAIN && x.pairAddress === pairAddress);
138
+ if (!p)
139
+ return null;
140
+ const WSOL = 'So11111111111111111111111111111111111111112';
141
+ return p.baseToken?.address === WSOL && p.quoteToken?.address ? p.quoteToken.address : p.baseToken?.address ?? null;
142
+ }
129
143
  async tokenPairs(mint) {
130
144
  const r = await this.http.getJson(`${this.base}/token-pairs/v1/${CHAIN}/${mint}`, FAST);
131
145
  return (Array.isArray(r.data) ? r.data : []).filter((p) => p.chainId === CHAIN).map((p) => normalizePair(p, r.fetchedAt));
@@ -240,7 +240,12 @@ export class PumpPortalClient {
240
240
  if (speed > 0 && m.tMs > last)
241
241
  await this.clock.sleep((m.tMs - last) / speed);
242
242
  last = m.tMs;
243
- this.handle(m.text, this.clock.now());
243
+ // Anchor each message's receive time to the recording's own `tMs` offset, not `clock.now()`. Reading the
244
+ // live clock per message makes `receivedAt` (and the row's `createdAt`) depend on where real-time
245
+ // millisecond boundaries happen to fall inside this synchronous loop, so the newest-first row order
246
+ // flipped between runs — a Node-22 CI flake in the new-pairs replay test (task-1394). Offsets from one
247
+ // clock read are monotonic and deterministic, so replay now sorts identically on every run and engine.
248
+ this.handle(m.text, this.startedAt + m.tMs);
244
249
  }
245
250
  return rec.messages.length;
246
251
  }
@@ -2,7 +2,7 @@
2
2
  // capped auto-mirror copy mandate (Tier 3). Personal, self-directed, non-custodial; paper/devnet only; nothing
3
3
  // here signs, holds a key, or arms anything without the plan engine's own keypress gate.
4
4
  export { SOL_LEG_MIN_LAMPORTS, tokenDeltasForOwner, lamportsDeltaFor, decodeTx, decodeWalletTrades } from "./trades.js";
5
- export { WIN_RATE_MIN_N, positionsOf, earlyEntryScore, walletStats } from "./stats.js";
5
+ export { WIN_RATE_MIN_N, positionsOf, earlyEntryScore, walletStats, fmtHoldMs } from "./stats.js";
6
6
  export { FOLLOW_NAME_MAX, FOLLOWS_MAX, defaultFollowsPath, isWalletAddress, cleanFollowName, FollowStore, shortAddr } from "./follows.js";
7
7
  export { WalletTracker, TRACKER_DEFAULTS } from "./tracker.js";
8
8
  export { MIRROR_LIMITS, leaderFillPriceUsd, buildMirror, leaderSoldSince } from "./mirror.js";
@@ -17,12 +17,13 @@ export function positionsOf(trades) {
17
17
  const delta = BigInt(tr.tokenDelta);
18
18
  let p = pos.get(tr.mint);
19
19
  if (!p) {
20
- p = { mint: tr.mint, tokensRaw: 0n, peakRaw: 0n, costSol: 0, realisedSol: 0, buys: 0, sells: 0, transfers: 0, flagged: false, closed: false, firstT: tr.t, lastT: tr.t };
20
+ p = { mint: tr.mint, tokensRaw: 0n, peakRaw: 0n, costSol: 0, realisedSol: 0, buys: 0, sells: 0, transfers: 0, flagged: false, closed: false, firstT: tr.t, lastT: tr.t, firstKnown: tr.tKnown, lastKnown: tr.tKnown, holdMs: null };
21
21
  pos.set(tr.mint, p);
22
22
  }
23
23
  const L = lots.get(tr.mint) ?? [];
24
24
  lots.set(tr.mint, L);
25
25
  p.lastT = tr.t;
26
+ p.lastKnown = tr.tKnown;
26
27
  if (delta > 0n) {
27
28
  // a buy with a SOL leg carries cost; a token-swap leg or an airdrop is cost 0 and flagged
28
29
  const cost = tr.side === 'buy' && tr.solDelta !== null ? Math.abs(tr.solDelta) : 0;
@@ -76,8 +77,34 @@ export function positionsOf(trades) {
76
77
  }
77
78
  p.closed = closedNow(p);
78
79
  }
80
+ // hold time = first buy → last sell, only trustworthy for a CLOSED position whose first+last block times are both real
81
+ for (const p of pos.values())
82
+ p.holdMs = p.closed && p.firstKnown && p.lastKnown && p.lastT > p.firstT ? p.lastT - p.firstT : null;
79
83
  return [...pos.values()].sort((a, b) => b.lastT - a.lastT);
80
84
  }
85
+ /** Compact human hold time: 45s · 12m · 3.2h · 2.1d. null → '—'. */
86
+ export function fmtHoldMs(ms) {
87
+ if (ms === null)
88
+ return '—';
89
+ const s = ms / 1000;
90
+ if (s < 90)
91
+ return `${Math.round(s)}s`;
92
+ const m = s / 60;
93
+ if (m < 90)
94
+ return `${Math.round(m)}m`;
95
+ const h = m / 60;
96
+ if (h < 48)
97
+ return `${h.toFixed(1)}h`;
98
+ return `${(h / 24).toFixed(1)}d`;
99
+ }
100
+ /** Median over the numbers (already finite); undefined on an empty list. */
101
+ function median(xs) {
102
+ if (!xs.length)
103
+ return undefined;
104
+ const s = [...xs].sort((a, b) => a - b);
105
+ const mid = Math.floor(s.length / 2);
106
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
107
+ }
81
108
  /** Early-entry score = median over tokens of (1 − rank / buyers); null without ranks (design §4.3). */
82
109
  export function earlyEntryScore(inputs) {
83
110
  const xs = inputs.filter((e) => e.buyers > 0 && e.rank >= 0 && e.rank < e.buyers).map((e) => 1 - e.rank / e.buyers).sort((a, b) => a - b);
@@ -96,6 +123,11 @@ export function walletStats(wallet, trades, opts = {}) {
96
123
  const ts = trades.map((t) => t.t).filter((t) => t > 0);
97
124
  const winRate = closed.length >= WIN_RATE_MIN_N ? wins / closed.length : null;
98
125
  const txs = opts.txs ?? new Set(trades.map((t) => t.sig)).size;
126
+ const holds = closed.map((p) => p.holdMs).filter((m) => m !== null);
127
+ const holdMedianMs = median(holds) ?? null;
128
+ const holdNote = holdMedianMs === null
129
+ ? (closed.length ? `no closed position with both block times known (${closed.length} closed)` : 'no closed positions yet')
130
+ : `median first-buy→last-sell over ${holds.length} closed position${holds.length === 1 ? '' : 's'} with known times`;
99
131
  return {
100
132
  wallet,
101
133
  trades: trades.length,
@@ -112,9 +144,11 @@ export function walletStats(wallet, trades, opts = {}) {
112
144
  winRateNote: closed.length >= WIN_RATE_MIN_N ? `${wins}/${closed.length} closed` : `hidden under n=${WIN_RATE_MIN_N} (${wins}/${closed.length} closed so far)`,
113
145
  open: positions.length - closed.length,
114
146
  earlyEntry: earlyEntryScore(opts.earlyEntry ?? []),
147
+ holdMedianMs,
148
+ holdNote,
115
149
  positions,
116
150
  firstT: ts.length ? Math.min(...ts) : null,
117
151
  lastT: ts.length ? Math.max(...ts) : null,
118
- sample: { txs, complete: !!opts.complete, note: opts.complete ? `all ${txs} txs` : `last ${txs} txs only · older history not fetched` },
152
+ sample: { txs, complete: !!opts.complete, note: opts.complete ? `all ${txs} txs` : `last ${txs} txs only · older history not fetched`, source: 'helius-history', asOf: opts.asOf ?? null },
119
153
  };
120
154
  }
@@ -63,7 +63,7 @@ export class WalletTracker {
63
63
  alerts(limit = 50) { return this.alertLog.slice(-limit).reverse(); }
64
64
  stats(addr) {
65
65
  const w = this.wallets.get(addr);
66
- return walletStats(addr, w?.trades ?? [], { solUsd: this.solUsd(), txs: w?.txs ?? 0, complete: w?.complete ?? false, earlyEntry: this.earlyEntry(addr) });
66
+ return walletStats(addr, w?.trades ?? [], { solUsd: this.solUsd(), txs: w?.txs ?? 0, complete: w?.complete ?? false, earlyEntry: this.earlyEntry(addr), asOf: w?.asOf ?? null });
67
67
  }
68
68
  /** Per-wallet poll error (shown on the row), null when the last poll was clean. */
69
69
  walletError(addr) { return this.wallets.get(addr)?.error ?? null; }
@@ -89,11 +89,12 @@ export class WalletTracker {
89
89
  for (const f of this.follows.list()) {
90
90
  let w = this.wallets.get(f.addr);
91
91
  if (!w) {
92
- w = { primed: false, seen: new Set(), trades: [], txs: 0, complete: false, lastSlot: 0, error: null };
92
+ w = { primed: false, seen: new Set(), trades: [], txs: 0, complete: false, lastSlot: 0, error: null, asOf: null };
93
93
  this.wallets.set(f.addr, w);
94
94
  }
95
95
  try {
96
96
  const page = await this.history.transactions(f.addr, { limit: w.primed ? this.pageLimit : this.backfillLimit, sortOrder: 'desc' });
97
+ w.asOf = page.fetchedAt;
97
98
  const fresh = page.data.filter((tx) => { const s = tx.transaction?.signatures?.[0]; return !!s && !w.seen.has(s); });
98
99
  for (const tx of fresh)
99
100
  w.seen.add(tx.transaction.signatures[0]);
@@ -0,0 +1,35 @@
1
+ ---
2
+ id: app-help-command
3
+ title: Get help on how dexterm works (dexterm help)
4
+ kind: app-help
5
+ screen: all
6
+ feature: app-help
7
+ tags:
8
+ - app-help
9
+ - help
10
+ - how
11
+ - topic
12
+ - topics
13
+ - guide
14
+ - manual
15
+ - docs
16
+ - commands
17
+ commands:
18
+ - help
19
+ flags:
20
+ - --help
21
+ confidence: high
22
+ owner: maintainers
23
+ origin: app-help-generated
24
+ reviewed_by: generated@2026-10-16
25
+ generated_from: dexterm/app keymap + overlay + mcp tools + cli (task-1413)
26
+ sources:
27
+ - packages/tui/src/keymap.ts
28
+ - packages/cli/src/main.ts
29
+ - packages/mcp/src/server.ts
30
+ date: 2026-10-16
31
+ ---
32
+
33
+ `dexterm help [topic]` answers "how do I ..." from the generated app-help knowledge base — the same entries the analyst's `app_help` tool uses, so they cannot drift from the app. `dexterm help` lists the topics; `dexterm help <topic|key|command>` prints the matching entry with the exact key or command. `--help` (or `-h`) on any command prints its usage. The analyst searches app-help first and never invents a key.
34
+
35
+ CLI: `dexterm help`.
@@ -0,0 +1,43 @@
1
+ ---
2
+ id: ask-the-analyst
3
+ title: Ask the analyst a question
4
+ kind: app-help
5
+ screen: analyst
6
+ feature: analyst
7
+ tags:
8
+ - app-help
9
+ - analyst
10
+ - ask
11
+ - claude
12
+ - ai
13
+ - question
14
+ - dossier
15
+ - chat
16
+ keys:
17
+ - a
18
+ - enter
19
+ actions:
20
+ - analyst.toggle
21
+ - open
22
+ flags:
23
+ - --no-ai
24
+ tools:
25
+ - get_dossier
26
+ - kb_search
27
+ - app_help
28
+ - case_lookup
29
+ confidence: high
30
+ owner: maintainers
31
+ origin: app-help-generated
32
+ reviewed_by: generated@2026-10-16
33
+ generated_from: dexterm/app keymap + overlay + mcp tools + cli (task-1413)
34
+ sources:
35
+ - packages/tui/src/keymap.ts
36
+ - packages/cli/src/main.ts
37
+ - packages/mcp/src/server.ts
38
+ date: 2026-10-16
39
+ ---
40
+
41
+ The analyst is your own `claude` login. ⏎ on a coin runs a full dossier; `a` runs a rug-check (or a trench-scan of the screener when nothing is selected). Type a follow-up and ⏎ to send. If `claude` is missing or logged out the panel shows the one-line fix and the rest of dexterm keeps working. Start without AI using `--no-ai`.
42
+
43
+ Keys: `a`, `⏎` (screener).
@@ -0,0 +1,33 @@
1
+ ---
2
+ id: backtest
3
+ title: Backtest the rug model or skills
4
+ kind: app-help
5
+ screen: all
6
+ feature: skill-backtest
7
+ tags:
8
+ - app-help
9
+ - backtest
10
+ - eval
11
+ - accuracy
12
+ - model
13
+ - metrics
14
+ - history
15
+ commands:
16
+ - backtest
17
+ tools:
18
+ - rug_backtest
19
+ confidence: high
20
+ owner: maintainers
21
+ origin: app-help-generated
22
+ reviewed_by: generated@2026-10-16
23
+ generated_from: dexterm/app keymap + overlay + mcp tools + cli (task-1413)
24
+ sources:
25
+ - packages/tui/src/keymap.ts
26
+ - packages/cli/src/main.ts
27
+ - packages/mcp/src/server.ts
28
+ date: 2026-10-16
29
+ ---
30
+
31
+ `dexterm backtest --subject heuristic|skills|larp|pack_verdicts` scores a subject against the recorder's outcome-labelled history and prints honest metrics (precision/recall, Brier vs base rate, AUC, calibration) with leakage checks. With no labelled outcomes it says so and prints no metrics rather than fabricating any.
32
+
33
+ CLI: `dexterm backtest`.