dexterm-cli 0.1.4 → 0.1.6

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 (131) hide show
  1. package/dist/packages/agents/src/index.js +2 -0
  2. package/dist/packages/agents/src/spectator.js +54 -0
  3. package/dist/packages/agents/src/story.js +523 -0
  4. package/dist/packages/agents/src/tiers.js +1 -0
  5. package/dist/packages/agents/src/tools.js +1 -0
  6. package/dist/packages/cli/assets/claude/plugin/skills/REGISTRY.json +1 -1
  7. package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +15 -8
  8. package/dist/packages/cli/src/agent-cmd.js +6 -4
  9. package/dist/packages/cli/src/agent-paper.js +60 -0
  10. package/dist/packages/cli/src/agent-spectator.js +17 -1
  11. package/dist/packages/cli/src/analyst/backend.js +206 -0
  12. package/dist/packages/cli/src/analyst/codex.js +301 -0
  13. package/dist/packages/cli/src/analyst/larp-gauge.js +60 -0
  14. package/dist/packages/cli/src/analyst/session.js +21 -3
  15. package/dist/packages/cli/src/app-help-gen.js +21 -12
  16. package/dist/packages/cli/src/backtest-cmd.js +13 -2
  17. package/dist/packages/cli/src/display.js +55 -9
  18. package/dist/packages/cli/src/doctor.js +58 -2
  19. package/dist/packages/cli/src/live/dev-history.js +115 -0
  20. package/dist/packages/cli/src/live/index.js +5 -1
  21. package/dist/packages/cli/src/live/newpairs.js +12 -0
  22. package/dist/packages/cli/src/live/screens/newpairs-live.js +1 -1
  23. package/dist/packages/cli/src/live/screens/token-live.js +51 -11
  24. package/dist/packages/cli/src/live/screens/watchlist-live.js +1 -1
  25. package/dist/packages/cli/src/live/token.js +1 -0
  26. package/dist/packages/cli/src/live/trade.js +22 -0
  27. package/dist/packages/cli/src/main.js +267 -19
  28. package/dist/packages/cli/src/mcp-follow.js +137 -10
  29. package/dist/packages/cli/src/plan-cmd.js +15 -5
  30. package/dist/packages/cli/src/popup.js +17 -1
  31. package/dist/packages/cli/src/record-cmd.js +2 -0
  32. package/dist/packages/cli/src/rules.js +25 -2
  33. package/dist/packages/cli/src/scan/ui.js +7 -1
  34. package/dist/packages/cli/src/screens/agent-hud.js +741 -0
  35. package/dist/packages/cli/src/screens/analyst-session.js +9 -3
  36. package/dist/packages/cli/src/screens/analyst.js +4 -3
  37. package/dist/packages/cli/src/screens/brain.js +608 -0
  38. package/dist/packages/cli/src/screens/newpairs.js +1 -1
  39. package/dist/packages/cli/src/screens/pnl-chip.js +143 -0
  40. package/dist/packages/cli/src/screens/screener.js +1 -0
  41. package/dist/packages/cli/src/screens/token.js +16 -7
  42. package/dist/packages/cli/src/screens/trending.js +1 -1
  43. package/dist/packages/cli/src/state.js +69 -5
  44. package/dist/packages/cli/src/version.js +1 -1
  45. package/dist/packages/custom/src/rules.js +2 -2
  46. package/dist/packages/custom/src/schema.js +13 -3
  47. package/dist/packages/data/src/fixtures.js +28 -6
  48. package/dist/packages/data/src/index.js +5 -0
  49. package/dist/packages/data/src/larp/score.js +220 -0
  50. package/dist/packages/data/src/larp/x-account.js +316 -0
  51. package/dist/packages/data/src/sources/jupiter.js +1 -0
  52. package/dist/packages/data/src/trade/agent/paper-desk.js +401 -0
  53. package/dist/packages/data/src/trade/agent/rendezvous.js +161 -0
  54. package/dist/packages/data/src/trade/plans/engine.js +7 -0
  55. package/dist/packages/data/src/trade/plans/mandate.js +28 -15
  56. package/dist/packages/data/src/trade/plans/service.js +1 -1
  57. package/dist/packages/mcp/assets/kb/app-help/agent-paper-trade.md +38 -0
  58. package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +1 -1
  59. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +1 -1
  60. package/dist/packages/mcp/assets/kb/app-help/dev-history.md +32 -0
  61. package/dist/packages/mcp/assets/kb/app-help/larp-check.md +5 -1
  62. package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +1 -1
  63. package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +1 -1
  64. package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +4 -1
  65. package/dist/packages/mcp/assets/kb/app-help/screen-token.md +4 -0
  66. package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +2 -1
  67. package/dist/packages/mcp/assets/kb/app-help/use-codex-analyst.md +31 -0
  68. package/dist/packages/mcp/assets/kb/docs/docs-agents-agent-paper-trading.md +51 -0
  69. package/dist/packages/mcp/assets/kb/docs/docs-agents-directory-metadata-prepared-not-submitted.md +36 -0
  70. package/dist/packages/mcp/assets/kb/docs/docs-agents-install-in-one-command.md +2 -0
  71. package/dist/packages/mcp/assets/kb/docs/docs-analyst-if-claude-is-missing.md +1 -1
  72. package/dist/packages/mcp/assets/kb/docs/docs-analyst-starting-a-run.md +2 -0
  73. package/dist/packages/mcp/assets/kb/docs/docs-analyst-use-codex-instead-of-claude.md +35 -0
  74. package/dist/packages/mcp/assets/kb/docs/docs-beginners-macos-or-linux-npm.md +1 -1
  75. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
  76. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +1 -1
  77. package/dist/packages/mcp/assets/kb/docs/docs-configuration-chart-y-axis.md +32 -0
  78. package/dist/packages/mcp/assets/kb/docs/docs-configuration-environment-variables.md +2 -0
  79. package/dist/packages/mcp/assets/kb/docs/docs-configuration-price-display-mode.md +3 -3
  80. package/dist/packages/mcp/assets/kb/docs/docs-configuration-rules-engine.md +1 -0
  81. package/dist/packages/mcp/assets/kb/docs/docs-configuration-the-analyst-s-launch.md +2 -0
  82. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +13 -3
  83. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +1 -1
  84. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-running-the-tests.md +1 -1
  85. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -2
  86. package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +1 -2
  87. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +60 -0
  88. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-verdict.md +0 -2
  89. package/dist/packages/mcp/assets/kb/docs/docs-larp-what-it-looks-at.md +4 -1
  90. package/dist/packages/mcp/assets/kb/docs/docs-larp.md +1 -1
  91. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-letting-an-agent-paper-trade-experimental.md +32 -0
  92. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-order-plans-and-standing-mandates.md +1 -9
  93. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-paper-only-mandates.md +41 -0
  94. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-what-the-live-path-keeps.md +1 -1
  95. package/dist/packages/mcp/assets/kb/docs/docs-popup-chart-market-cap-axis.md +1 -1
  96. package/dist/packages/mcp/assets/kb/docs/docs-popup-chart-what-it-shows.md +1 -0
  97. package/dist/packages/mcp/assets/kb/docs/docs-scan-the-read.md +2 -0
  98. package/dist/packages/mcp/assets/kb/docs/docs-screens-dev-history.md +47 -0
  99. package/dist/packages/mcp/assets/kb/docs/docs-screens-how-well-it-predicts-measured-held-out.md +46 -0
  100. package/dist/packages/mcp/assets/kb/docs/docs-spectator-follow-over-mcp-in-the-running-terminal.md +1 -1
  101. package/dist/packages/mcp/assets/kb/docs/docs-spectator-limits.md +2 -0
  102. package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-agent-hud-spectator-v2.md +58 -0
  103. package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-brain-view-and-the-pnl-chip.md +46 -0
  104. package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-three-surfaces.md +1 -1
  105. package/dist/packages/mcp/assets/kb/docs/docs-spectator.md +1 -1
  106. package/dist/packages/mcp/assets/kb/docs/docs-troubleshooting-doctor-lines.md +2 -0
  107. package/dist/packages/mcp/src/index.js +5 -2
  108. package/dist/packages/mcp/src/paper-agent.js +93 -0
  109. package/dist/packages/mcp/src/server.js +354 -19
  110. package/dist/packages/mcp/src/spectator-sink.js +158 -4
  111. package/dist/packages/mcp/src/user-content.js +1 -0
  112. package/dist/packages/packs/src/index.js +1 -0
  113. package/dist/packages/packs/src/larp-score-backtest.js +119 -0
  114. package/dist/packages/popup/src/pages/chart-lib.js +36 -15
  115. package/dist/packages/popup/src/pages/chart.css +9 -0
  116. package/dist/packages/popup/src/pages/chart.html +4 -1
  117. package/dist/packages/popup/src/pages/chart.js +34 -5
  118. package/dist/packages/popup/src/pages/spectator.css +45 -0
  119. package/dist/packages/popup/src/pages/spectator.html +1 -0
  120. package/dist/packages/popup/src/pages/spectator.js +69 -0
  121. package/dist/packages/popup/src/protocol.js +2 -0
  122. package/dist/packages/popup/src/server.js +5 -1
  123. package/dist/packages/recorder/src/dev-history.js +133 -0
  124. package/dist/packages/recorder/src/index.js +1 -0
  125. package/dist/packages/recorder/src/recorder.js +23 -0
  126. package/dist/packages/recorder/src/schema.js +11 -0
  127. package/dist/packages/tui/src/canvas.js +7 -0
  128. package/dist/packages/tui/src/fmt.js +53 -0
  129. package/dist/packages/tui/src/keymap.js +4 -0
  130. package/dist/packages/tui/src/widgets.js +9 -4
  131. package/package.json +1 -1
