dexterm-cli 0.1.2 → 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 (118) hide show
  1. package/dist/packages/agents/src/bus.js +37 -1
  2. package/dist/packages/agents/src/follow.js +105 -0
  3. package/dist/packages/agents/src/index.js +5 -0
  4. package/dist/packages/agents/src/spectator-server.js +153 -0
  5. package/dist/packages/agents/src/spectator.js +212 -0
  6. package/dist/packages/cli/assets/claude/analyst-system.md +6 -1
  7. package/dist/packages/cli/src/agent-cmd.js +71 -7
  8. package/dist/packages/cli/src/agent-spectator.js +37 -0
  9. package/dist/packages/cli/src/alerts/format.js +25 -0
  10. package/dist/packages/cli/src/app-help-gen.js +365 -0
  11. package/dist/packages/cli/src/doctor.js +6 -5
  12. package/dist/packages/cli/src/follow-cmd.js +107 -1
  13. package/dist/packages/cli/src/help-cmd.js +48 -0
  14. package/dist/packages/cli/src/live/index.js +64 -3
  15. package/dist/packages/cli/src/live/screens/token-live.js +24 -0
  16. package/dist/packages/cli/src/live/signals.js +109 -0
  17. package/dist/packages/cli/src/live/trade.js +24 -1
  18. package/dist/packages/cli/src/main.js +355 -10
  19. package/dist/packages/cli/src/mcp-cmd.js +2 -7
  20. package/dist/packages/cli/src/pnl-app.js +221 -0
  21. package/dist/packages/cli/src/pnl-card.js +615 -0
  22. package/dist/packages/cli/src/record-cmd.js +455 -0
  23. package/dist/packages/cli/src/rules.js +50 -2
  24. package/dist/packages/cli/src/screens/follows.js +91 -2
  25. package/dist/packages/cli/src/screens/overlay.js +3 -0
  26. package/dist/packages/cli/src/screens/positions.js +1 -1
  27. package/dist/packages/cli/src/screens/rules.js +1 -1
  28. package/dist/packages/cli/src/spectate-cmd.js +104 -0
  29. package/dist/packages/cli/src/state.js +1 -1
  30. package/dist/packages/cli/src/version.js +1 -1
  31. package/dist/packages/cli/src/voice-cmd.js +86 -11
  32. package/dist/packages/cli/src/voice-hud.js +1 -1
  33. package/dist/packages/custom/src/rules.js +2 -1
  34. package/dist/packages/data/src/analyst-actions.js +130 -3
  35. package/dist/packages/data/src/fast-lane.js +16 -2
  36. package/dist/packages/data/src/index.js +1 -0
  37. package/dist/packages/data/src/intel/live-signals.js +185 -0
  38. package/dist/packages/data/src/trade/copy/index.js +1 -1
  39. package/dist/packages/data/src/trade/copy/stats.js +36 -2
  40. package/dist/packages/data/src/trade/copy/tracker.js +3 -2
  41. package/dist/packages/mcp/assets/kb/app-help/app-help-command.md +35 -0
  42. package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +43 -0
  43. package/dist/packages/mcp/assets/kb/app-help/backtest.md +33 -0
  44. package/dist/packages/mcp/assets/kb/app-help/bubble-map.md +35 -0
  45. package/dist/packages/mcp/assets/kb/app-help/calm-mode.md +31 -0
  46. package/dist/packages/mcp/assets/kb/app-help/change-voice-speed.md +31 -0
  47. package/dist/packages/mcp/assets/kb/app-help/change-voice.md +30 -0
  48. package/dist/packages/mcp/assets/kb/app-help/chart-popup-selftest.md +32 -0
  49. package/dist/packages/mcp/assets/kb/app-help/coin-images.md +29 -0
  50. package/dist/packages/mcp/assets/kb/app-help/configure-rpc-key.md +28 -0
  51. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent-registry.md +35 -0
  52. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +39 -0
  53. package/dist/packages/mcp/assets/kb/app-help/custom-keymap.md +32 -0
  54. package/dist/packages/mcp/assets/kb/app-help/edit-kb-or-skill.md +36 -0
  55. package/dist/packages/mcp/assets/kb/app-help/edit-rules.md +34 -0
  56. package/dist/packages/mcp/assets/kb/app-help/export-a-view.md +36 -0
  57. package/dist/packages/mcp/assets/kb/app-help/follow-a-wallet.md +33 -0
  58. package/dist/packages/mcp/assets/kb/app-help/getting-started.md +37 -0
  59. package/dist/packages/mcp/assets/kb/app-help/kol-lists.md +34 -0
  60. package/dist/packages/mcp/assets/kb/app-help/larp-check.md +36 -0
  61. package/dist/packages/mcp/assets/kb/app-help/link-a-wallet.md +41 -0
  62. package/dist/packages/mcp/assets/kb/app-help/login-with-x.md +33 -0
  63. package/dist/packages/mcp/assets/kb/app-help/narrative-board.md +32 -0
  64. package/dist/packages/mcp/assets/kb/app-help/open-a-coin.md +43 -0
  65. package/dist/packages/mcp/assets/kb/app-help/open-chart-popup.md +33 -0
  66. package/dist/packages/mcp/assets/kb/app-help/order-plans.md +33 -0
  67. package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +36 -0
  68. package/dist/packages/mcp/assets/kb/app-help/price-or-marketcap.md +38 -0
  69. package/dist/packages/mcp/assets/kb/app-help/record-headless.md +34 -0
  70. package/dist/packages/mcp/assets/kb/app-help/recorder-history.md +39 -0
  71. package/dist/packages/mcp/assets/kb/app-help/rug-check.md +39 -0
  72. package/dist/packages/mcp/assets/kb/app-help/run-doctor.md +32 -0
  73. package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +37 -0
  74. package/dist/packages/mcp/assets/kb/app-help/screen-analyst.md +181 -0
  75. package/dist/packages/mcp/assets/kb/app-help/screen-kol.md +190 -0
  76. package/dist/packages/mcp/assets/kb/app-help/screen-newpairs.md +181 -0
  77. package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +180 -0
  78. package/dist/packages/mcp/assets/kb/app-help/screen-screener.md +199 -0
  79. package/dist/packages/mcp/assets/kb/app-help/screen-token.md +240 -0
  80. package/dist/packages/mcp/assets/kb/app-help/screen-trending.md +192 -0
  81. package/dist/packages/mcp/assets/kb/app-help/screen-watchlist.md +181 -0
  82. package/dist/packages/mcp/assets/kb/app-help/set-a-filter.md +36 -0
  83. package/dist/packages/mcp/assets/kb/app-help/setup-neural-voice.md +36 -0
  84. package/dist/packages/mcp/assets/kb/app-help/share-packs.md +34 -0
  85. package/dist/packages/mcp/assets/kb/app-help/sort-the-screener.md +34 -0
  86. package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +36 -0
  87. package/dist/packages/mcp/assets/kb/app-help/standing-mandates.md +33 -0
  88. package/dist/packages/mcp/assets/kb/app-help/switch-theme.md +42 -0
  89. package/dist/packages/mcp/assets/kb/app-help/turn-on-alerts.md +37 -0
  90. package/dist/packages/mcp/assets/kb/app-help/update-dexterm.md +30 -0
  91. package/dist/packages/mcp/assets/kb/app-help/use-presets.md +42 -0
  92. package/dist/packages/mcp/assets/kb/app-help/use-voice-mode.md +36 -0
  93. package/dist/packages/mcp/assets/kb/app-help/watch-a-coin.md +34 -0
  94. package/dist/packages/mcp/assets/kb/app-help/watch-headless.md +35 -0
  95. package/dist/packages/mcp/assets/kb/app-help/what-dexterm-shows-agents.md +31 -0
  96. package/dist/packages/mcp/assets/kb/app-help/workspaces.md +38 -0
  97. package/dist/packages/mcp/src/app-help.js +135 -0
  98. package/dist/packages/mcp/src/index.js +3 -0
  99. package/dist/packages/mcp/src/server.js +90 -3
  100. package/dist/packages/mcp/src/spectator-sink.js +72 -0
  101. package/dist/packages/packs/src/index.js +2 -0
  102. package/dist/packages/packs/src/skill-care.js +206 -0
  103. package/dist/packages/popup/src/pages/spectator.css +45 -0
  104. package/dist/packages/popup/src/pages/spectator.html +25 -0
  105. package/dist/packages/popup/src/pages/spectator.js +74 -0
  106. package/dist/packages/popup/src/selftest.js +19 -0
  107. package/dist/packages/popup/src/server.js +7 -1
  108. package/dist/packages/tui/src/keymap.js +10 -0
  109. package/dist/packages/voice/src/controller.js +42 -1
  110. package/dist/packages/voice/src/doctor.js +25 -8
  111. package/dist/packages/voice/src/engines.js +8 -1
  112. package/dist/packages/voice/src/index.js +3 -1
  113. package/dist/packages/voice/src/speakable.js +111 -0
  114. package/dist/packages/voice/src/tts/piper-setup.js +229 -0
  115. package/dist/packages/voice/src/tts/piper.js +309 -66
  116. package/dist/packages/voice/src/tts/say.js +3 -2
  117. package/dist/packages/voice/src/tts/voices.js +8 -5
  118. package/package.json +1 -1