@@ -7,11 +7,16 @@
7
7
  // The record carries NOTHING an agent could act on — it only describes a read/navigation call that
8
8
  // already completed. The file is local and token-marked; raw addresses live only on this loopback channel
9
9
  // and are redacted before anything leaves the machine.
10
- import { appendFileSync, readFileSync, statSync } from 'node:fs';
10
+ import { appendFileSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
12
  import { join } from 'node:path';
13
13
  /** The UI-action tools that move the screen (so follow mode can visibly perform them). */
14
- const NAV_TOOLS = new Set(['open_token', 'show_in_screener', 'switch_feed', 'add_to_watchlist', 'open_ticket']);
14
+ const NAV_TOOLS = new Set(['open_token', 'show_in_screener', 'switch_feed', 'switch_screen', 'add_to_watchlist', 'open_ticket']);
15
+ /**
16
+ * task-1443: the view-only UI tools an EXTERNAL agent may use to move a terminal that follows it over MCP. Nothing
17
+ * here changes user data or settings (no watchlist add, no ticket, no theme/workspace): the screen only moves.
18
+ */
19
+ export const FOLLOW_NAV_ACTIONS = ['open_token', 'show_in_screener', 'switch_feed', 'switch_screen'];
15
20
  function navFor(tool, args) {
16
21
  const s = (v) => (typeof v === 'string' ? v : undefined);
17
22
  if (tool === 'open_token')
@@ -19,7 +24,10 @@ function navFor(tool, args) {
19
24
  if (tool === 'switch_feed')
20
25
  return { screen: 'screener', feed: s(args.feed) };
21
26
  if (tool === 'show_in_screener')
22
- return { screen: 'screener', filter: s(args.filter), highlight: s(args.highlight) };
27
+ return { screen: 'screener', feed: s(args.preset), filter: s(args.filter), highlight: s(args.highlight) };
28
+ // task-1443: the task-1413 view-only screen switch moves a following terminal too (e.g. to positions).
29
+ if (tool === 'switch_screen')
30
+ return s(args.screen) ? { screen: s(args.screen) } : null;
23
31
  if (tool === 'add_to_watchlist')
24
32
  return { screen: 'watchlist', mint: s(args.mint) };
25
33
  if (tool === 'open_ticket')
@@ -46,6 +54,29 @@ function shortMint(args) {
46
54
  const m = typeof args.mint === 'string' ? args.mint : typeof args.symbol === 'string' ? args.symbol : '';
47
55
  return m ? ` ${m.length > 10 ? m.slice(0, 6) + '…' : m}` : '';
48
56
  }
57
+ /**
58
+ * task-1443: a readable, PAPER-labelled summary for an agent paper-trading call, so the activity panel says what
59
+ * the agent did to the simulated ledger (side, size, symbol), not just the tool name. null for any other tool.
60
+ */
61
+ export function paperSummary(tool, args, result) {
62
+ if (!tool.startsWith('paper_'))
63
+ return null;
64
+ const s = (v) => (typeof v === 'string' ? v.slice(0, 16) : '');
65
+ const t = (result.ticket ?? {});
66
+ const f = (result.fill ?? {});
67
+ switch (tool) {
68
+ case 'paper_open_ticket': return `PAPER open ${s(args.side)} ticket ${s(args.symbol)}`.trim();
69
+ case 'paper_size_ticket': return typeof t.usd === 'number' ? `PAPER size ${s(t.symbol)} $${t.usd.toFixed(2)}` : typeof t.fraction === 'number' ? `PAPER size ${s(t.symbol)} ${Math.round(t.fraction * 100)}%` : 'PAPER size';
70
+ case 'paper_confirm':
71
+ case 'paper_close': {
72
+ const sol = typeof f.solDelta === 'number' ? ` ${f.solDelta > 0 ? '+' : ''}${f.solDelta.toFixed(4)} SOL` : '';
73
+ return `PAPER ${tool === 'paper_close' ? 'close' : s(f.side) || 'fill'} ${s(f.symbol)}${sol}`;
74
+ }
75
+ case 'paper_positions': return `PAPER positions (${Array.isArray(result.positions) ? result.positions.length : 0})`;
76
+ case 'paper_pnl': return 'PAPER pnl';
77
+ default: return null;
78
+ }
79
+ }
49
80
  /** Build one spectator record for a completed MCP tool call. */
50
81
  export function recordForToolCall(opts) {
51
82
  const isErr = typeof opts.result.error === 'string';
@@ -55,11 +86,96 @@ export function recordForToolCall(opts) {
55
86
  // `nav` phase is the enforcement boundary that prevents a denied MCP call from moving the owner's TUI.
56
87
  v: 1, seq: opts.seq, t: (opts.now ?? Date.now)(), phase: isErr ? 'refused' : nav ? 'nav' : 'result',
57
88
  agent: opts.agent, transport: 'mcp', tool: opts.tool,
58
- summary: isErr ? `error: ${String(opts.result.error).slice(0, 80)}` : `${opts.tool}${shortMint(opts.args)}`,
89
+ summary: isErr ? `error: ${String(opts.result.error).slice(0, 80)}` : paperSummary(opts.tool, opts.args, opts.result) ?? `${opts.tool}${shortMint(opts.args)}`,
59
90
  ms: opts.ms, dataAgeSec: isErr ? null : maxAgeSec(opts.result), nav, ok: !isErr,
91
+ ...(() => { const d = detailFor(opts.tool, opts.args, opts.result); return d ? { detail: d } : {}; })(),
60
92
  ...(opts.token ? { token: opts.token } : {}),
61
93
  };
62
94
  }
95
+ const num = (x) => (typeof x === 'number' && Number.isFinite(x) ? x : null);
96
+ /** A firewalled field's inner text (`<untrusted …>X</untrusted>` → X; the inner text is already scrubbed upstream). */
97
+ const unwrap = (x) => x.replace(/^<untrusted\b[^>]*>([\s\S]*?)<\/untrusted>$/, '$1');
98
+ const str = (x, max = 16) => (typeof x === 'string' && x ? unwrap(x).slice(0, max) || null : null);
99
+ const MINT_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
100
+ const mintOf = (x) => (typeof x === 'string' && MINT_RE.test(x) ? x : null);
101
+ /**
102
+ * task-1466: the app-computed numbers behind one call, for the follow view (SpectatorDetail in @dexterm/agents).
103
+ * Copied from the tool's OWN result (a screener page, a verdict, a PAPER ticket / fill / position, the caps the
104
+ * desk replayed); agent-supplied args only name the coin. null when the call has nothing worth showing.
105
+ */
106
+ export function detailFor(tool, args, result) {
107
+ const d = {};
108
+ if (typeof result.error === 'string') {
109
+ if (tool.startsWith('paper_') && typeof result.reason === 'string')
110
+ d.refusal = result.reason.slice(0, 40);
111
+ return Object.keys(d).length ? d : null;
112
+ }
113
+ if (tool === 'screener_query' && Array.isArray(result.rows)) {
114
+ d.rows = result.rows.slice(0, 12).flatMap((r) => {
115
+ const mint = mintOf(r.mint);
116
+ if (!mint)
117
+ return [];
118
+ const pc = (r.priceChange ?? {});
119
+ return [{ mint, symbol: str(r.symbol) ?? '?', chg: num(pc.h1) }];
120
+ });
121
+ }
122
+ if (tool === 'screen_state' && Array.isArray(result.visible)) {
123
+ d.rows = result.visible.slice(0, 12).flatMap((r) => {
124
+ const mint = mintOf(r.mint);
125
+ return mint ? [{ mint, symbol: str(r.symbol) ?? '?', chg: num(r.chg1h) }] : [];
126
+ });
127
+ }
128
+ if (tool === 'get_token') {
129
+ const tk = (result.token ?? {});
130
+ const pair = (result.pair ?? {});
131
+ const mint = mintOf(tk.mint) ?? mintOf(args.mint);
132
+ if (mint)
133
+ d.coin = { mint, symbol: str(tk.symbol), priceUsd: num(pair.priceUsd) };
134
+ }
135
+ else if (tool === 'analyze_token') {
136
+ const mint = mintOf(result.mint) ?? mintOf(args.mint);
137
+ if (mint)
138
+ d.coin = { mint, symbol: str(result.symbol) };
139
+ const label = str(result.deterministic_verdict, 18);
140
+ if (label)
141
+ d.verdict = { label, rug: num(result.rug_probability) };
142
+ }
143
+ else if (tool === 'larp_check') {
144
+ const mint = mintOf(result.mint) ?? mintOf(args.mint);
145
+ if (mint)
146
+ d.coin = { mint, symbol: str(result.symbol) };
147
+ const flags = Array.isArray(result.flags) ? result.flags.filter((f) => typeof f === 'string') : [];
148
+ d.larp = { flags: flags.length, items: flags.slice(0, 6).map((f) => f.slice(0, 60)) };
149
+ }
150
+ else if (['open_token', 'get_trades', 'get_candles', 'get_holders', 'rugcheck', 'get_dev_history', 'dev_history', 'get_dossier'].includes(tool)) {
151
+ const mint = mintOf(args.mint);
152
+ if (mint)
153
+ d.coin = { mint, symbol: str(args.symbol) };
154
+ }
155
+ if (tool === 'paper_open_ticket' || tool === 'paper_size_ticket') {
156
+ const t = (result.ticket ?? {});
157
+ const mint = mintOf(t.mint);
158
+ if (mint && (t.side === 'buy' || t.side === 'sell'))
159
+ d.ticket = { side: t.side, symbol: str(t.symbol) ?? '?', mint, usd: num(t.usd), fraction: num(t.fraction) };
160
+ }
161
+ if (tool === 'paper_confirm' || tool === 'paper_close') {
162
+ const f = (result.fill ?? {});
163
+ const mint = mintOf(f.mint);
164
+ if (mint && (f.side === 'buy' || f.side === 'sell') && num(f.solDelta) !== null && num(f.tokens) !== null) {
165
+ d.fill = { side: f.side, symbol: str(f.symbol) ?? '?', mint, sol: num(f.solDelta), qty: num(f.tokens), priceUsd: num(f.priceUsd), impactPct: num(f.priceImpactPct), realizedSol: num(f.realizedSol) };
166
+ }
167
+ const p = (result.position ?? null);
168
+ const pm = p ? mintOf(p.mint) : null;
169
+ if (p && pm && num(p.tokens) !== null && num(p.costSol) !== null)
170
+ d.position = { mint: pm, symbol: str(p.symbol) ?? '?', qty: num(p.tokens), costSol: num(p.costSol), realizedSol: num(p.realizedSol) ?? 0 };
171
+ }
172
+ if (tool === 'paper_pnl') {
173
+ const m = (result.mandate ?? null);
174
+ if (m && num(m.perTradeUsd) !== null && num(m.totalUsd) !== null)
175
+ d.caps = { perTradeUsd: num(m.perTradeUsd), totalUsd: num(m.totalUsd), maxPriceImpactPct: num(m.maxPriceImpactPct) };
176
+ }
177
+ return Object.keys(d).length ? d : null;
178
+ }
63
179
  export function fileSpectatorSinkFromEnv(env) {
64
180
  const file = env.DEXTERM_SPECTATOR_FILE;
65
181
  if (!file)
@@ -88,6 +204,44 @@ function spectatorHome(env) {
88
204
  return env.DEXTERM_HOME && env.DEXTERM_HOME.trim() ? env.DEXTERM_HOME : join(homedir(), '.dexterm');
89
205
  }
90
206
  export function rendezvousPath(env) { return join(spectatorHome(env), MCP_SPECTATOR_RENDEZVOUS); }
207
+ // task-1443: an EXTERNAL `dexterm mcp` server has no DEXTERM_SCREEN_STATE (the running app did not spawn it), so it
208
+ // could never validate a navigation call against "the tokens currently on screen" and every nav tool failed — the
209
+ // following terminal never moved. While a terminal follows over MCP it publishes a reduced snapshot of its screen
210
+ // (screen, visible rows, selection, the action allow-list; no wallet, positions, tickets or app settings) here,
211
+ // refreshed about once a second; the server validates nav calls against it exactly as it does for the in-app analyst.
212
+ /** The follow-mode screen snapshot file name under ~/.dexterm. */
213
+ export const MCP_FOLLOW_SCREEN = 'mcp-screen.json';
214
+ /** A snapshot older than this is ignored (the terminal quit or froze): nav calls then fail closed. */
215
+ export const FOLLOW_SCREEN_MAX_AGE_MS = 10_000;
216
+ export function followScreenPath(env) { return join(spectatorHome(env), MCP_FOLLOW_SCREEN); }
217
+ /** Write the follow-mode snapshot (0600). Best-effort: a write failure only means nav calls fail closed. */
218
+ export function writeFollowScreen(env, snapshot) {
219
+ try {
220
+ writeFileSync(followScreenPath(env), JSON.stringify(snapshot) + '\n', { mode: 0o600 });
221
+ }
222
+ catch { /* fail closed */ }
223
+ }
224
+ /** Remove the follow-mode snapshot (the terminal quit or stopped following). */
225
+ export function clearFollowScreen(env) {
226
+ try {
227
+ rmSync(followScreenPath(env), { force: true });
228
+ }
229
+ catch { /* ignore */ }
230
+ }
231
+ /** Read the follow-mode screen snapshot, or null when absent, malformed or stale. Never throws. */
232
+ export function readFollowScreen(env, now = Date.now()) {
233
+ const path = followScreenPath(env);
234
+ try {
235
+ const st = statSync(path);
236
+ if (now - st.mtimeMs > FOLLOW_SCREEN_MAX_AGE_MS)
237
+ return null;
238
+ const o = JSON.parse(readFileSync(path, 'utf8'));
239
+ return o && typeof o === 'object' && !Array.isArray(o) ? o : null;
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ }
91
245
  /**
92
246
  * Resolve the spectator sink for a `dexterm mcp` server. DEXTERM_SPECTATOR_FILE (the running app spawned us,
93
247
  * e.g. the in-app analyst) wins and is always active; otherwise the sink watches the rendezvous file under
@@ -84,6 +84,7 @@ export function parseEntryFile(file, origin = 'bundled') {
84
84
  const MONEY_WORDS = new Set([
85
85
  'trade', 'swap', 'sign', 'signer', 'send', 'arm', 'ticket', 'order', 'transfer', 'withdraw', 'buy', 'sell',
86
86
  'confirm', 'execute', 'submit', 'panic', 'cap', 'caps', 'mandate', 'mandates', 'approve', 'pay', 'payment', 'fund',
87
+ 'close', // task-1443: paper_close books a (simulated) sell — a skill may never name it
87
88
  ]);
88
89
  /** Tools that exist in dexterm and are money-adjacent by name, refused even though the server knows them. */
89
90
  export const MONEY_TOOLS = ['open_ticket'];
@@ -18,5 +18,6 @@ export * from "./backtest.js";
18
18
  // task-1359: LARP eval (recorded product verdicts vs outcome labels; counts only below n = 50).
19
19
  export * from "./larp-eval.js";
20
20
  export * from "./skill-backtest.js";
21
+ export * from "./larp-score-backtest.js";
21
22
  // task-1414: skill care — rolling split, the proposal gate (new-coins + interval-noise + frozen canary), trend.
22
23
  export * from "./skill-care.js";
@@ -0,0 +1,119 @@
1
+ // LARP score backtest + calibration (task-1471). Pure: input is the task-1401 eval set (skills-eval.json).
2
+ //
3
+ // The eval set holds, per coin, the URL-free link features the coin advertised at t0 and the recorder's 72 h
4
+ // outcome (bad = rug or dead). That is the only part of the LARP score history can replay: pages, domains, GitHub
5
+ // and X accounts were not captured point-in-time (and looking them up now leaks the outcome, because dead coins'
6
+ // sites and accounts disappear). So this fits and scores the LINK-SHAPE flags only, and says so in every output.
7
+ //
8
+ // Pre-registered rule (written before the test split was scored):
9
+ // - fit on TRAIN only: for each link flag carried by ≥ LARP_SCORE_MIN_FLAG_N train coins,
10
+ // points = clamp(round(8 × ln OR), 0, 12), OR = Haldane-smoothed odds ratio of a bad outcome with vs without it;
11
+ // flags under that count keep their prior points;
12
+ // - bands are the fixed round thresholds 25 / 50 (LARP_SCORE_BANDS), never tuned;
13
+ // - report AUROC (ties count half) and P(bad) per band on train and on the held-out TEST split, with Wilson 95 %.
14
+ import { LARP_SCORE_BANDS, LARP_SCORE_WEIGHTS, bandOf, linkFlagIds, linkScore } from '../../data/src/index.js';
15
+ import { wilson } from "./larp-eval.js";
16
+ export const LARP_SCORE_BACKTEST_SCHEMA = 'dexterm/packs/larp-score-backtest/1';
17
+ export const LARP_SCORE_MIN_FLAG_N = 20;
18
+ const SCALE = 8;
19
+ const CAP = 12;
20
+ /** The link-shape flags the eval set can carry (in a stable order). */
21
+ export const LINK_FLAG_IDS = ['no_website', 'no_x', 'x_not_account', 'no_telegram', 'site_is_social', 'site_is_launchpad', 'site_linktree', 'site_free_host', 'domain_no_name_match', 'cheap_tld'];
22
+ /** Fit the link-flag points on train rows. Throws on a test row (the split is fixed before tuning). */
23
+ export function calibrateLinkPoints(train) {
24
+ if (train.some((r) => r.split !== 'train'))
25
+ throw new Error('calibrateLinkPoints: test rows are held out');
26
+ const N = train.length, B = train.filter((r) => r.bad).length;
27
+ return LINK_FLAG_IDS.map((id) => {
28
+ const has = train.filter((r) => linkFlagIds(r.link).includes(id));
29
+ const n = has.length, a = has.filter((r) => r.bad).length;
30
+ if (n < LARP_SCORE_MIN_FLAG_N)
31
+ return { id, n, bad: a, ln_or: null, points: LARP_SCORE_WEIGHTS[id].points, source: 'prior' };
32
+ const b = B - a, m = N - n;
33
+ const lnOr = Math.log(((a + 0.5) / (n - a + 0.5)) / ((b + 0.5) / (m - b + 0.5)));
34
+ return { id, n, bad: a, ln_or: Math.round(lnOr * 1000) / 1000, points: Math.max(0, Math.min(CAP, Math.round(SCALE * lnOr))), source: 'calibrated' };
35
+ });
36
+ }
37
+ /** AUROC with ties counted half. Null when a class is empty. */
38
+ export function auroc(rows) {
39
+ const pos = rows.filter((r) => r.bad).map((r) => r.score), neg = rows.filter((r) => !r.bad).map((r) => r.score);
40
+ if (!pos.length || !neg.length)
41
+ return null;
42
+ let s = 0;
43
+ for (const p of pos)
44
+ for (const q of neg)
45
+ s += p > q ? 1 : p === q ? 0.5 : 0;
46
+ return s / (pos.length * neg.length);
47
+ }
48
+ function splitReport(rows, weights) {
49
+ const scored = rows.map((r) => ({ score: linkScore(r.link, weights), bad: r.bad }));
50
+ const by_band = { low: { n: 0, bad: 0, rate: null }, medium: { n: 0, bad: 0, rate: null }, high: { n: 0, bad: 0, rate: null } };
51
+ for (const s of scored) {
52
+ const b = by_band[bandOf(s.score)];
53
+ b.n++;
54
+ if (s.bad)
55
+ b.bad++;
56
+ }
57
+ for (const b of Object.values(by_band))
58
+ b.rate = b.n >= 20 ? wilson(b.bad, b.n) : null;
59
+ const B = scored.filter((s) => s.bad).length;
60
+ const at_threshold = [LARP_SCORE_BANDS.medium, LARP_SCORE_BANDS.high].map((t) => {
61
+ const f = scored.filter((s) => s.score >= t), tp = f.filter((s) => s.bad).length;
62
+ return { threshold: t, flagged: f.length, precision: f.length >= 20 ? wilson(tp, f.length) : null, recall: B ? wilson(tp, B) : null };
63
+ });
64
+ const sorted = scored.map((s) => s.score).sort((a, b) => a - b);
65
+ const q = (p) => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))] : 0;
66
+ const a = auroc(scored);
67
+ return { n: rows.length, bad: B, auroc: a === null ? null : Math.round(a * 1000) / 1000, by_band, at_threshold, score_quantiles: { p10: q(0.1), p50: q(0.5), p90: q(0.9), max: sorted.at(-1) ?? 0 } };
68
+ }
69
+ /** Fit on train, score train and test with the fitted points. */
70
+ export function larpScoreBacktest(set) {
71
+ const train = set.rows.filter((r) => r.split === 'train'), test = set.rows.filter((r) => r.split === 'test');
72
+ const fit = calibrateLinkPoints(train);
73
+ const weights = Object.fromEntries(fit.map((f) => [f.id, f.points]));
74
+ return {
75
+ schema: LARP_SCORE_BACKTEST_SCHEMA,
76
+ eval: { built_at: set.built_at, horizon_sec: set.horizon_sec, rows: set.rows.length, label_rule: set.label_rule, split_rule: set.split_rule },
77
+ part: 'link-shape flags only (what the eval set holds point-in-time)',
78
+ method: [
79
+ `fit on the train split only (${train.length} coins): points = clamp(round(${SCALE} × ln odds ratio of a bad outcome), 0, ${CAP}) for a flag on ≥ ${LARP_SCORE_MIN_FLAG_N} train coins; rarer flags keep their prior points`,
80
+ `bands fixed before scoring: low < ${LARP_SCORE_BANDS.medium} ≤ medium < ${LARP_SCORE_BANDS.high} ≤ high (not tuned)`,
81
+ 'AUROC with ties counted half; rates with 95% Wilson intervals, printed only for n ≥ 20',
82
+ 'meaning: how well the link-shape points rank coins that went bad (a price outcome) above the rest, NOT whether a product is real',
83
+ ],
84
+ fit,
85
+ shipped_matches_fit: fit.every((f) => LARP_SCORE_WEIGHTS[f.id].points === f.points && LARP_SCORE_WEIGHTS[f.id].source === f.source),
86
+ train: splitReport(train, weights),
87
+ test: splitReport(test, weights),
88
+ not_measured: [
89
+ 'website page flags (default page, wallet connect, drainer markers, never names the token): pages were not captured at t0',
90
+ 'domain flags (new domain, registrant privacy) and GitHub flags: not captured at t0',
91
+ "X-account flags (new account, tiny following, no posts, suspended, handle mismatch): not captured at t0, and a lookup today leaks the outcome (dead coins' accounts get deleted or suspended); the X sources' robots.txt also rule out bulk crawling them for a backfill",
92
+ 'the high band: link shape alone tops out below it by design, so its precision is unmeasured',
93
+ ],
94
+ };
95
+ }
96
+ const pct = (r) => (r === null ? 'counts only (n < 20)' : `${(r.value * 100).toFixed(1)}% [${(r.lo * 100).toFixed(0)}–${(r.hi * 100).toFixed(0)}]`);
97
+ export function renderLarpScoreBacktestText(r) {
98
+ const L = [];
99
+ L.push(`LARP SCORE BACKTEST ${r.part}`);
100
+ L.push(` eval set built ${r.eval.built_at}, ${r.eval.rows} coins, horizon ${r.eval.horizon_sec}s; ${r.eval.label_rule}`);
101
+ L.push(' fitted points (train split):');
102
+ for (const f of r.fit)
103
+ L.push(` ${f.id.padEnd(22)} n=${String(f.n).padStart(4)} bad=${String(f.bad).padStart(4)} ln OR=${f.ln_or === null ? ' — ' : f.ln_or.toFixed(2).padStart(6)} → ${String(f.points).padStart(2)} pts (${f.source})`);
104
+ L.push(` shipped weights match this fit: ${r.shipped_matches_fit ? 'yes' : 'NO (re-tune or update LARP_SCORE_WEIGHTS)'}`);
105
+ for (const [name, s] of [['train', r.train], ['TEST (held out)', r.test]]) {
106
+ L.push(` ${name}: n=${s.n} bad=${s.bad} (${((s.bad / Math.max(1, s.n)) * 100).toFixed(1)}%) AUROC=${s.auroc === null ? '—' : s.auroc.toFixed(3)} score p10/p50/p90/max=${s.score_quantiles.p10}/${s.score_quantiles.p50}/${s.score_quantiles.p90}/${s.score_quantiles.max}`);
107
+ for (const [b, v] of Object.entries(s.by_band))
108
+ L.push(` band ${b.padEnd(6)} n=${String(v.n).padStart(4)} bad=${String(v.bad).padStart(4)} P(bad)=${pct(v.rate)}`);
109
+ for (const t of s.at_threshold)
110
+ L.push(` score ≥ ${t.threshold}: flagged ${t.flagged} precision=${pct(t.precision)} recall=${t.recall ? pct(t.recall) : '—'}`);
111
+ }
112
+ L.push(' method:');
113
+ for (const m of r.method)
114
+ L.push(` - ${m}`);
115
+ L.push(' not measured:');
116
+ for (const m of r.not_measured)
117
+ L.push(` - ${m}`);
118
+ return L.join('\n') + '\n';
119
+ }
@@ -180,22 +180,43 @@
180
180
  if (a >= 1e3) return `${s}$${(a / 1e3).toFixed(1)}K`;
181
181
  return `${s}$${a.toFixed(0)}`;
182
182
  }
183
- // ---- task-1366 market cap (mirrors @dexterm/tui fmtMcapBare: K/M/B/T, ≤ 3 significant digits, never 1000K)
184
- const MC_UNITS = ['', 'K', 'M', 'B', 'T'];
185
- function fmtMcapAxis(n) {
183
+ // ---- task-1472 the one chart number formatter (mirrors @dexterm/tui fmtCompact, parity-tested): lowercase k, then
184
+ // M/B/T; one decimal while < 10 of the unit, none after; the unit is promoted after rounding (999 900 → 1M); below 1
185
+ // the subscript price form (0.0₅493): never scientific notation. `extra` (0..3) keeps two axis ticks apart.
186
+ const CU = ['', 'k', 'M', 'B', 'T'];
187
+ const trimZeros = (t) => (t.includes('.') ? t.replace(/0+$/, '').replace(/\.$/, '') : t);
188
+ function tinyBare(p) {
189
+ if (p >= 0.01) return p.toFixed(4);
190
+ const m = p.toFixed(12).match(/^0\.(0*)(\d+)/);
191
+ if (!m) return String(p);
192
+ const zeros = m[1].length;
193
+ const digits = m[2].replace(/0+$/, '').slice(0, 4) || '0';
194
+ if (zeros < 3) return `0.${m[1]}${digits}`;
195
+ return `0.0${String(zeros).split('').map((c) => SUB[Number(c)]).join('')}${digits}`;
196
+ }
197
+ function fmtCompact(n, extra = 0) {
186
198
  if (!fin(n)) return '—';
199
+ if (n === 0) return '0';
187
200
  const sign = n < 0 ? '-' : '';
188
- let a = Math.abs(n);
189
- if (a === 0) return '0';
190
- if (a < 1) return sign + String(Number(a.toPrecision(2)));
191
- let u = 0;
192
- while (u < MC_UNITS.length - 1 && a >= 1000) { a /= 1000; u++; }
193
- let r = Number(a.toPrecision(3));
194
- if (r >= 1000 && u < MC_UNITS.length - 1) { r = Number((r / 1000).toPrecision(3)); u++; }
195
- return `${sign}${r >= 1000 ? String(Math.round(r)) : String(r)}${MC_UNITS[u]}`;
201
+ const a = Math.abs(n);
202
+ if (a < 1) return sign + trimZeros(tinyBare(a));
203
+ const x = Math.max(0, Math.min(3, Math.floor(extra) || 0));
204
+ const round = (q) => Number(q.toFixed((q < 10 ? 1 : 0) + x));
205
+ let u = 0, v = a;
206
+ while (u < CU.length - 1 && v >= 1000) { v /= 1000; u++; }
207
+ let r = round(v);
208
+ if (r >= 1000 && u < CU.length - 1) { u++; r = round(r / 1000); }
209
+ return `${sign}${trimZeros(r.toFixed((r < 10 ? 1 : 0) + x))}${CU[u]}`;
210
+ }
211
+ /** `$4.9k`, `-$1.2k` (volume pane, legend volume). */
212
+ function fmtCompactUsd(n, extra = 0) {
213
+ const b = fmtCompact(n, extra);
214
+ return b === '—' ? b : b.startsWith('-') ? `-$${b.slice(1)}` : `$${b}`;
196
215
  }
216
+ /** task-1366 name kept: the mcap axis label is now the compact formatter. */
217
+ const fmtMcapAxis = (n) => fmtCompact(n);
197
218
  const MCAP_LABEL = 'mcap (current supply)';
198
- const MCAP_FALLBACK = 'supply unknown · showing price';
219
+ const MCAP_FALLBACK = 'price (mcap unavailable)'; // task-1472: the card's wording (was 'supply unknown · showing price')
199
220
  /**
200
221
  * How the page shows numbers for a VM: `mcap` (with the supply multiplier) only when the terminal is in mcap mode AND
201
222
  * sent a usable supply; mcap mode without one is `price` + a visible note. Never invents a supply.
@@ -207,7 +228,7 @@
207
228
  if (supply) return { mode: 'mcap', supply, label: MCAP_LABEL, key: `mcap:${Number(supply.toPrecision(4))}` };
208
229
  return { mode: 'price', supply: null, label: wantMcap ? MCAP_FALLBACK : null, key: wantMcap ? 'price:fallback' : 'price' };
209
230
  }
210
- /** The Y-axis / crosshair label formatter for a display: mcap = price × supply in K/M/B, else the price label. */
231
+ /** The Y-axis / crosshair label formatter for a display: mcap = price × supply in compact k/M/B, else the price label. */
211
232
  function axisFormatter(d) {
212
233
  if (d && d.mode === 'mcap' && fin(d.supply)) { const s = d.supply; return (p) => fmtMcapAxis(p * s); }
213
234
  return fmtAxis;
@@ -225,7 +246,7 @@
225
246
  const chg = base > 0 ? (bar.close - base) / base : null;
226
247
  const f = axisFormatter(display);
227
248
  const mc = !!display && display.mode === 'mcap';
228
- return { o: f(bar.open), h: f(bar.high), l: f(bar.low), c: mc ? `${f(bar.close)} (${fmtPrice(bar.close)})` : f(bar.close), v: fmtUsd(bar.volume), chg: fmtPct(chg), up: bar.close >= bar.open };
249
+ return { o: f(bar.open), h: f(bar.high), l: f(bar.low), c: mc ? `${f(bar.close)} (${fmtPrice(bar.close)})` : f(bar.close), v: fmtCompactUsd(bar.volume), chg: fmtPct(chg), up: bar.close >= bar.open };
229
250
  }
230
251
 
231
252
  /** Index of the bar at `time` (binary search), or -1. */
@@ -257,6 +278,6 @@
257
278
  return bars[i].time + f * (bars[i + 1].time - bars[i].time);
258
279
  }
259
280
 
260
- const api = { setTzOffset, chartTime, INTERVALS, PERIOD_MS, INDICATORS, STYLES, SCALES, toBars, mergeTail, ema, sma, vwap, volumes, barTimeOf, tapeMarkers, fillMarkers, priceFormatFor, fmtPrice, fmtAxis, fmtUsd, fmtPct, fmtSol, legendFor, fmtMcapAxis, displayOf, axisFormatter, MCAP_LABEL, MCAP_FALLBACK, indexOfTime, logicalOf, timeOfLogical };
281
+ const api = { setTzOffset, chartTime, INTERVALS, PERIOD_MS, INDICATORS, STYLES, SCALES, toBars, mergeTail, ema, sma, vwap, volumes, barTimeOf, tapeMarkers, fillMarkers, priceFormatFor, fmtPrice, fmtAxis, fmtUsd, fmtPct, fmtSol, legendFor, fmtMcapAxis, fmtCompact, fmtCompactUsd, displayOf, axisFormatter, MCAP_LABEL, MCAP_FALLBACK, indexOfTime, logicalOf, timeOfLogical };
261
282
  root.DTChart = Object.freeze(api);
262
283
  })(typeof window !== 'undefined' ? window : globalThis);
@@ -48,3 +48,12 @@
48
48
  /* LWC attribution logo: the library injects this exact rule too (allowed by CSP hash); repeated here so the
49
49
  logo stays positioned even if a future build changes its inline style text. */
50
50
  a#tv-attr-logo { position: absolute; left: 10px; bottom: 10px; height: 19px; width: 35px; margin: 0; padding: 0; border: 0; z-index: 3; }
51
+ /* task-1473 r2: the persistent POSITION PnL chip (the held coin, which need not be the charted coin). Display only. */
52
+ .pnlchip { display: flex; flex-wrap: nowrap; gap: 0; padding: 2px 10px; font-size: 11px; white-space: nowrap; overflow: hidden; border-bottom: 1px solid var(--border); background: rgba(255,45,240,.07); color: var(--muted); }
53
+ .pnlchip .seg + .seg::before { content: ' │ '; color: var(--border); }
54
+ .pnlchip .head { color: var(--pump); font-weight: 700; }
55
+ .pnlchip .pnl { font-weight: 700; }
56
+ .pnlchip .pnl.up, .pnlchip .peak.up { color: var(--up); } .pnlchip .pnl.down, .pnlchip .peak.down { color: var(--down); }
57
+ .pnlchip .dd { color: var(--warn); }
58
+ .pnlchip .pnl.flash { background: var(--up); color: var(--bg, #000); padding: 0 3px; border-radius: 2px; }
59
+ @media (prefers-reduced-motion: reduce) { .pnlchip .pnl.flash { background: none; color: var(--up); padding: 0; } }
@@ -23,6 +23,8 @@
23
23
  <div id="toolbar" class="toolbar">
24
24
  <span class="grp" id="tf"></span>
25
25
  <span class="sep"></span>
26
+ <span class="grp" id="axis"></span>
27
+ <span class="sep"></span>
26
28
  <span class="grp" id="styles"></span>
27
29
  <span class="sep"></span>
28
30
  <span class="grp" id="scales"></span>
@@ -31,6 +33,7 @@
31
33
  <span class="sep"></span>
32
34
  <span class="grp" id="draws"></span>
33
35
  </div>
36
+ <div id="pnlchip" class="pnlchip" hidden title="position PnL (display only)"></div>
34
37
  <div id="banner" class="banner" hidden></div>
35
38
  <div class="stage">
36
39
  <canvas id="rain"></canvas>
@@ -44,7 +47,7 @@
44
47
  <div id="pos-cells" class="pos-cells"></div>
45
48
  </div>
46
49
  <div id="foot" class="foot">
47
- <span class="keys">1–6 tf · c style · a/l/p scale · e ema · w vwap · m vol·ma · v vol · b tape · h/t draw · f fit · esc close</span>
50
+ <span class="keys">1–6 tf · A/y mcap⇄price · c style · a/l/p scale · e ema · w vwap · m vol·ma · v vol · b tape · h/t draw · f fit · esc close</span>
48
51
  <span class="attr" title="TradingView Lightweight Charts v__LWC_VERSION__ (Apache-2.0), self-hosted"><span id="notice">__LWC_NOTICE__</span> · <a id="tv-link" href="https://www.tradingview.com/" target="_blank" rel="noopener noreferrer">https://www.tradingview.com/</a></span>
49
52
  </div>
50
53
  </div>
@@ -12,6 +12,7 @@
12
12
  const $ = (id) => document.getElementById(id);
13
13
  const titleEl = $('title'), legendEl = $('legend'), liveEl = $('live'), bannerEl = $('banner'), hintEl = $('hint');
14
14
  const posEl = $('pos'), posTitleEl = $('pos-title'), posCapEl = $('pos-cap'), posCellsEl = $('pos-cells');
15
+ const chipEl = $('pnlchip');
15
16
  const imgSlot = $('coin-img-slot');
16
17
  const chartEl = $('chart'), drawCv = $('draw'), dctx = drawCv.getContext('2d');
17
18
  const STALE_MS = 20_000;
@@ -57,7 +58,7 @@
57
58
  if (type === 2) return String(d.getUTCDate());
58
59
  return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
59
60
  }
60
- const volFmt = (v) => C.fmtUsd(v);
61
+ const volFmt = (v) => C.fmtCompactUsd(v); // task-1472: volume stays USD, compact k/M
61
62
 
62
63
  // ---------------------------------------------------------------- chart + series
63
64
  const chart = L.createChart(chartEl, {
@@ -80,7 +81,7 @@
80
81
  handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: false },
81
82
  handleScale: { axisPressedMouseMove: { time: true, price: true }, axisDoubleClickReset: true, mouseWheel: true, pinch: true },
82
83
  });