@@ -0,0 +1,455 @@
1
+ // dexterm — `dexterm record` (task-1414): a HEADLESS collector that keeps the history recorder fed when
2
+ // nobody has the TUI open. The recorder (task-1329) only grows while the interactive app runs; the backtest
3
+ // (task-1401) is only worth re-running on fresh data, so this command runs the exact same recorder jobs
4
+ // (observe snapshots + feed membership, the 1 h/2 h/6 h/24 h/72 h outcome sweeps, labels, retention) with no
5
+ // TUI, to be driven by a user-level launchd LaunchAgent on the Mac mini.
6
+ //
7
+ // dexterm record [run] [--interval S] [--ticks N | --once] [--maintain S] [--max-db-mb N] [--no-newpairs] [--db PATH] [--verbose]
8
+ // dexterm record install [--interval S] [--max-db-mb N] [--load] [--dry-run]
9
+ // dexterm record uninstall [--unload]
10
+ // dexterm record status [--db PATH]
11
+ //
12
+ // SAFETY / BUDGETS: this builds the keyless live feed and the recorder exactly as the live app does, so every
13
+ // request goes through the data layer's rate limiter + health gate and the sweep's own per-sweep / per-day caps
14
+ // (DEFAULT_SWEEP). It never builds a signer, never trades, never prints the Helius key (the URL is built inside
15
+ // the data layer; only the provider name is ever logged). Modest CPU: one feed refresh per `--interval` (60 s
16
+ // default) and recorder maintenance at most every `--maintain` seconds. A soft disk cap pauses OBSERVATION
17
+ // (the growth source) above `--max-db-mb` while labels + retention keep running, so the DB cannot run away.
18
+ import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
19
+ import { spawnSync } from 'node:child_process';
20
+ import { dirname, join, resolve } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { defaultDbPath, resolveRpcUrl, extraRpcUrls, redactSecrets } from '../../data/src/index.js';
23
+ import { PumpPortalClient } from '../../data/src/index.js';
24
+ import { Recorder, recorderEnabled } from '../../recorder/src/index.js';
25
+ import { createLiveFeed } from "./feed.js";
26
+ import { NewPairsLive } from "./live/index.js";
27
+ /** The LaunchAgent label (reverse-DNS). One collector per user. */
28
+ export const COLLECTOR_LABEL = 'xyz.dexterm.collector';
29
+ /** Default spacing between feed refreshes (one observation pass). Background work: keep it unhurried. */
30
+ export const DEFAULT_INTERVAL_MS = 60_000;
31
+ /** Default spacing between recorder maintenance passes (sweeps/labels/retention). */
32
+ export const DEFAULT_MAINTAIN_MS = 60_000;
33
+ /** Default soft cap on the whole DB file. Above it, observation pauses; sweeps/labels/retention continue. */
34
+ export const DEFAULT_MAX_DB_MB = 1024;
35
+ /** Build the real collector engine: the keyless live feed, the recorder attached to its store, and (unless
36
+ * disabled) the PumpPortal new-pairs stream folded into the feed's candidate pool so fresh launches get
37
+ * recorded before the REST feeds index them. */
38
+ export function buildLiveEngine(env, opts) {
39
+ const rpc = resolveRpcUrl({ env });
40
+ const feed = createLiveFeed({
41
+ dbPath: opts.dbPath, rpcUrl: rpc.url, rpcFallbackUrl: rpc.fallbackUrl, rpcPublicPool: true, rpcExtraUrls: extraRpcUrls(env),
42
+ onRequest: opts.verbose ? (req, meta, o) => opts.onLog?.(` ${meta.source.padEnd(14)} ${String(o.status ?? 'ERR').padStart(3)} ${String(o.latencyMs).padStart(5)}ms ${redactSecrets(req.url).slice(0, 110)}\n`) : undefined,
43
+ });
44
+ const recorder = new Recorder({ path: opts.dbPath, onLog: opts.verbose ? opts.onLog : undefined });
45
+ recorder.attach(feed.dl.store);
46
+ let newpairs = null;
47
+ if (!opts.noNewpairs) {
48
+ newpairs = new NewPairsLive(feed.dl, {});
49
+ const client = new PumpPortalClient({ health: feed.dl.health, clock: feed.dl.clock, onEvent: (ev) => newpairs.handleEvent(ev), onStatus: () => { } });
50
+ newpairs.attach(client, 'live');
51
+ newpairs.start();
52
+ const np = newpairs;
53
+ feed.extraMints = () => [...np.rows.keys()];
54
+ }
55
+ const dl = feed.dl;
56
+ return {
57
+ async observe() {
58
+ const before = recorder.counters.written;
59
+ await feed.refresh();
60
+ return recorder.counters.written - before;
61
+ },
62
+ async maintain() { await recorder.maintain(dl); },
63
+ prune() { try {
64
+ recorder.prune();
65
+ }
66
+ catch { /* logged inside; never fatal */ } },
67
+ dbBytes() {
68
+ try {
69
+ const db = recorder.database;
70
+ const pc = db.prepare('PRAGMA page_count').get().page_count;
71
+ const ps = db.prepare('PRAGMA page_size').get().page_size;
72
+ return Number(pc) * Number(ps);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ },
78
+ newPairs() { return newpairs ? newpairs.rows.size : 0; },
79
+ close() {
80
+ try {
81
+ newpairs?.stop();
82
+ }
83
+ catch { /* ignore */ }
84
+ try {
85
+ feed.close();
86
+ }
87
+ catch { /* ignore */ }
88
+ try {
89
+ recorder.close();
90
+ }
91
+ catch { /* ignore */ }
92
+ },
93
+ };
94
+ }
95
+ /**
96
+ * Drive the engine. Each tick (after the first waits `intervalMs`): if a disk cap is set and the DB is at/over
97
+ * it, force a retention pass; if it is still over, SKIP observation this tick (so the growth source pauses) but
98
+ * keep running maintenance. Otherwise observe one pass. Maintenance runs at most every `maintainEveryMs`.
99
+ * Never throws out of the loop: an engine error is logged and the loop continues to the next tick.
100
+ */
101
+ export async function runCollectorLoop(engine, opts) {
102
+ const res = { ticks: 0, observePasses: 0, rowsWritten: 0, maintainPasses: 0, cappedTicks: 0 };
103
+ let lastMaintain = -Infinity;
104
+ for (let i = 0; i < opts.ticks && !opts.aborted(); i++) {
105
+ if (i > 0) {
106
+ await opts.sleep(opts.intervalMs);
107
+ if (opts.aborted())
108
+ break;
109
+ }
110
+ res.ticks++;
111
+ let over = false;
112
+ if (opts.maxDbBytes !== null) {
113
+ const b = engine.dbBytes();
114
+ if (b !== null && b >= opts.maxDbBytes) {
115
+ engine.prune();
116
+ const b2 = engine.dbBytes();
117
+ over = b2 !== null && b2 >= opts.maxDbBytes;
118
+ if (over) {
119
+ res.cappedTicks++;
120
+ opts.onLog?.(`record: DB at ${(b2 / 1e6).toFixed(0)} MB ≥ cap ${(opts.maxDbBytes / 1e6).toFixed(0)} MB — observation paused this tick (labels + retention still run)\n`);
121
+ }
122
+ }
123
+ }
124
+ if (!over) {
125
+ try {
126
+ res.rowsWritten += await engine.observe();
127
+ res.observePasses++;
128
+ }
129
+ catch (e) {
130
+ opts.onLog?.(`record: observe failed: ${redactSecrets(e.message)}\n`);
131
+ }
132
+ }
133
+ const now = opts.now();
134
+ if (now - lastMaintain >= opts.maintainEveryMs) {
135
+ lastMaintain = now;
136
+ try {
137
+ await engine.maintain();
138
+ res.maintainPasses++;
139
+ }
140
+ catch (e) {
141
+ opts.onLog?.(`record: maintain failed: ${redactSecrets(e.message)}\n`);
142
+ }
143
+ }
144
+ }
145
+ return res;
146
+ }
147
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
148
+ /**
149
+ * A user-level LaunchAgent (no sudo). KeepAlive so a crash or a reboot-after-login brings it back, RunAtLoad,
150
+ * ProcessType Background + Nice 10 + Low throttling so it never competes with foreground work, and a
151
+ * ThrottleInterval so a crash-loop cannot spin. It writes to `logPath`. NOTE (documented, and in `install`
152
+ * output): a LaunchAgent does NOT start before the user logs in — this Mac must be logged in for the
153
+ * collector to run; a true pre-login daemon would be a LaunchDaemon under sudo, which this task does not use.
154
+ */
155
+ export function renderCollectorPlist(o) {
156
+ const args = [o.nodePath, o.binPath, 'record', 'run', '--interval', String(Math.round(o.intervalMs / 1000)), '--max-db-mb', String(o.maxDbMb)];
157
+ const argXml = args.map((a) => ` <string>${esc(a)}</string>`).join('\n');
158
+ return `<?xml version="1.0" encoding="UTF-8"?>
159
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
160
+ <plist version="1.0">
161
+ <dict>
162
+ <key>Label</key>
163
+ <string>${esc(o.label)}</string>
164
+ <key>ProgramArguments</key>
165
+ <array>
166
+ ${argXml}
167
+ </array>
168
+ <key>WorkingDirectory</key>
169
+ <string>${esc(o.workingDir)}</string>
170
+ <key>RunAtLoad</key>
171
+ <true/>
172
+ <key>KeepAlive</key>
173
+ <true/>
174
+ <key>ThrottleInterval</key>
175
+ <integer>30</integer>
176
+ <key>ProcessType</key>
177
+ <string>Background</string>
178
+ <key>Nice</key>
179
+ <integer>10</integer>
180
+ <key>LowPriorityIO</key>
181
+ <true/>
182
+ <key>StandardOutPath</key>
183
+ <string>${esc(o.logPath)}</string>
184
+ <key>StandardErrorPath</key>
185
+ <string>${esc(o.logPath)}</string>
186
+ </dict>
187
+ </plist>
188
+ `;
189
+ }
190
+ /** `~/Library/LaunchAgents/<label>.plist` for the invoking user (HOME from env). */
191
+ export function plistPath(env, label = COLLECTOR_LABEL) {
192
+ const home = env.HOME || env.USERPROFILE || '';
193
+ return join(home, 'Library', 'LaunchAgents', `${label}.plist`);
194
+ }
195
+ /** The data directory that holds the DB (and the collector log), from the resolved DB path. */
196
+ function dataDir(env, db) {
197
+ return dirname(db && db !== ':memory:' ? db : defaultDbPath(env));
198
+ }
199
+ /** The source bin shim this file ships next to (…/packages/cli/bin/dexterm.mjs). */
200
+ function sourceBin() {
201
+ return fileURLToPath(new URL('../bin/dexterm.mjs', import.meta.url));
202
+ }
203
+ export function parseRun(argv) {
204
+ 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 };
205
+ for (let i = 0; i < argv.length; i++) {
206
+ const t = argv[i];
207
+ if (t === '--verbose' || t === '-v')
208
+ a.verbose = true;
209
+ else if (t === '--no-newpairs')
210
+ a.noNewpairs = true;
211
+ else if (t === '--once')
212
+ a.ticks = 1;
213
+ else if (t === '--ticks') {
214
+ const n = Number(argv[++i]);
215
+ if (!Number.isInteger(n) || n < 1)
216
+ return { error: '--ticks needs an integer ≥ 1' };
217
+ a.ticks = n;
218
+ }
219
+ else if (t === '--interval') {
220
+ const n = Number(argv[++i]);
221
+ if (!Number.isFinite(n) || n < 1)
222
+ return { error: '--interval needs seconds ≥ 1' };
223
+ a.intervalMs = Math.round(n * 1000);
224
+ }
225
+ else if (t === '--maintain') {
226
+ const n = Number(argv[++i]);
227
+ if (!Number.isFinite(n) || n < 1)
228
+ return { error: '--maintain needs seconds ≥ 1' };
229
+ a.maintainMs = Math.round(n * 1000);
230
+ }
231
+ else if (t === '--max-db-mb') {
232
+ const n = Number(argv[++i]);
233
+ if (!Number.isFinite(n) || n < 0)
234
+ return { error: '--max-db-mb needs a number ≥ 0 (0 = no cap)' };
235
+ a.maxDbBytes = n === 0 ? null : Math.round(n * 1_000_000);
236
+ }
237
+ else if (t === '--db') {
238
+ a.db = argv[++i] ?? null;
239
+ if (!a.db)
240
+ return { error: '--db needs a PATH' };
241
+ }
242
+ else
243
+ return { error: `unknown option "${t}"` };
244
+ }
245
+ return a;
246
+ }
247
+ export const RECORD_USAGE = `dexterm record: keep the history recorder fed with no TUI open (task-1414).
248
+
249
+ dexterm record [run] [--interval S] [--ticks N | --once] [--maintain S] [--max-db-mb N] [--no-newpairs] [--db PATH] [--verbose]
250
+ dexterm record install [--interval S] [--max-db-mb N] [--load] [--dry-run]
251
+ dexterm record uninstall [--unload]
252
+ dexterm record status [--db PATH]
253
+
254
+ run the collector loop: one keyless feed refresh per --interval (default 60 s) feeds the recorder
255
+ (snapshots + feed membership + new pairs), recorder maintenance (outcome sweeps, labels, retention)
256
+ every --maintain seconds. --max-db-mb is a soft cap (default 1024): above it observation pauses while
257
+ labels + retention keep running. --no-newpairs drops the PumpPortal stream. Ctrl-C stops cleanly
258
+ (labels whatever is due). Requests honour the data layer's rate limits, health gate and sweep budgets.
259
+ install write a user-level launchd LaunchAgent (no sudo) that runs \`record run\`; prints the launchctl
260
+ command. --load also runs launchctl (off by default). --dry-run prints the plist, writes nothing.
261
+ uninstall remove the LaunchAgent plist; --unload also runs launchctl bootout.
262
+ status recorder stats + whether the LaunchAgent is installed + DB size vs the cap.
263
+
264
+ NOTE: a LaunchAgent does not start before the user logs in on this Mac (it is not a pre-login daemon). The
265
+ collector runs only while this user is logged in. Free-tier APIs only; the Helius key is never printed.`;
266
+ export async function recordCommand(argv, io, deps = {}) {
267
+ const sub = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'run';
268
+ const rest = argv[0] && !argv[0].startsWith('--') ? argv.slice(1) : argv;
269
+ if (sub === 'help' || argv.includes('--help') || argv.includes('-h')) {
270
+ io.stdout.write(RECORD_USAGE + '\n');
271
+ return 0;
272
+ }
273
+ if (sub === 'install')
274
+ return install(rest, io);
275
+ if (sub === 'uninstall')
276
+ return uninstall(rest, io);
277
+ if (sub === 'status')
278
+ return status(rest, io);
279
+ if (sub === 'run')
280
+ return run(rest, io, deps);
281
+ io.stderr.write(`dexterm record: unknown subcommand "${sub}"\n\n${RECORD_USAGE}\n`);
282
+ return 2;
283
+ }
284
+ async function run(argv, io, deps) {
285
+ const parsed = parseRun(argv);
286
+ if ('error' in parsed) {
287
+ io.stderr.write(`dexterm record: ${parsed.error}\n\n${RECORD_USAGE}\n`);
288
+ return 2;
289
+ }
290
+ const now = deps.now ?? (() => Date.now());
291
+ const dbPath = parsed.db ?? defaultDbPath(io.env);
292
+ if (!deps.engine && dbPath === ':memory:') {
293
+ io.stderr.write('dexterm record: an in-memory DB keeps no history; give a --db PATH\n');
294
+ return 2;
295
+ }
296
+ if (!deps.engine && !recorderEnabled(io.env, false)) {
297
+ io.stderr.write('dexterm record: the recorder is disabled (DEXTERM_RECORDER=0); nothing to collect\n');
298
+ return 2;
299
+ }
300
+ // SIGINT/SIGTERM-aware cancellable sleeper (launchd sends SIGTERM to stop the agent).
301
+ const stop = (() => {
302
+ let aborted = false;
303
+ let wake = () => { };
304
+ return {
305
+ get aborted() { return aborted; },
306
+ abort() { aborted = true; wake(); },
307
+ sleep(ms) { return new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); wake = () => { clearTimeout(t); r(); }; }); },
308
+ };
309
+ })();
310
+ const log = (s) => io.stderr.write(s);
311
+ const engine = deps.engine ?? buildLiveEngine(io.env, { dbPath, noNewpairs: parsed.noNewpairs, verbose: parsed.verbose, onLog: log });
312
+ const own = !deps.engine;
313
+ const onSig = () => { io.stderr.write('\ndexterm record: stopping…\n'); stop.abort(); };
314
+ if (own) {
315
+ process.on('SIGINT', onSig);
316
+ process.on('SIGTERM', onSig);
317
+ }
318
+ if (parsed.verbose || parsed.ticks !== Infinity)
319
+ 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'}\n`);
320
+ let result;
321
+ try {
322
+ result = await runCollectorLoop(engine, {
323
+ intervalMs: parsed.intervalMs, ticks: parsed.ticks, maintainEveryMs: parsed.maintainMs, maxDbBytes: parsed.maxDbBytes,
324
+ now, sleep: (ms) => stop.sleep(ms), aborted: () => stop.aborted, onLog: log,
325
+ });
326
+ }
327
+ finally {
328
+ if (own) {
329
+ process.removeListener('SIGINT', onSig);
330
+ process.removeListener('SIGTERM', onSig);
331
+ }
332
+ engine.close();
333
+ }
334
+ if (parsed.verbose || parsed.ticks !== Infinity)
335
+ io.stdout.write(`record: ${result.observePasses} observe pass(es), ${result.rowsWritten} snapshot row(s), ${result.maintainPasses} maintenance pass(es)${result.cappedTicks ? `, ${result.cappedTicks} tick(s) paused at the disk cap` : ''}\n`);
336
+ return 0;
337
+ }
338
+ function install(argv, io) {
339
+ let intervalMs = DEFAULT_INTERVAL_MS, maxDbMb = DEFAULT_MAX_DB_MB, load = false, dry = false;
340
+ for (let i = 0; i < argv.length; i++) {
341
+ const t = argv[i];
342
+ if (t === '--load')
343
+ load = true;
344
+ else if (t === '--dry-run')
345
+ dry = true;
346
+ else if (t === '--interval') {
347
+ const n = Number(argv[++i]);
348
+ if (!Number.isFinite(n) || n < 1) {
349
+ io.stderr.write('dexterm record: --interval needs seconds ≥ 1\n');
350
+ return 2;
351
+ }
352
+ intervalMs = Math.round(n * 1000);
353
+ }
354
+ else if (t === '--max-db-mb') {
355
+ const n = Number(argv[++i]);
356
+ if (!Number.isFinite(n) || n < 1) {
357
+ io.stderr.write('dexterm record: --max-db-mb needs a number ≥ 1\n');
358
+ return 2;
359
+ }
360
+ maxDbMb = n;
361
+ }
362
+ else {
363
+ io.stderr.write(`dexterm record install: unknown option "${t}"\n`);
364
+ return 2;
365
+ }
366
+ }
367
+ const path = plistPath(io.env);
368
+ const logPath = join(dataDir(io.env), 'collector.log');
369
+ const bin = sourceBin();
370
+ const plist = renderCollectorPlist({ label: COLLECTOR_LABEL, nodePath: process.execPath, binPath: bin, intervalMs, maxDbMb, logPath, workingDir: resolve(dirname(bin), '..', '..', '..') });
371
+ if (dry) {
372
+ io.stdout.write(plist);
373
+ io.stderr.write(`\ndexterm record: dry run — would write ${path}\n`);
374
+ return 0;
375
+ }
376
+ mkdirSync(dirname(path), { recursive: true });
377
+ mkdirSync(dirname(logPath), { recursive: true });
378
+ writeFileSync(path, plist);
379
+ io.stdout.write(`wrote ${path}\nlog ${logPath}\n\nEnable it (user-level, no sudo):\n launchctl bootstrap gui/$(id -u) ${path}\n launchctl kickstart -k gui/$(id -u)/${COLLECTOR_LABEL}\nStop/remove it: dexterm record uninstall --unload\n\nNOTE: a LaunchAgent does NOT start before you log in on this Mac; the collector runs only while logged in.\n`);
380
+ if (load)
381
+ return runLaunchctl(['bootstrap', `gui/${process.getuid?.() ?? 0}`, path], io);
382
+ io.stderr.write('dexterm record: plist written but NOT loaded (pass --load, or run the launchctl command above) once you have checked it.\n');
383
+ return 0;
384
+ }
385
+ function uninstall(argv, io) {
386
+ const unload = argv.includes('--unload');
387
+ const path = plistPath(io.env);
388
+ if (unload)
389
+ runLaunchctl(['bootout', `gui/${process.getuid?.() ?? 0}/${COLLECTOR_LABEL}`], io);
390
+ if (existsSync(path)) {
391
+ rmSync(path, { force: true });
392
+ io.stdout.write(`removed ${path}\n`);
393
+ }
394
+ else
395
+ io.stdout.write(`no LaunchAgent at ${path}\n`);
396
+ return 0;
397
+ }
398
+ function status(argv, io) {
399
+ let db = null;
400
+ for (let i = 0; i < argv.length; i++) {
401
+ if (argv[i] === '--db') {
402
+ db = argv[++i] ?? null;
403
+ if (!db) {
404
+ io.stderr.write('dexterm record: --db needs a PATH\n');
405
+ return 2;
406
+ }
407
+ }
408
+ }
409
+ const path = plistPath(io.env);
410
+ const installed = existsSync(path);
411
+ const dbPath = db ?? defaultDbPath(io.env);
412
+ io.stdout.write(`LaunchAgent ${installed ? `installed (${path})` : 'not installed (dexterm record install)'}\n`);
413
+ io.stdout.write(`service note LaunchAgents do not start before login; the collector runs only while logged in\n`);
414
+ if (dbPath === ':memory:') {
415
+ io.stdout.write('recorder in-memory DB (no history)\n');
416
+ return 0;
417
+ }
418
+ let rec = null;
419
+ try {
420
+ rec = new Recorder({ path: dbPath, enabled: false });
421
+ const s = rec.stats();
422
+ const when = (t) => (t === null ? '—' : new Date(t).toISOString());
423
+ io.stdout.write([
424
+ `db ${dbPath} (${s.dbBytes === null ? '—' : `${(s.dbBytes / 1e6).toFixed(1)} MB`})`,
425
+ `snapshots ${s.snapshots} (live ${s.live}, sweep ${s.sweep}, t0 anchors ${s.t0})`,
426
+ `outcomes ${s.outcomes}${s.outcomes ? ` (${Object.entries(s.outcomesByLabel).map(([k, v]) => `${k} ${v}`).join(', ')})` : ''}`,
427
+ `sweeps ${s.sweeps} runs · ${s.sweepCallsToday} DexScreener calls today`,
428
+ `span ${when(s.oldestT)} → ${when(s.newestT)}`,
429
+ ].join('\n') + '\n');
430
+ }
431
+ catch (e) {
432
+ io.stderr.write(`dexterm record status: ${e instanceof Error ? e.message : String(e)}\n`);
433
+ return 1;
434
+ }
435
+ finally {
436
+ rec?.close();
437
+ }
438
+ return 0;
439
+ }
440
+ /** Run launchctl best-effort (only on --load/--unload). Never throws: a failure is reported, the plist stays. */
441
+ function runLaunchctl(args, io) {
442
+ try {
443
+ const r = spawnSync('launchctl', args, { stdio: 'inherit' });
444
+ if (r.status !== 0) {
445
+ io.stderr.write(`dexterm record: launchctl ${args[0]} exited ${r.status ?? 'null'}\n`);
446
+ return 1;
447
+ }
448
+ io.stdout.write(`launchctl ${args[0]} ok\n`);
449
+ return 0;
450
+ }
451
+ catch (e) {
452
+ io.stderr.write(`dexterm record: launchctl failed: ${e instanceof Error ? e.message : String(e)}\n`);
453
+ return 1;
454
+ }
455
+ }
@@ -48,7 +48,13 @@ export function larpGrade(verdict) {
48
48
  const g = LARP_GRADES[verdict];
49
49
  return g === undefined ? null : g;
50
50
  }
51
- /** The rows a rule can see: the screener feed (no skeletons) with the wallet-intel top-cluster share and the LARP grade attached. */
51
+ /**
52
+ * The rows a rule can see: the screener feed (no skeletons) with the wallet-intel top-cluster share and the LARP
53
+ * grade attached. task-1404: the OPEN token also carries its LIVE holder/rug signal fields (top10 / dev / bundled /
54
+ * snipers / fresh / cluster / lpLocked / bonding / mintable / freezable), so a rule like `dev.delta < 0` or
55
+ * `mintable > 0` fires on the real streaming values, not the on-open snapshot. A token opened by `--mint` that is
56
+ * not in the screener feed is appended as its own row so rules still see it.
57
+ */
52
58
  export function ruleRowsOf(state) {
53
59
  const out = [];
54
60
  const seen = new Set();
@@ -59,14 +65,56 @@ export function ruleRowsOf(state) {
59
65
  const c = res.analysis.clusters[0];
60
66
  return c ? c.pct : null;
61
67
  };
68
+ const openMint = state.live?.token?.view.mint ?? null;
62
69
  for (const r of state.screener.rows) {
63
70
  if (!r.mint || r.skeleton || seen.has(r.mint))
64
71
  continue;
65
72
  seen.add(r.mint);
66
- out.push(Object.assign(Object.create(null), r, { cluster: clusterOf(r.mint), larp: larpGrade(state.larpByMint?.get(r.mint)?.verdict) }));
73
+ const row = Object.assign(Object.create(null), r, { cluster: clusterOf(r.mint), larp: larpGrade(state.larpByMint?.get(r.mint)?.verdict) });
74
+ if (r.mint === openMint)
75
+ overlaySignalFields(row, state, r.mint);
76
+ out.push(row);
77
+ }
78
+ // A token opened by mint (not in the screener feed): synthesize a row from its live view so rules see it too.
79
+ if (openMint && !seen.has(openMint) && state.live?.token) {
80
+ const v = state.live.token.view;
81
+ const row = Object.assign(Object.create(null), {
82
+ mint: v.mint, symbol: v.symbol, price: v.price, mcap: v.mcap, fdv: v.fdv, liq: v.liq, vol24: v.vol24,
83
+ holders: v.holders, org: v.org, top10: v.top10, dev: v.dev, lpLocked: v.lpLocked,
84
+ cluster: clusterOf(v.mint), larp: larpGrade(state.larpByMint?.get(v.mint)?.verdict),
85
+ ageMin: v.ageMin, chg: v.chg, launchpad: v.launchpad, flags: v.flags, verified: v.verified, sus: v.sus,
86
+ });
87
+ overlaySignalFields(row, state, v.mint);
88
+ out.push(row);
67
89
  }
68
90
  return out;
69
91
  }