83
- // task-1366: the axis formatter follows the terminal's price mode (mcap = price × the current supply, K/M/B)
84
+ // task-1366/1472: the axis formatter follows the terminal's chart axis (mcap = price × the current supply, compact k/M/B)
84
85
  const pricePF = (p) => ({ type: 'custom', formatter: C.axisFormatter(S.display), minMove: C.priceFormatFor(p).minMove });
85
86
  // Keep the position levels (entry / stop / target) and the live price in view: autoscale widens to include them
86
87
  // when they are within 3× of the last close (a far-away level would squash the candles; its row cell still shows it).
@@ -159,13 +160,16 @@
159
160
  DT.onTheme(applyChartTheme);
160
161
 
161
162
  // ---------------------------------------------------------------- toolbar (buttons built with textContent only)
162
- const btns = { tf: new Map(), styles: new Map(), scales: new Map(), inds: new Map(), draws: new Map() };
163
+ const btns = { tf: new Map(), axis: new Map(), styles: new Map(), scales: new Map(), inds: new Map(), draws: new Map() };
163
164
  function mkBtn(group, id, label, title, onClick) {
164
165
  const b = document.createElement('button'); b.type = 'button'; b.className = 'tb'; b.textContent = label; b.title = title;
165
166
  b.addEventListener('click', (e) => { e.preventDefault(); onClick(); });
166
167
  $(group).appendChild(b); btns[group].set(id, b); return b;
167
168
  }
168
169
  C.INTERVALS.forEach((iv, i) => mkBtn('tf', iv, iv, `timeframe ${iv} (${i + 1})`, () => requestInterval(iv)));
170
+ // task-1472: MCAP | PRICE asks the terminal to switch (and save) the chart axis; the next VM re-labels the page
171
+ mkBtn('axis', 'mcap', 'MCAP', 'Y axis: market cap (A / y toggles; saved by the terminal)', () => setAxis('mcap'));
172
+ mkBtn('axis', 'price', 'PRICE', 'Y axis: USD price (A / y toggles; saved by the terminal)', () => setAxis('price'));
169
173
  mkBtn('styles', 'candles', 'candles', 'candlesticks (c cycles)', () => setStyle('candles', true));
170
174
  mkBtn('styles', 'line', 'line', 'line (c cycles)', () => setStyle('line', true));
171
175
  mkBtn('styles', 'area', 'area', 'area (c cycles)', () => setStyle('area', true));
@@ -183,6 +187,8 @@
183
187
  mkBtn('draws', 'x', '✕ clear', 'clear drawings (x; backspace = undo last)', () => clearDrawings());
184
188
  function syncToolbar() {
185
189
  for (const [iv, b] of btns.tf) { b.classList.toggle('on', iv === S.interval); b.classList.toggle('pending', iv === S.pendingInterval && iv !== S.interval); }
190
+ const ax = S.vm && S.vm.priceMode === 'mcap' ? 'mcap' : 'price';
191
+ for (const [k, b] of btns.axis) b.classList.toggle('on', k === ax);
186
192
  for (const [k, b] of btns.styles) b.classList.toggle('on', k === S.style);
187
193
  for (const [k, b] of btns.scales) b.classList.toggle('on', k === S.scale);
188
194
  for (const [k, b] of btns.inds) b.classList.toggle('on', S.ind.has(k));
@@ -196,6 +202,11 @@
196
202
  S.pendingInterval = iv; S.reqAt = performance.now(); syncToolbar();
197
203
  DT.send({ type: 'event', name: 'chart.interval', payload: { interval: iv } });
198
204
  }