92
+ /** Overlay the live-signals engine's latest values (non-null wins) onto a rule row (task-1404). */
93
+ function overlaySignalFields(row, state, mint) {
94
+ const f = state.live?.signals?.fields(mint);
95
+ if (!f)
96
+ return;
97
+ if (f.top10 !== null)
98
+ row.top10 = f.top10;
99
+ if (f.dev !== null)
100
+ row.dev = f.dev;
101
+ if (f.bundled !== null)
102
+ row.bundled = f.bundled;
103
+ if (f.snipers !== null)
104
+ row.snipers = f.snipers;
105
+ if (f.cluster !== null)
106
+ row.cluster = f.cluster;
107
+ if (f.lpLocked !== null)
108
+ row.lpLocked = f.lpLocked;
109
+ if (f.bonding !== null)
110
+ row.bonding = f.bonding;
111
+ if (f.fresh !== null)
112
+ row.fresh = f.fresh;
113
+ if (f.mintable !== null)
114
+ row.mintable = f.mintable;
115
+ if (f.freezable !== null)
116
+ row.freezable = f.freezable;
117
+ }
70
118
  /**
71
119
  * One engine tick: observe the rows, evaluate, and turn the outputs into app state. Self-throttled to RULES_TICK_MS.
72
120
  * Alerts become the first pending toast + log lines; ticket/ask become PROPOSAL cards; watch/tag touch the user's
@@ -6,7 +6,7 @@
6
6
  // mirror PROPOSAL and Tier 3 the capped auto-mirror, both behind the plan engine's own keypress gates.
7
7
  import { Canvas, header, footer, breakpoint, fmtAgeSec } from '../../../tui/src/index.js';
8
8
  import { headerModel } from "../state.js";
9
- import { shortAddr, autoSizeCap } from '../../../data/src/trade/copy/index.js';
9
+ import { shortAddr, autoSizeCap, fmtHoldMs } from '../../../data/src/trade/copy/index.js';
10
10
  function fmtSol(n) { const s = n >= 0 ? '+' : '−'; return `${s}${Math.abs(n).toFixed(Math.abs(n) >= 10 ? 1 : 3)} SOL`; }
11
11
  function fmtUsdSigned(n) { const s = n >= 0 ? '+' : '−'; const a = Math.abs(n); return `${s}$${a >= 1000 ? (a / 1000).toFixed(1) + 'k' : a.toFixed(a >= 100 ? 0 : 2)}`; }
12
12
  function fmtUsdAbs(n) { if (n === null)
@@ -196,6 +196,95 @@ export function renderFollows(c, state) {
196
196
  y += 2;
197
197
  c.text(mbox.x + 1, y, gateOk ? 'Hold and release [enter] or [y] to turn auto ON (physical keypress) · [esc] leaves it OFF · a pipe/agent cannot' : '[esc] closes · accept the popup, then A again', { fg: t.accent, bg: mbg }, mbox.w - 2);
198
198
  }
199
+ // task-1399: the per-wallet DETAIL view (its own trades + positions + metrics, each with source + data age).
200
+ if (ui?.detail)
201
+ renderWalletDetail(c, state, now);
199
202
  const autoHint = (() => { const f = follows[Math.max(0, Math.min(follows.length - 1, ui?.selected ?? 0))]; const p = f ? svc?.auto?.policy(f.addr) : null; return p?.enabled ? 'auto OFF' : 'auto-mirror'; })();
200
- footer(c, [['↑↓', 'move'], ['i', 'follow a wallet'], ['d', 'unfollow'], ['m', ui?.mirror ? (ui.mirror.result?.ok ? 'PROPOSE mirror' : 'close') : 'mirror ticket'], ['A', autoHint], ['r', 'poll now'], ['esc', 'back'], ['q', 'quit']], t.name);
203
+ const foot = ui?.detail
204
+ ? [['↑↓', 'scroll'], ['enter/esc', 'close'], ['q', 'quit']]
205
+ : [['↑↓', 'move'], ['enter', 'wallet view'], ['i', 'follow a wallet'], ['d', 'unfollow'], ['m', ui?.mirror ? (ui.mirror.result?.ok ? 'PROPOSE mirror' : 'close') : 'mirror ticket'], ['A', autoHint], ['r', 'poll now'], ['esc', 'back'], ['q', 'quit']];
206
+ footer(c, foot, t.name);
207
+ }
208
+ /**
209
+ * task-1399 — one followed wallet's DETAIL view: its own recent decoded trades and positions, plus the full metric
210
+ * set (realised PnL, win rate with n, median hold time, early-entry score, open), each labelled with its source
211
+ * (Helius history) and data age (time since the last poll). Read-only: nothing here trades, arms, proposes or signs.
212
+ */
213
+ function renderWalletDetail(c, state, now) {
214
+ const t = c.theme;
215
+ const ui = state.copyUi;
216
+ const svc = state.copy;
217
+ if (!ui?.detail || !svc)
218
+ return;
219
+ const addr = ui.detail.wallet;
220
+ const f = svc.follows().find((x) => x.addr === addr);
221
+ const s = svc.stats(addr);
222
+ const trades = svc.trades(addr); // newest first
223
+ const top = 2, bottom = c.h - 1;
224
+ const mw = Math.min(c.w - 2, 104);
225
+ const mh = Math.max(10, bottom - top - 1);
226
+ const mx = Math.floor((c.w - mw) / 2), my = top + 1;
227
+ c.noRain(mx, my, mw, mh);
228
+ const label = f?.name ? ` · ${f.name}` : '';
229
+ const box = c.box(mx, my, mw, mh, { title: `WALLET${label} — read-only`, caption: 'its own trades + positions · every number: source + data age · not a recommendation', focus: true, bg: t.panelAlt });
230
+ const bg = box.bg;
231
+ let y = box.y;
232
+ c.text(box.x + 1, y, addr, { fg: t.text, bg }, box.w - 2);
233
+ y += 1;
234
+ const age = s.sample.asOf ? `${Math.max(0, Math.round((now - s.sample.asOf) / 1000))}s ago` : 'not fetched yet';
235
+ c.text(box.x + 1, y, `source ${s.sample.source} · ${age} · ${s.sample.note}`, { fg: t.dim, bg }, box.w - 2);
236
+ y += 1;
237
+ const pnl = `${fmtSol(s.realisedSol)}${s.realisedUsd !== null ? ` ${fmtUsdSigned(s.realisedUsd)}` : ''}`;
238
+ const wr = s.winRate === null ? `win rate n<10 (${s.wins}/${s.closed})` : `win rate ${Math.round(s.winRate * 100)}% (${s.wins}/${s.closed})`;
239
+ c.text(box.x + 1, y, `realised ${pnl} · ${wr} · hold ${fmtHoldMs(s.holdMedianMs)} · EES ${s.earlyEntry.score === null ? '—' : s.earlyEntry.score.toFixed(2)} · open ${s.open} · ${s.buys}b/${s.sells}s`, { fg: t.text, bg }, box.w - 2);
240
+ y += 1;
241
+ c.text(box.x + 1, y, `hold: ${s.holdNote} · EES: ${s.earlyEntry.note}`, { fg: t.dim, bg }, box.w - 2);
242
+ y += 2;
243
+ const footerY = box.y + box.h - 1;
244
+ const posCount = Math.min(s.positions.length, 6);
245
+ const posHeaderY = posCount ? footerY - posCount - 1 : footerY;
246
+ // RECENT TRADES (scrollable) fill the space above the positions block.
247
+ c.text(box.x + 1, y, 'RECENT TRADES', { fg: t.dim, bg });
248
+ y += 1;
249
+ if (!trades.length) {
250
+ c.text(box.x + 1, y, svc.status().state === 'unavailable' ? 'needs a free Helius key' : 'no swaps in fetched history', { fg: t.dim, bg, i: true });
251
+ y += 1;
252
+ }
253
+ else {
254
+ const roomRows = Math.max(1, posHeaderY - 1 - y);
255
+ const scroll = Math.max(0, Math.min(Math.max(0, trades.length - roomRows), ui.detail.scroll));
256
+ for (let i = scroll; i < trades.length && y < posHeaderY - 1; i++) {
257
+ const tr = trades[i];
258
+ const sideFg = tr.side === 'buy' ? t.up : tr.side === 'sell' ? t.down : t.muted;
259
+ let cx = c.text(box.x + 1, y, (tr.tKnown ? new Date(tr.t).toISOString().replace('T', ' ').slice(5, 19) : 'time unknown ').padEnd(15), { fg: t.dim, bg });
260
+ cx = c.text(cx + 1, y, tr.side.toUpperCase().padEnd(9), { fg: sideFg, bg, b: true });
261
+ cx = c.text(cx + 1, y, shortAddr(tr.mint).padEnd(11), { fg: t.text, bg });
262
+ cx = c.text(cx + 1, y, (tr.solDelta !== null ? `${Math.abs(tr.solDelta).toFixed(3)} SOL` : 'unpriced').padStart(11), { fg: t.muted, bg });
263
+ cx = c.text(cx + 1, y, fmtUsdAbs(tr.usd).padStart(8), { fg: t.text, bg });
264
+ if (tr.note)
265
+ c.text(cx + 1, y, `(${tr.note})`, { fg: t.dim, bg }, box.w - (cx + 1 - box.x) - 1);
266
+ y += 1;
267
+ }
268
+ const older = trades.length - scroll - roomRows;
269
+ if (older > 0)
270
+ c.textR(box.x + 1, posHeaderY - 1, box.w - 2, `… ${older} older (↑↓)`, { fg: t.dim, bg, i: true });
271
+ }
272
+ // POSITIONS block (fixed, bottom).
273
+ if (posCount) {
274
+ c.text(box.x + 1, posHeaderY, `POSITIONS · ${s.positions.length}`, { fg: t.dim, bg });
275
+ let py = posHeaderY + 1;
276
+ for (const p of s.positions.slice(0, posCount)) {
277
+ if (py >= footerY)
278
+ break;
279
+ let cx = c.text(box.x + 1, py, shortAddr(p.mint).padEnd(11), { fg: t.text, bg });
280
+ cx = c.text(cx + 1, py, (p.closed ? 'closed' : 'open').padEnd(7), { fg: p.closed ? t.muted : t.accent, bg });
281
+ cx = c.text(cx + 1, py, fmtSol(p.realisedSol).padStart(11), { fg: p.realisedSol > 0 ? t.up : p.realisedSol < 0 ? t.down : t.muted, bg });
282
+ cx = c.text(cx + 1, py, `${p.buys}b/${p.sells}s`.padEnd(8), { fg: t.dim, bg });
283
+ cx = c.text(cx + 1, py, `hold ${fmtHoldMs(p.holdMs)}`.padEnd(12), { fg: t.dim, bg });
284
+ if (p.flagged)
285
+ c.text(cx + 1, py, '(cost-0 lot: airdrop/transfer/unpriced)', { fg: t.dim, bg }, box.w - (cx + 1 - box.x) - 1);
286
+ py += 1;
287
+ }
288
+ }
289
+ c.textR(box.x + 1, footerY, box.w - 2, 'not a recommendation · a wallet\'s past fills say nothing about its next one', { fg: t.dim, bg, i: true });
201
290
  }