205
+ /** task-1472: ask the terminal for a Y axis; nothing changes here until its next VM carries the new mode. */
206
+ function setAxis(axis) {
207
+ if (axis !== 'mcap' && axis !== 'price') return;
208
+ DT.send({ type: 'event', name: 'chart.axis', payload: { axis } });
209
+ }
199
210
  function setStyle(st, user) {
200
211
  if (!C.STYLES.includes(st) || st === S.style) return;
201
212
  S.style = st;
@@ -397,6 +408,23 @@
397
408
  }
398
409
  }
399
410
 
411
+ // task-1473 r2: the POSITION PnL chip (formatted terminal-side; textContent only, display only)
412
+ function renderChip() {
413
+ const chip = S.vm && S.vm.chip;
414
+ if (!chipEl) return;
415
+ if (!chip || !Array.isArray(chip.segments) || !chip.segments.length) { chipEl.hidden = true; return; }
416
+ chipEl.hidden = false;
417
+ while (chipEl.firstChild) chipEl.removeChild(chipEl.firstChild);
418
+ const tone = ['up', 'down'].includes(chip.tone) ? chip.tone : '';
419
+ for (const sg of chip.segments.slice(0, 8)) {
420
+ const id = ['head', 'pnl', 'peak', 'px', 'held', 'dd', 'tp'].includes(sg.id) ? sg.id : 'x';
421
+ let cls = `seg ${id}`;
422
+ if (id === 'pnl') cls += ` ${tone}${chip.newPeak ? ' flash' : ''}`;
423
+ if (id === 'peak') cls += ` ${/^peak −/.test(String(sg.text)) ? 'down' : 'up'}`;
424
+ chipEl.appendChild(el('span', cls, String(sg.text).slice(0, 48)));
425
+ }
426
+ }
427
+
400
428
  // ---------------------------------------------------------------- incoming view models
401
429
  function onData(vm) {
402
430
  if (!vm || typeof vm !== 'object') return;
@@ -409,7 +437,7 @@
409
437
  // task-1366: a mode / supply change re-labels the axis in place (the bars are prices either way)
410
438
  const disp = C.displayOf(vm);
411
439
  if (disp.key !== S.display.key) { S.display = disp; applyPriceFormat(); }
412
- if (tail && (!vm.seriesKey || vm.seriesKey !== S.key)) { S.vm = { ...vm, candles: [] }; renderHeader(); renderPosition(); renderOverlays(); return; }
440
+ if (tail && (!vm.seriesKey || vm.seriesKey !== S.key)) { S.vm = { ...vm, candles: [] }; renderHeader(); renderPosition(); renderChip(); renderOverlays(); return; }
413
441
  const intervalChanged = vm.interval !== S.interval;
414
442
  S.interval = vm.interval;
415
443
  const answered = S.pendingInterval === vm.interval ? S.reqAt : null;
@@ -425,7 +453,7 @@
425
453
  if (intervalChanged || S.key !== prevKey) S.fitNext = true;
426
454
  renderFull();
427
455
  }
428
- renderHeader(); renderLegend(S.hover); renderDataBanner(); renderPosition(); syncToolbar(); renderLive();
456
+ renderHeader(); renderLegend(S.hover); renderDataBanner(); renderPosition(); renderChip(); syncToolbar(); renderLive();
429
457
  // rain mood follows direction (gold on a pump, red-shift on a dump), as in the v1 HUD; chg is in percent