@@ -3,6 +3,7 @@
3
3
  // after the effects pass, so the card is crisp and no rain/scanline crosses it. Any key closes the
4
4
  // overlay; `?` toggles help.
5
5
  import { Canvas, DEFAULT_KEYMAP, mix } from '../../../tui/src/index.js';
6
+ import { renderPnlOverlay } from "../pnl-app.js";
6
7
  /** Pretty single-key display for a keymap id ('enter' → '⏎', 'pagedown' → 'PgDn', 'ctrl+f' → '^F'). */
7
8
  function prettyKey(id) {
8
9
  switch (id) {
@@ -241,6 +242,8 @@ export function renderOverlay(c, state) {
241
242
  renderHelp(c, state);
242
243
  else if (state.overlay === 'workspaces')
243
244
  renderWorkspaces(c, state);
245
+ else if (state.overlay === 'pnl')
246
+ renderPnlOverlay(c, state);
244
247
  else if (state.overlay === 'links' && state.linkMenu) {
245
248
  const menu = state.linkMenu;
246
249
  const box = card(c, { w: 100, h: Math.min(24, menu.items.length * 2 + 6) }, 'ADVERTISED LINKS (UNTRUSTED)', 'j/k select · enter open · esc close');
@@ -172,5 +172,5 @@ export function renderPositions(c, state) {
172
172
  }
173
173
  if (v.history.length === 0)
174
174
  c.text(hb.x + 1, hb.y, 'no paper trades yet', { fg: t.dim, bg: hb.bg });
175
- footer(c, [['esc', 'back'], ['r', 'refresh'], ['1-4', 'screens'], ['q', 'quit']], t.name, isCalm(state.vfx));
175
+ footer(c, [['esc', 'back'], ['r', 'refresh'], ['H', 'PnL card'], ['1-4', 'screens'], ['q', 'quit']], t.name, isCalm(state.vfx));
176
176
  }
@@ -39,7 +39,7 @@ export function renderRules(c, state) {
39
39
  cy += 1;
40
40
  c.text(box.x + 1, cy, 'dexterm rules add "<rule>" the same from a shell · rules list | check | dry-run | remove | enable | disable', { fg: t.dim, bg }, box.w - 2);
41
41
  cy += 1;
42
- c.text(box.x + 1, cy, 'fields: price mcap fdv liq vol5m vol1h vol24 holders org top10 dev lplocked bundled snipers cluster age m5 h1 h6 h24 buys sells bonding', { fg: t.dim, bg }, box.w - 2);
42
+ c.text(box.x + 1, cy, 'fields: price mcap fdv liq vol5m vol1h vol24 holders org top10 dev lplocked bundled snipers cluster fresh mintable freezable age m5 h1 h6 h24 buys sells bonding', { fg: t.dim, bg }, box.w - 2);
43
43
  cy += 1;
44
44
  c.text(box.x + 1, cy, 'signals: <field>.velocity (× its own pace) · <field>.delta (change in the window) · tags: launchpad: flag: verified: sus:', { fg: t.dim, bg }, box.w - 2);
45
45
  cy += 2;