430
458
  const dir = vm.chg ? (vm.chg.h24 ?? vm.chg.h1 ?? 0) : 0;
431
459
  if (rain) { rain.setMood(dir > 1 ? 'rainPump' : dir < -1 ? 'rainDump' : 'rain'); rain.setActivity(Math.min(1, 0.4 + Math.abs(dir) / 100)); rain.setSources([String(vm.symbol || ''), String(vm.mint || '').slice(0, 6)]); }
@@ -514,6 +542,7 @@
514
542
  const n = parseInt(k, 10);
515
543
  if (n >= 1 && n <= 6) { requestInterval(C.INTERVALS[n - 1]); return; }
516
544
  switch (k) {
545
+ case 'A': case 'y': setAxis(S.vm && S.vm.priceMode === 'mcap' ? 'price' : 'mcap'); break; // task-1472
517
546
  case 'c': setStyle(C.STYLES[(C.STYLES.indexOf(S.style) + 1) % C.STYLES.length], true); break;
518
547
  case 'a': setScale('auto', true); break;
519
548
  case 'l': setScale(S.scale === 'log' ? 'auto' : 'log', true); break;
@@ -43,3 +43,48 @@
43
43
  .spec-act-age { color: var(--warn); }
44
44
 
45
45
  .spec-foot-redact { color: var(--up); font-weight: 600; }
46
+
47
+ /* task-1466 spectator v2: the agent story (phase rail + heartbeat, thought ribbon, call timeline, vetting + PAPER cards) */
48
+ .spec-story { display: flex; flex-direction: column; gap: 8px; margin-bottom: 10px; font: 12px ui-monospace, Menlo, monospace; }
49
+ .spec-rail { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
50
+ .spec-heart { width: 10px; height: 10px; border-radius: 50%; background: var(--down); margin-right: 6px; box-shadow: 0 0 8px var(--down); animation: spec-beat 1s infinite ease-out; }
51
+ .spec-heart.still { animation: none; background: var(--warn); box-shadow: none; }
52
+ .spec-heart.dead { animation: none; background: var(--dim); box-shadow: none; }
53
+ @keyframes spec-beat { 0% { transform: scale(1.35); opacity: 1; } 30% { transform: scale(0.9); opacity: 0.7; } 100% { transform: scale(1); opacity: 0.85; } }
54
+ .spec-bpm { color: var(--muted); margin-right: 8px; min-width: 44px; }
55
+ .spec-phase { padding: 2px 8px; border-radius: 4px; color: var(--dim); border: 1px solid transparent; letter-spacing: 0.05em; }
56
+ .spec-phase.done { color: var(--muted); }
57
+ .spec-phase.on { color: #000; background: var(--accent); font-weight: 700; box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 60%, transparent); }
58
+ .spec-phase.on.p-vetting { background: var(--borderFocus); box-shadow: 0 0 12px color-mix(in srgb, var(--borderFocus) 60%, transparent); }
59
+ .spec-phase.on.p-sizing { background: var(--warn); }
60
+ .spec-phase.on.p-executing { background: var(--up); }
61
+ .spec-phase.on.p-holding { background: var(--pump); }
62
+ .spec-phase.on.p-exiting { background: var(--ai, var(--accent)); }
63
+ .spec-sep { color: var(--border); }
64
+ .spec-paper { margin-left: auto; padding: 1px 6px; border-radius: 4px; background: var(--warn); color: #000; font-weight: 700; }
65
+ .spec-ctlstate { padding: 1px 6px; border-radius: 4px; font-weight: 700; color: #000; }
66
+ .spec-ctlstate.paused { background: var(--warn); }
67
+ .spec-ctlstate.takeover { background: var(--accent); }
68
+ .spec-ctlstate.killed { background: var(--down); color: #fff; }
69
+ .spec-ribbon { padding: 6px 8px; border-left: 3px solid var(--ai, var(--accent)); background: color-mix(in srgb, var(--panel) 85%, transparent); color: var(--text); min-height: 1.4em; }
70
+ .spec-ribbon.ours { border-left-color: var(--border); color: var(--muted); font-style: italic; }
71
+ .spec-ribbon-who { color: var(--ai, var(--accent)); margin-right: 6px; font-style: normal; }
72
+ .spec-timeline { display: flex; align-items: flex-start; gap: 0; overflow: hidden; justify-content: flex-end; }
73
+ .spec-node { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 0 2px; }
74
+ .spec-node-l { color: var(--text); white-space: nowrap; }
75
+ .spec-node.paper .spec-node-l { color: var(--warn); font-weight: 700; }
76
+ .spec-chip { font-size: 10px; padding: 0 5px; border-radius: 3px; background: color-mix(in srgb, var(--up) 18%, transparent); color: var(--up); }
77
+ .spec-chip.slow { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); }
78
+ .spec-chip.fail, .spec-chip.refused { background: color-mix(in srgb, var(--down) 22%, transparent); color: var(--down); }
79
+ .spec-link { color: var(--border); align-self: flex-start; margin-top: 1px; }
80
+ .spec-cards { display: flex; flex-wrap: wrap; gap: 8px; }
81
+ .spec-card { flex: 1 1 220px; border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; background: color-mix(in srgb, var(--panel) 85%, transparent); }
82
+ .spec-card-h { color: #000; background: var(--borderFocus); font-weight: 700; padding: 0 6px; border-radius: 3px; display: inline-block; margin-bottom: 4px; }
83
+ .spec-card.trade .spec-card-h { background: var(--pump); }
84
+ .spec-check { margin-right: 8px; color: var(--dim); }
85
+ .spec-check.ok { color: var(--up); font-weight: 600; }
86
+ .spec-big { font-size: 30px; font-weight: 800; letter-spacing: 0.02em; line-height: 1.1; }
87
+ .spec-big.up { color: var(--up); text-shadow: 0 0 14px color-mix(in srgb, var(--up) 45%, transparent); }
88
+ .spec-big.down { color: var(--down); }
89
+ .spec-kv { color: var(--muted); }
90
+ .spec-kv b { color: var(--text); font-weight: 600; }