dexterm-cli 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/dist/packages/agents/src/spectator.js +28 -2
  2. package/dist/packages/agents/src/tiers.js +3 -0
  3. package/dist/packages/agents/src/tools.js +3 -0
  4. package/dist/packages/autotrade/src/backtest.js +6 -2
  5. package/dist/packages/autotrade/src/chat/connector.js +5 -4
  6. package/dist/packages/autotrade/src/chat/control.js +3 -0
  7. package/dist/packages/autotrade/src/chat/hub.js +37 -2
  8. package/dist/packages/autotrade/src/chat/index.js +3 -2
  9. package/dist/packages/autotrade/src/chat/readonly.js +530 -0
  10. package/dist/packages/autotrade/src/chat/service.js +12 -1
  11. package/dist/packages/autotrade/src/engine.js +75 -29
  12. package/dist/packages/autotrade/src/fleet.js +2 -2
  13. package/dist/packages/autotrade/src/index.js +4 -0
  14. package/dist/packages/autotrade/src/rugproof-probe.js +415 -0
  15. package/dist/packages/autotrade/src/rugproof.js +231 -0
  16. package/dist/packages/autotrade/src/runner.js +203 -30
  17. package/dist/packages/autotrade/src/service-install.js +6 -4
  18. package/dist/packages/autotrade/src/sessions.js +16 -7
  19. package/dist/packages/autotrade/src/store.js +1 -1
  20. package/dist/packages/autotrade/src/strategy-stats.js +344 -0
  21. package/dist/packages/autotrade/src/strategy.js +29 -1
  22. package/dist/packages/autotrade/src/tiers.js +194 -0
  23. package/dist/packages/classifier/src/calibrate.js +138 -0
  24. package/dist/packages/classifier/src/classify.js +107 -0
  25. package/dist/packages/classifier/src/config.js +298 -0
  26. package/dist/packages/classifier/src/dossier.js +95 -0
  27. package/dist/packages/classifier/src/hardware.js +63 -0
  28. package/dist/packages/classifier/src/index.js +19 -0
  29. package/dist/packages/classifier/src/models.js +111 -0
  30. package/dist/packages/classifier/src/providers/heuristic.js +77 -0
  31. package/dist/packages/classifier/src/providers/http.js +63 -0
  32. package/dist/packages/classifier/src/providers/jev-open.js +86 -0
  33. package/dist/packages/classifier/src/providers/llm.js +89 -0
  34. package/dist/packages/classifier/src/providers/systemone-http.js +95 -0
  35. package/dist/packages/classifier/src/questions.js +97 -0
  36. package/dist/packages/classifier/src/registry.js +42 -0
  37. package/dist/packages/classifier/src/snapshot.js +31 -0
  38. package/dist/packages/classifier/src/systemone.js +88 -0
  39. package/dist/packages/classifier/src/types.js +18 -0
  40. package/dist/packages/classifier/src/win-acl.js +71 -0
  41. package/dist/packages/cli/assets/dash/dash.css +139 -0
  42. package/dist/packages/cli/assets/dash/dash.js +200 -0
  43. package/dist/packages/cli/assets/dash/icon.svg +1 -0
  44. package/dist/packages/cli/assets/dash/index.html +36 -0
  45. package/dist/packages/cli/src/app-help-gen.js +13 -1
  46. package/dist/packages/cli/src/autotrade-cmd/fleet.js +12 -3
  47. package/dist/packages/cli/src/autotrade-cmd/run.js +14 -2
  48. package/dist/packages/cli/src/autotrade-cmd/service.js +3 -1
  49. package/dist/packages/cli/src/autotrade-cmd/signal.js +2 -2
  50. package/dist/packages/cli/src/autotrade-cmd/strategies.js +1 -1
  51. package/dist/packages/cli/src/autotrade-cmd/telegram-host.js +2 -0
  52. package/dist/packages/cli/src/classifier-cmd.js +361 -0
  53. package/dist/packages/cli/src/classifier-live.js +81 -0
  54. package/dist/packages/cli/src/dash/data.js +217 -0
  55. package/dist/packages/cli/src/dash/server.js +92 -0
  56. package/dist/packages/cli/src/dash-cmd.js +113 -0
  57. package/dist/packages/cli/src/doctor.js +34 -0
  58. package/dist/packages/cli/src/live/screens/token-live.js +4 -0
  59. package/dist/packages/cli/src/live/signals.js +8 -0
  60. package/dist/packages/cli/src/main.js +98 -6
  61. package/dist/packages/cli/src/mcp-follow.js +21 -1
  62. package/dist/packages/cli/src/narratives-cmd.js +147 -0
  63. package/dist/packages/cli/src/narratives.js +114 -0
  64. package/dist/packages/cli/src/overlay/model.js +147 -0
  65. package/dist/packages/cli/src/overlay/page.js +146 -0
  66. package/dist/packages/cli/src/overlay/pnl.js +75 -0
  67. package/dist/packages/cli/src/overlay/server.js +170 -0
  68. package/dist/packages/cli/src/overlay-cmd.js +367 -0
  69. package/dist/packages/cli/src/record-cmd.js +82 -11
  70. package/dist/packages/cli/src/rules-cmd.js +2 -1
  71. package/dist/packages/cli/src/rules.js +24 -4
  72. package/dist/packages/cli/src/screens/narrative.js +154 -2
  73. package/dist/packages/cli/src/skill-eval-build.js +7 -2
  74. package/dist/packages/cli/src/state.js +1 -0
  75. package/dist/packages/cli/src/version.js +1 -1
  76. package/dist/packages/custom/src/rules.js +5 -1
  77. package/dist/packages/mcp/assets/kb/app-help/autotrade-telegram.md +1 -1
  78. package/dist/packages/mcp/assets/kb/app-help/classification-models.md +41 -0
  79. package/dist/packages/mcp/assets/kb/app-help/narratives-tracker.md +45 -0
  80. package/dist/packages/mcp/assets/kb/app-help/strategy-dashboard.md +38 -0
  81. package/dist/packages/mcp/assets/kb/app-help/streamer-overlay.md +36 -0
  82. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-confidence-tiers-experimental-unproven-paper-only.md +56 -0
  83. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-rug-proof-entries-experimental.md +52 -0
  84. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +3 -1
  85. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-strategy-dashboard-dexterm-dash.md +37 -0
  86. package/dist/packages/mcp/assets/kb/docs/docs-autotrade.md +3 -1
  87. package/dist/packages/mcp/assets/kb/docs/docs-beginners-before-any-experimental-trading.md +2 -2
  88. package/dist/packages/mcp/assets/kb/docs/docs-beginners-copy-and-paste-without-surprises.md +0 -1
  89. package/dist/packages/mcp/assets/kb/docs/docs-beginners-if-dexterm-says-command-not-found.md +2 -2
  90. package/dist/packages/mcp/assets/kb/docs/docs-beginners-reopen-dexterm-later.md +1 -1
  91. package/dist/packages/mcp/assets/kb/docs/docs-beginners-terminal-shell-prompt-command-output-and-path.md +2 -2
  92. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
  93. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows.md +1 -1
  94. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +2 -0
  95. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +2 -0
  96. package/dist/packages/mcp/assets/kb/docs/docs-classifier-hosted-jev-with-your-own-key.md +36 -0
  97. package/dist/packages/mcp/assets/kb/docs/docs-classifier-how-they-measured-on-dexterm-s-own-history.md +52 -0
  98. package/dist/packages/mcp/assets/kb/docs/docs-classifier-set-up-kev-on-your-own-machine.md +39 -0
  99. package/dist/packages/mcp/assets/kb/docs/docs-classifier-settings.md +35 -0
  100. package/dist/packages/mcp/assets/kb/docs/docs-classifier-the-choices.md +36 -0
  101. package/dist/packages/mcp/assets/kb/docs/docs-classifier-where-it-plugs-in.md +29 -0
  102. package/dist/packages/mcp/assets/kb/docs/docs-classifier-which-model-for-which-machine.md +42 -0
  103. package/dist/packages/mcp/assets/kb/docs/docs-classifier.md +32 -0
  104. package/dist/packages/mcp/assets/kb/docs/docs-cli-invocations.md +1 -1
  105. package/dist/packages/mcp/assets/kb/docs/docs-configuration-headless-recorder-dexterm-record.md +3 -0
  106. package/dist/packages/mcp/assets/kb/docs/docs-copy-trading.md +1 -1
  107. package/dist/packages/mcp/assets/kb/docs/docs-data-sources-recorded-history-and-retention.md +1 -0
  108. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
  109. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +2 -2
  110. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -11
  111. package/dist/packages/mcp/assets/kb/docs/docs-getting-started.md +2 -2
  112. package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +2 -2
  113. package/dist/packages/mcp/assets/kb/docs/docs-larp-paste-a-coin-on-the-web.md +1 -1
  114. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +1 -1
  115. package/dist/packages/mcp/assets/kb/docs/docs-narratives-how-coins-are-grouped.md +39 -0
  116. package/dist/packages/mcp/assets/kb/docs/docs-narratives-limits.md +29 -0
  117. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-is-measured.md +44 -0
  118. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-testing-showed.md +43 -0
  119. package/dist/packages/mcp/assets/kb/docs/docs-narratives-where-to-find-it.md +37 -0
  120. package/dist/packages/mcp/assets/kb/docs/docs-narratives.md +29 -0
  121. package/dist/packages/mcp/assets/kb/docs/docs-overlay-before-you-start.md +33 -0
  122. package/dist/packages/mcp/assets/kb/docs/docs-overlay-limits.md +30 -0
  123. package/dist/packages/mcp/assets/kb/docs/docs-overlay-options.md +36 -0
  124. package/dist/packages/mcp/assets/kb/docs/docs-overlay-security-model.md +36 -0
  125. package/dist/packages/mcp/assets/kb/docs/docs-overlay-set-it-up-in-obs-step-by-step.md +49 -0
  126. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-pnl-scene-paper-and-live.md +34 -0
  127. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-three-scenes.md +35 -0
  128. package/dist/packages/mcp/assets/kb/docs/docs-overlay-what-is-redacted.md +31 -0
  129. package/dist/packages/mcp/assets/kb/docs/docs-overlay.md +25 -0
  130. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading.md +1 -1
  131. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +2 -0
  132. package/dist/packages/mcp/assets/kb/docs/docs-screens-bubble-map.md +1 -1
  133. package/dist/packages/mcp/assets/kb/docs/docs-spectator-streaming-it.md +1 -0
  134. package/dist/packages/mcp/assets/kb/docs/docs-telegram-read-only-data-check-top-and-signals-experimental.md +48 -0
  135. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +3 -1
  136. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-will-not-do.md +1 -1
  137. package/dist/packages/mcp/assets/kb/docs/docs-voice-linux-and-windows.md +2 -2
  138. package/dist/packages/mcp/assets/kb/entries/narratives-tracker.md +45 -0
  139. package/dist/packages/mcp/src/server.js +106 -1
  140. package/dist/packages/mcp/src/spectator-sink.js +27 -0
  141. package/dist/packages/narratives/src/cluster.js +193 -0
  142. package/dist/packages/narratives/src/embed.js +184 -0
  143. package/dist/packages/narratives/src/export.js +60 -0
  144. package/dist/packages/narratives/src/index.js +10 -0
  145. package/dist/packages/narratives/src/label.js +48 -0
  146. package/dist/packages/narratives/src/normalize.js +179 -0
  147. package/dist/packages/narratives/src/recorder.js +93 -0
  148. package/dist/packages/narratives/src/tracker.js +328 -0
  149. package/dist/packages/packs/src/skill-care.js +10 -2
  150. package/dist/packages/recorder/src/exit-probes.js +215 -0
  151. package/dist/packages/recorder/src/index.js +1 -0
  152. package/dist/packages/recorder/src/recorder.js +11 -1
  153. package/dist/packages/recorder/src/retention.js +4 -0
  154. package/dist/packages/recorder/src/schema.js +20 -1
  155. package/dist/packages/tui/src/keymap.js +5 -0
  156. package/package.json +3 -1
  157. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +0 -39
@@ -0,0 +1,147 @@
1
+ // `dexterm narratives [top|detail ID|of MINT|export] [--db PATH] [--json] [--at ISO] [--lookback-h N] [--out FILE]`
2
+ // (task-1818, EXPERIMENTAL). The MEMES + NARRATIVES tracker over the history recorder: coins clustered by the theme
3
+ // their name/ticker carries, with per-window heat metrics and lifecycle states. Keyless, local, read-only on the DB.
4
+ // Measured heat, never advice.
5
+ import { DatabaseSync } from 'node:sqlite';
6
+ import { writeFileSync } from 'node:fs';
7
+ import { defaultDbPath } from '../../data/src/index.js';
8
+ import { trackerFromRecorder, toExport, renderTable, coinField, embedderFromEnv, HEAT_NOTE, LIFECYCLE_RULES } from '../../narratives/src/index.js';
9
+ export const NARRATIVES_HELP = `dexterm narratives: the MEMES + NARRATIVES tracker (task-1818, EXPERIMENTAL). No network, no model, no key.
10
+
11
+ dexterm narratives [top] the hottest narratives (rank = memetic traction score)
12
+ dexterm narratives detail ID one narrative: windows, lifecycle rule, member coins in spawn order
13
+ dexterm narratives of MINT which narrative a coin belongs to
14
+ dexterm narratives export [--out FILE] the dexterm-narratives/1 JSON the strategy tracker page reads
15
+
16
+ --db PATH defaults to DEXTERM_DB_PATH, else ~/.local/share/dexterm/dexterm.db (opened read-only)
17
+ --at ISO replay up to this time instead of now (point-in-time: only rows recorded before it)
18
+ --lookback-h N hours of history to replay (default 26)
19
+ --limit N rows (default 20)
20
+ --json machine-readable output
21
+
22
+ Coins are grouped by what their name/ticker says (ticker puns, animals, celebrities/politics, AI agents,
23
+ seasonal, copy-cats of a runner); a coin needs no real project to belong. Lifecycle states:
24
+ ${LIFECYCLE_RULES.map(([s, r]) => ` ${s.padEnd(9)} ${r}`).join('\n')}
25
+ ${HEAT_NOTE}
26
+ Embedder: DEXTERM_NARRATIVE_EMBEDDER=lexical (default, built in) | minilm (needs DEXTERM_EMBED_MODULE_DIR) | ollama[:model].`;
27
+ function parse(argv) {
28
+ const opts = {};
29
+ const pos = [];
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (!a.startsWith('--')) {
33
+ pos.push(a);
34
+ continue;
35
+ }
36
+ const k = a.slice(2);
37
+ const v = argv[i + 1];
38
+ if (v !== undefined && !v.startsWith('--') && k !== 'json' && k !== 'help') {
39
+ opts[k] = v;
40
+ i++;
41
+ }
42
+ else
43
+ opts[k] = true;
44
+ }
45
+ return { pos, opts };
46
+ }
47
+ export async function narrativesCommand(argv, io) {
48
+ const { pos, opts } = parse(argv);
49
+ if (opts.help || pos[0] === 'help') {
50
+ io.stdout.write(NARRATIVES_HELP + '\n');
51
+ return 0;
52
+ }
53
+ const cmd = pos[0] ?? 'top';
54
+ if (!['top', 'detail', 'of', 'export'].includes(cmd)) {
55
+ io.stderr.write(`dexterm narratives: unknown command "${cmd}" (top | detail ID | of MINT | export)\n`);
56
+ return 2;
57
+ }
58
+ const path = typeof opts.db === 'string' ? opts.db : (io.env.DEXTERM_DB_PATH || defaultDbPath(io.env));
59
+ if (path === ':memory:') {
60
+ io.stderr.write('dexterm narratives: an in-memory DB has no history\n');
61
+ return 2;
62
+ }
63
+ const at = typeof opts.at === 'string' ? Date.parse(opts.at) : Date.now();
64
+ if (!Number.isFinite(at)) {
65
+ io.stderr.write('dexterm narratives: --at needs an ISO time\n');
66
+ return 2;
67
+ }
68
+ const lookbackH = typeof opts['lookback-h'] === 'string' ? Number(opts['lookback-h']) : 26;
69
+ if (!(Number.isFinite(lookbackH) && lookbackH > 0)) {
70
+ io.stderr.write('dexterm narratives: --lookback-h needs a number of hours above 0\n');
71
+ return 2;
72
+ }
73
+ const limitN = typeof opts.limit === 'string' ? Math.floor(Number(opts.limit)) : 20;
74
+ if (!Number.isFinite(limitN) || limitN < 1) {
75
+ io.stderr.write('dexterm narratives: --limit needs a whole number of 1 or more\n');
76
+ return 2;
77
+ }
78
+ const limit = Math.min(200, limitN);
79
+ let db = null;
80
+ try {
81
+ db = new DatabaseSync(path, { readOnly: true });
82
+ if (!db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'token_snapshots'").get()) {
83
+ io.stderr.write(`dexterm narratives: no recorded history in ${path} (run the app with the recorder on first)\n`);
84
+ return 2;
85
+ }
86
+ const t0 = performance.now();
87
+ const tr = await trackerFromRecorder(db, { now: at, lookbackMs: lookbackH * 3_600_000, embedder: embedderFromEnv(io.env) });
88
+ const ms = Math.round(performance.now() - t0);
89
+ if (cmd === 'export') {
90
+ const ex = toExport(tr, { limit: Math.max(limit, 50), now: at });
91
+ const json = JSON.stringify(ex, null, 1) + '\n';
92
+ if (typeof opts.out === 'string') {
93
+ writeFileSync(opts.out, json);
94
+ io.stdout.write(`wrote ${opts.out}: ${ex.narratives.length} narratives over ${ex.coins} coins (${ms} ms)\n`);
95
+ }
96
+ else
97
+ io.stdout.write(json);
98
+ return 0;
99
+ }
100
+ if (cmd === 'detail' || cmd === 'of') {
101
+ const key = pos[1];
102
+ if (!key) {
103
+ io.stderr.write(`dexterm narratives ${cmd}: needs ${cmd === 'detail' ? 'a narrative id' : 'a mint'}\n`);
104
+ return 2;
105
+ }
106
+ const n = cmd === 'of' ? tr.narrativeOf(key) : null;
107
+ if (cmd === 'of' && !n) {
108
+ io.stdout.write(opts.json ? JSON.stringify({ mint: key, narrative: null, note: 'not recorded in the lookback window' }) + '\n' : `${coinField(null)} (no recorded sighting in the last ${lookbackH} h)\n`);
109
+ return 0;
110
+ }
111
+ const d = tr.detail(cmd === 'of' ? n.narrative : key);
112
+ if (!d) {
113
+ io.stderr.write(`dexterm narratives: unknown narrative id ${key}\n`);
114
+ return 2;
115
+ }
116
+ if (opts.json) {
117
+ io.stdout.write(JSON.stringify(cmd === 'of' ? { ...n, detail: d } : d, null, 1) + '\n');
118
+ return 0;
119
+ }
120
+ if (n)
121
+ io.stdout.write(coinField(n) + '\n');
122
+ io.stdout.write(renderTable([d], 150) + '\n\n');
123
+ io.stdout.write(`lifecycle rule: ${d.lifecycleRule}\nmembers (spawn order, newest ${Math.min(limit, d.memberList.length)} of ${d.memberList.length}):\n`);
124
+ for (const m of d.memberList.slice(-limit))
125
+ io.stdout.write(` #${String(m.spawn).padEnd(4)} ${m.symbol.padEnd(12)} ${m.name.slice(0, 28).padEnd(28)} ${m.x == null ? ' —' : (m.x.toFixed(2) + 'x').padStart(7)} ${m.mint}\n`);
126
+ return 0;
127
+ }
128
+ const rows = tr.top(limit);
129
+ if (opts.json) {
130
+ io.stdout.write(JSON.stringify(toExport(tr, { limit, now: at }), null, 1) + '\n');
131
+ return 0;
132
+ }
133
+ io.stdout.write(`NARRATIVES (EXPERIMENTAL) · as of ${tr.lastTickAt ? new Date(tr.lastTickAt).toISOString() : '—'} · ${tr.coins.size} coins over ${lookbackH} h · embedder ${tr.embedder.id} · ${ms} ms\n`);
134
+ io.stdout.write(renderTable(rows, 150) + '\n');
135
+ return 0;
136
+ }
137
+ catch (e) {
138
+ io.stderr.write(`dexterm narratives: ${e instanceof Error ? e.message : String(e)}\n`);
139
+ return 1;
140
+ }
141
+ finally {
142
+ try {
143
+ db?.close();
144
+ }
145
+ catch { /* ignore */ }
146
+ }
147
+ }
@@ -0,0 +1,114 @@
1
+ import { NarrativeTracker, RecorderNarratives, coinField, embedderFromEnv, sanitize } from '../../narratives/src/index.js';
2
+ /** Later refreshes run at most this often (the first one runs on open). */
3
+ export const NARRATIVES_REFRESH_MS = 60_000;
4
+ /** Rows in the heat table. */
5
+ export const HEAT_LIMIT = 50;
6
+ export const UNTRUSTED_NOTE = 'names are untrusted, shown control-stripped';
7
+ const services = new WeakMap();
8
+ const screenTrackers = new WeakMap();
9
+ export function narrativesUiOf(state) {
10
+ if (!state.narrativesUi)
11
+ state.narrativesUi = { mode: 'board', selected: 0, drill: null, busy: false, error: null, source: null, refreshedAt: 0, buildMs: null };
12
+ return state.narrativesUi;
13
+ }
14
+ const dbOf = (state) => state.live?.recorder?.database ?? null;
15
+ export const narrativesSource = (state) => (dbOf(state) ? 'recorded' : 'screen');
16
+ /** The tracker ALREADY built for this state, or null. Never builds (the coin pane / rules / renders call this). */
17
+ export function currentTracker(state) {
18
+ const db = dbOf(state);
19
+ return (db ? services.get(db)?.tracker ?? null : null) ?? screenTrackers.get(state) ?? null;
20
+ }
21
+ /** Install a tracker for a state with no recorder DB (tests; the screen-rows fallback uses the same slot). */
22
+ export function setNarrativeTracker(state, tr) { if (tr)
23
+ screenTrackers.set(state, tr);
24
+ else
25
+ screenTrackers.delete(state); }
26
+ /**
27
+ * Build or refresh the tracker. Recorder DB: the per-process RecorderNarratives (first call = the 26 h replay, later
28
+ * calls replay only new rows; it self-throttles to NARRATIVES_REFRESH_MS). No DB: the visible screener rows are fed
29
+ * to a per-state tracker (first sightings once, a sample per row each call) and ticked now.
30
+ */
31
+ export async function ensureNarratives(state, now = Date.now()) {
32
+ const db = dbOf(state);
33
+ if (db) {
34
+ let svc = services.get(db);
35
+ if (!svc)
36
+ services.set(db, (svc = new RecorderNarratives(db, { embedder: embedderFromEnv(process.env), minRefreshMs: NARRATIVES_REFRESH_MS })));
37
+ return svc.refresh(now);
38
+ }
39
+ const rows = state.screener.rows.filter((r) => r.mint && !r.skeleton);
40
+ let tr = screenTrackers.get(state) ?? null;
41
+ if (!rows.length)
42
+ return tr;
43
+ if (!tr)
44
+ screenTrackers.set(state, (tr = new NarrativeTracker()));
45
+ const at = (r) => Math.min(now, r.fetchedAt ?? now);
46
+ await tr.addCoins(rows.map((r) => ({ mint: r.mint, t: at(r), symbol: r.symbol, name: r.name })));
47
+ for (const r of rows)
48
+ tr.addSample({ mint: r.mint, t: at(r), price: r.price, liq: r.liq, mcap: r.mcap, vol5m: r.vol5m, vol1h: r.vol1h, vol6h: r.vol6h, vol24h: r.vol24 });
49
+ tr.tick(now, true);
50
+ return tr;
51
+ }
52
+ /**
53
+ * Start a build/refresh in the background unless one is running or the last finished < NARRATIVES_REFRESH_MS ago
54
+ * (`force` skips the age check). The work starts on a short timer so the "building…" frame paints first; the recorder
55
+ * replay then yields to the event loop every 40 ms (RecorderNarratives), so frames keep drawing. Returns whether it started.
56
+ */
57
+ export function kickNarratives(state, opts = {}) {
58
+ const ui = narrativesUiOf(state);
59
+ const now = opts.now ?? Date.now();
60
+ if (ui.busy)
61
+ return false;
62
+ if (!opts.force && ui.refreshedAt && now - ui.refreshedAt < NARRATIVES_REFRESH_MS)
63
+ return false;
64
+ ui.busy = true;
65
+ ui.error = null;
66
+ const run = () => {
67
+ const t0 = Date.now();
68
+ void ensureNarratives(state, Date.now())
69
+ .then(() => { ui.source = narrativesSource(state); ui.buildMs = Date.now() - t0; })
70
+ .catch((e) => { ui.error = e instanceof Error ? e.message : String(e); })
71
+ .finally(() => { ui.busy = false; ui.refreshedAt = Date.now(); opts.onDone?.(); });
72
+ };
73
+ const tm = setTimeout(run, opts.delayMs ?? 40);
74
+ tm.unref?.();
75
+ return true;
76
+ }
77
+ /** The heat table's rows (control-stripped labels). The screen-rows fallback has no history, so solo coins show too. */
78
+ export function heatRows(state) {
79
+ const tr = currentTracker(state);
80
+ if (!tr)
81
+ return [];
82
+ return tr.top(HEAT_LIMIT, { minMembers: narrativesSource(state) === 'screen' ? 1 : 2 }).map((s) => sanitize(s));
83
+ }
84
+ /** The drilled narrative (with its member list in spawn order), or null. */
85
+ export function drillDetail(state) {
86
+ const id = state.narrativesUi?.drill?.id;
87
+ const tr = currentTracker(state);
88
+ if (!id || !tr)
89
+ return null;
90
+ const d = tr.detail(id);
91
+ if (d)
92
+ d.memberList.sort((a, b) => a.spawn - b.spawn);
93
+ return d;
94
+ }
95
+ /** One coin's narrative from the already-built tracker (null = not built or not tracked). */
96
+ export function narrativeFor(state, mint) {
97
+ return currentTracker(state)?.narrativeOf(mint) ?? null;
98
+ }
99
+ /** The coin pane's Narrative row value + level: warn when copy-cat saturated or fading/dead, else the neutral na (heat is
100
+ * never shown as a green "ok": it is not a quality mark of the coin). */
101
+ export function narrativeCell(state, mint) {
102
+ if (!currentTracker(state))
103
+ return ['open N → n to build', 'na'];
104
+ const n = narrativeFor(state, mint);
105
+ const text = coinField(n).replace(/^narrative: /, '');
106
+ if (!n)
107
+ return [text, 'na'];
108
+ return [text, n.saturated || n.lifecycle === 'fading' || n.lifecycle === 'dead' ? 'warn' : 'na'];
109
+ }
110
+ /** The rules' narrative fields for one coin (all null when not built / not tracked; a rule clause on them is then false). */
111
+ export function narrativeRuleFields(state, mint) {
112
+ const n = narrativeFor(state, mint);
113
+ return { narrativeRank: n?.rank ?? null, narrativeScore: n?.score ?? null, narrativeLifecycle: n?.lifecycle ?? null, narrativeSaturated: n ? n.saturated : null };
114
+ }
@@ -0,0 +1,147 @@
1
+ // dexterm — STREAMER OVERLAY model (task-1427). A pure fold of spectator events into the three OBS scenes:
2
+ // activity the agent's recent calls (redacted), newest first
3
+ // coin the coin the agent is on: name, LARP evidence + verdict, a top-10 holder bar, live signals with ages
4
+ // pnl the agent's PAPER fills on that coin (drawn by the task-1407 card renderer, see overlay/pnl.ts)
5
+ //
6
+ // Honesty by construction:
7
+ // - every event is passed through redactSpectatorEvent HERE, whatever its source already did, so a full address
8
+ // or key can never reach a scene (addresses become head…tail; key material becomes [key]);
9
+ // - every figure is copied from an event; nothing is estimated. Each scene carries the age of its data;
10
+ // - a replayed recording is SAMPLE DATA: `sample` is set once by the caller and every scene view carries it.
11
+ // No I/O, no clock (callers pass `now`), no control surface: the overlay can only describe what already happened.
12
+ import { redactSpectatorEvent } from '../../../agents/src/index.js';
13
+ /** How many activity rows a scene keeps (OBS lower thirds are short). */
14
+ export const OVERLAY_ACTIVITY_MAX = 8;
15
+ /** Live signals kept on the coin card. */
16
+ export const OVERLAY_FLAGS_MAX = 4;
17
+ export const OVERLAY_SCENES = ['activity', 'coin', 'pnl'];
18
+ export function emptyOverlayState(sample) {
19
+ return { sample, lastEventAt: null, events: 0, activity: [], coins: new Map(), current: null, paper: new Map(), paperCurrent: null };
20
+ }
21
+ const MASKED_RE = /^[1-9A-HJ-NP-Za-km-z]{4}…[1-9A-HJ-NP-Za-km-z]{4}$/;
22
+ // eslint-disable-next-line no-control-regex
23
+ const UNSAFE_RE = /[\u0000-\u001f\u007f-\u009f​-‏‪-‮⁦-⁩]/g;
24
+ const clean = (s, max) => (typeof s === 'string' ? s.replace(UNSAFE_RE, '').slice(0, max) : '');
25
+ const finite = (x) => (typeof x === 'number' && Number.isFinite(x) ? x : null);
26
+ /** Fold one event (any source) into the state. Returns the redacted copy that was folded. */
27
+ export function foldOverlay(state, raw) {
28
+ const ev = redactSpectatorEvent(raw);
29
+ const t = finite(ev.t) ?? 0;
30
+ state.events++;
31
+ state.lastEventAt = state.lastEventAt === null ? t : Math.max(state.lastEventAt, t);
32
+ if (ev.phase !== 'note' || ev.tool !== 'live_signal') {
33
+ state.activity.unshift({
34
+ t, agent: clean(ev.agent, 24) || 'agent', phase: clean(ev.phase, 12), tool: clean(ev.tool ?? '', 28),
35
+ summary: clean(ev.summary ?? '', 120), ok: ev.ok !== false, dataAgeSec: finite(ev.dataAgeSec),
36
+ });
37
+ if (state.activity.length > OVERLAY_ACTIVITY_MAX)
38
+ state.activity.length = OVERLAY_ACTIVITY_MAX;
39
+ }
40
+ const d = ev.detail ?? {};
41
+ const age = finite(ev.dataAgeSec) ?? 0;
42
+ const mint = [d.coin?.mint, d.fill?.mint, d.ticket?.mint, ev.nav?.mint].find((m) => typeof m === 'string' && MASKED_RE.test(m)) ?? null;
43
+ if (mint) {
44
+ const c = coin(state, mint, t);
45
+ if (ev.nav?.mint === mint || d.coin || d.verdict || d.larp || d.holders)
46
+ state.current = mint;
47
+ const sym = clean(d.coin?.symbol ?? d.fill?.symbol ?? d.ticket?.symbol ?? '', 16);
48
+ if (sym && sym !== '?')
49
+ c.symbol = sym;
50
+ const px = finite(d.coin?.priceUsd ?? null) ?? finite(d.fill?.priceUsd ?? null);
51
+ if (px !== null && px > 0)
52
+ c.priceUsd = { value: px, at: t, ageSec: age };
53
+ if (d.verdict)
54
+ c.verdict = { label: clean(d.verdict.label, 18), rug: finite(d.verdict.rug ?? null), at: t, ageSec: age };
55
+ if (d.larp)
56
+ c.larp = { flags: Math.max(0, Math.round(d.larp.flags)), items: d.larp.items.slice(0, 4).map((x) => clean(x, 60)), at: t, ageSec: age };
57
+ if (d.holders && finite(d.holders.top10Pct) !== null) {
58
+ c.holders = { top10Pct: Math.max(0, Math.min(100, d.holders.top10Pct)), count: finite(d.holders.count ?? null), basis: d.holders.basis === 'raw' ? 'raw' : 'ex-pools', at: t, ageSec: finite(d.holders.ageSec ?? null) ?? age };
59
+ }
60
+ for (const f of d.liveFlags ?? []) {
61
+ // dated on the event's clock: at the event the reading was its own age plus the time since the signal fired
62
+ // (so a replayed recording, re-timed to the playback clock, keeps its signals' ages consistent)
63
+ const fat = finite(f.at) ?? t;
64
+ c.flags.unshift({ kind: clean(f.kind, 16), level: f.level, title: clean(f.title, 24), text: clean(f.text, 120), source: clean(f.source, 24), stale: f.stale === true, at: t, ageSec: Math.max(0, finite(f.ageSec) ?? 0) + Math.max(0, t - fat) / 1000 });
65
+ }
66
+ if (c.flags.length > OVERLAY_FLAGS_MAX)
67
+ c.flags.length = OVERLAY_FLAGS_MAX;
68
+ if (d.fill && ev.ok !== false) {
69
+ const f = d.fill;
70
+ const sol = finite(f.sol), qty = finite(f.qty);
71
+ if (sol !== null && qty !== null && qty > 0 && (f.side === 'buy' || f.side === 'sell')) {
72
+ const p = state.paper.get(mint) ?? { mint, symbol: c.symbol ?? '?', fills: [] };
73
+ p.symbol = c.symbol ?? p.symbol;
74
+ p.fills.push({ t, side: f.side, sol, qty, priceUsd: finite(f.priceUsd) });
75
+ if (p.fills.length > 200)
76
+ p.fills.splice(0, p.fills.length - 200);
77
+ state.paper.set(mint, p);
78
+ state.paperCurrent = mint;
79
+ }
80
+ }
81
+ }
82
+ return ev;
83
+ }
84
+ function coin(state, mint, t) {
85
+ let c = state.coins.get(mint);
86
+ if (!c) {
87
+ c = { mint, symbol: null, priceUsd: null, verdict: null, larp: null, holders: null, flags: [], lastAt: t };
88
+ state.coins.set(mint, c);
89
+ // bounded: a long session touches many coins; keep the 50 most recent
90
+ if (state.coins.size > 50) {
91
+ const oldest = [...state.coins.values()].filter((x) => x.mint !== state.current).sort((a, b) => a.lastAt - b.lastAt)[0];
92
+ if (oldest)
93
+ state.coins.delete(oldest.mint);
94
+ }
95
+ }
96
+ c.lastAt = Math.max(c.lastAt, t);
97
+ return c;
98
+ }
99
+ // ------------------------------------------------------------------------------------------------ scene views
100
+ /** Seconds → "12s" / "4m" / "2h" / "3d" (the scenes' age chips). */
101
+ export function fmtAgeShort(sec) {
102
+ if (sec === null || !Number.isFinite(sec))
103
+ return '—';
104
+ const s = Math.max(0, Math.round(sec));
105
+ return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86_400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86_400)}d`;
106
+ }
107
+ /** The age of a dated value at `now`: how old it was when it arrived plus the time since. */
108
+ export const ageAt = (d, now) => d.ageSec + Math.max(0, now - d.at) / 1000;
109
+ function header(state, newestAgeSec) {
110
+ return { sample: state.sample, ageSec: newestAgeSec, age: newestAgeSec === null ? 'no data yet' : `data age ${fmtAgeShort(newestAgeSec)}` };
111
+ }
112
+ export function activityView(state, now) {
113
+ const newest = state.activity[0];
114
+ return {
115
+ scene: 'activity',
116
+ ...header(state, newest ? Math.max(0, now - newest.t) / 1000 + (newest.dataAgeSec ?? 0) : null),
117
+ rows: state.activity.map((r) => ({
118
+ when: fmtAgeShort(Math.max(0, now - r.t) / 1000), agent: r.agent, phase: r.phase, tool: r.tool, summary: r.summary, ok: r.ok,
119
+ dataAge: r.dataAgeSec === null ? null : fmtAgeShort(r.dataAgeSec + Math.max(0, now - r.t) / 1000),
120
+ })),
121
+ };
122
+ }
123
+ const fmtUsdPrice = (v) => (v >= 1 ? `$${v.toFixed(4)}` : v >= 0.0001 ? `$${v.toPrecision(4)}` : `$${v.toExponential(2)}`);
124
+ export function coinView(state, now) {
125
+ const c = state.current ? state.coins.get(state.current) ?? null : null;
126
+ if (!c)
127
+ return { scene: 'coin', ...header(state, null), coin: null };
128
+ const ages = [c.priceUsd, c.verdict, c.larp, c.holders, ...c.flags].filter((x) => !!x).map((x) => ageAt(x, now));
129
+ return {
130
+ scene: 'coin',
131
+ ...header(state, ages.length ? Math.min(...ages) : null),
132
+ coin: {
133
+ mint: c.mint,
134
+ symbol: c.symbol ?? '?',
135
+ price: c.priceUsd ? { text: fmtUsdPrice(c.priceUsd.value), age: fmtAgeShort(ageAt(c.priceUsd, now)) } : null,
136
+ verdict: c.verdict ? { label: c.verdict.label, rug: c.verdict.rug === null ? null : `${Math.round(c.verdict.rug * 100)}%`, age: fmtAgeShort(ageAt(c.verdict, now)) } : null,
137
+ larp: c.larp ? { flags: c.larp.flags, items: c.larp.items, age: fmtAgeShort(ageAt(c.larp, now)) } : null,
138
+ holders: c.holders ? {
139
+ top10Pct: +c.holders.top10Pct.toFixed(1),
140
+ text: `top 10 hold ${c.holders.top10Pct.toFixed(1)}%${c.holders.count !== null ? ` · ${c.holders.count.toLocaleString('en-US')} holders` : ''}`,
141
+ basis: c.holders.basis === 'ex-pools' ? 'pools, curves, locks and burns left out' : 'every account counted (raw)',
142
+ age: fmtAgeShort(ageAt(c.holders, now)),
143
+ } : null,
144
+ flags: c.flags.map((f) => ({ level: f.level, title: f.title, text: f.text, source: f.source, age: fmtAgeShort(ageAt(f, now)), stale: f.stale })),
145
+ },
146
+ };
147
+ }
@@ -0,0 +1,146 @@
1
+ // dexterm — STREAMER OVERLAY page (task-1427): one self-contained HTML document per scene, built for an OBS
2
+ // browser source. The body is transparent (OBS composites it over the stream); the panels carry the cyberspace
3
+ // look in three presets. Everything is inlined under the server's per-page nonce; the script builds the DOM with
4
+ // textContent only (never innerHTML), because every string it shows came from an agent or a token and is untrusted.
5
+ // The page has no controls and sends nothing back: the socket is read-only (see server.ts).
6
+ /** Three presets of the cyberspace look (the app's `cyberspace` theme tokens and two neon variants). */
7
+ export const OVERLAY_THEMES = {
8
+ cyberspace: { panel: 'rgba(3,9,12,0.86)', edge: '#0c3a24', glow: '#39ff14', text: '#eafff0', muted: '#62c98a', dim: '#3aa866', accent: '#00f0ff', up: '#5dff6e', down: '#ff3b6b', warn: '#f5ff2d' },
9
+ ice: { panel: 'rgba(2,8,16,0.86)', edge: '#0b3348', glow: '#00f0ff', text: '#e8fbff', muted: '#6cc7dd', dim: '#3f98b0', accent: '#39ff14', up: '#5dff6e', down: '#ff3b6b', warn: '#f5ff2d' },
10
+ neon: { panel: 'rgba(10,3,16,0.86)', edge: '#3b0f4a', glow: '#ff2bd6', text: '#fff0fc', muted: '#d07ac0', dim: '#a04f93', accent: '#00f0ff', up: '#5dff6e', down: '#ff3b6b', warn: '#f5ff2d' },
11
+ };
12
+ export const OVERLAY_THEME_NAMES = Object.keys(OVERLAY_THEMES);
13
+ const CSS = `
14
+ :root{--panel:__PANEL__;--edge:__EDGE__;--glow:__GLOW__;--text:__TEXT__;--muted:__MUTED__;--dim:__DIM__;--accent:__ACCENT__;--up:__UP__;--down:__DOWN__;--warn:__WARN__}
15
+ html,body{margin:0;padding:0;background:transparent;color:var(--text);font:600 18px/1.35 ui-monospace,SFMono-Regular,Menlo,Consolas,"DejaVu Sans Mono",monospace;overflow:hidden}
16
+ body{padding:12px}
17
+ .grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px}
18
+ .grid .pnl{grid-column:1 / span 2}
19
+ .scene{background:var(--panel);border:1px solid var(--edge);box-shadow:0 0 0 1px rgba(0,0,0,.4),0 0 18px -6px var(--glow);border-radius:6px;padding:10px 14px;max-width:1200px}
20
+ .head{display:flex;gap:10px;align-items:center;border-bottom:1px solid var(--edge);padding-bottom:6px;margin-bottom:8px;white-space:nowrap}
21
+ .brand{color:var(--glow)}.title{color:var(--muted);letter-spacing:.08em}
22
+ .sp{flex:1}
23
+ .chip{border:1px solid var(--edge);border-radius:3px;padding:0 6px;color:var(--dim);font-size:14px}
24
+ .chip.sample{color:#000;background:var(--warn);border-color:var(--warn)}
25
+ .chip.old{color:var(--warn);border-color:var(--warn)}
26
+ .chip.paper{color:#000;background:var(--warn);border-color:var(--warn)}
27
+ .chip.live{color:#000;background:var(--up);border-color:var(--up)}
28
+ .chip.off{color:var(--down);border-color:var(--down)}
29
+ .row{display:grid;grid-template-columns:3.2em 9em 11em minmax(0,1fr) 4.5em;gap:8px;white-space:nowrap;overflow:hidden}
30
+ .row>*{overflow:hidden;text-overflow:ellipsis}
31
+ .when,.age{color:var(--dim)}.agent{color:var(--accent)}.tool{color:var(--muted)}.bad{color:var(--down)}
32
+ .sym{font-size:34px;color:var(--text)}.mint{color:var(--dim);font-size:14px}
33
+ .kv{display:flex;gap:10px;margin:4px 0;white-space:nowrap}.k{color:var(--dim);min-width:7.5em}.v{overflow:hidden;text-overflow:ellipsis}
34
+ .bar{height:14px;border:1px solid var(--edge);border-radius:2px;background:rgba(0,0,0,.35);margin:3px 0 2px}
35
+ .fill{height:100%;background:linear-gradient(90deg,var(--up),var(--warn) 55%,var(--down))}
36
+ .small{font-size:14px;color:var(--dim)}
37
+ .flag{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
38
+ .flag.warn,.flag.down{color:var(--down)}.flag.up{color:var(--up)}.flag.info{color:var(--muted)}
39
+ .card{position:relative}.card img{display:block;width:100%;height:auto;image-rendering:pixelated;border-radius:3px}
40
+ .banner{position:absolute;left:0;right:0;top:0;text-align:center;font-size:22px;line-height:30px;color:#000;background:var(--warn);letter-spacing:.12em;border-radius:3px 3px 0 0}
41
+ .empty{color:var(--dim)}
42
+ .foot{margin-top:6px;color:var(--dim);font-size:13px}
43
+ `;
44
+ const JS = `
45
+ (function(){
46
+ 'use strict';
47
+ var PAGE = document.querySelector('meta[name="dexterm-page"]').content;
48
+ var WS_URL = document.querySelector('meta[name="dexterm-ws"]').content;
49
+ var SCENES = PAGE === 'all' ? ['coin','activity','pnl'] : [PAGE];
50
+ var TITLES = { activity: 'AGENT ACTIVITY', coin: 'COIN', pnl: 'PnL' };
51
+ var root = document.getElementById('root');
52
+ if (PAGE === 'all') root.className = 'grid';
53
+ var boxes = {};
54
+ function el(tag, cls, text){ var e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = String(text); return e; }
55
+ SCENES.forEach(function(s){
56
+ var box = el('section', 'scene ' + s);
57
+ var head = el('div', 'head');
58
+ head.appendChild(el('span', 'brand', '\\u25c6 dexterm'));
59
+ head.appendChild(el('span', 'title', TITLES[s]));
60
+ head.appendChild(el('span', 'sp'));
61
+ var stamp = el('span', 'chip'); stamp.hidden = true; head.appendChild(stamp);
62
+ var sample = el('span', 'chip sample', 'SAMPLE DATA \\u00b7 REPLAY'); sample.hidden = true; head.appendChild(sample);
63
+ var age = el('span', 'chip age', 'waiting for data'); head.appendChild(age);
64
+ var conn = el('span', 'chip off', 'disconnected'); conn.hidden = true; head.appendChild(conn);
65
+ var body = el('div', 'body'); body.appendChild(el('div', 'empty', 'waiting for the agent\\u2026'));
66
+ var foot = el('div', 'foot', 'read-only \\u00b7 addresses redacted \\u00b7 not financial advice \\u00b7 dexterm.xyz');
67
+ box.appendChild(head); box.appendChild(body); box.appendChild(foot);
68
+ root.appendChild(box);
69
+ boxes[s] = { body: body, age: age, sample: sample, stamp: stamp, conn: conn };
70
+ });
71
+ function header(b, v){
72
+ b.sample.hidden = !v.sample;
73
+ b.age.textContent = v.age;
74
+ b.age.className = 'chip age' + (v.ageSec !== null && v.ageSec > 120 ? ' old' : '');
75
+ }
76
+ function clear(n){ while (n.firstChild) n.removeChild(n.firstChild); }
77
+ function kv(k, v, extra){ var r = el('div', 'kv'); r.appendChild(el('span', 'k', k)); r.appendChild(el('span', 'v', v)); if (extra) r.appendChild(el('span', 'age', extra)); return r; }
78
+ var render = {
79
+ activity: function(b, v){
80
+ clear(b.body);
81
+ if (!v.rows.length) { b.body.appendChild(el('div', 'empty', 'no agent calls yet')); return; }
82
+ v.rows.forEach(function(r){
83
+ var row = el('div', 'row' + (r.ok ? '' : ' bad'));
84
+ row.appendChild(el('span', 'when', r.when));
85
+ row.appendChild(el('span', 'agent', r.agent));
86
+ row.appendChild(el('span', 'tool', r.tool || r.phase));
87
+ row.appendChild(el('span', 'sum', r.summary));
88
+ row.appendChild(el('span', 'age', r.dataAge ? r.dataAge + ' old' : ''));
89
+ b.body.appendChild(row);
90
+ });
91
+ },
92
+ coin: function(b, v){
93
+ clear(b.body);
94
+ var c = v.coin;
95
+ if (!c) { b.body.appendChild(el('div', 'empty', 'the agent has not opened a coin yet')); return; }
96
+ var top = el('div', 'kv'); top.appendChild(el('span', 'sym', '$' + c.symbol)); top.appendChild(el('span', 'mint', c.mint)); b.body.appendChild(top);
97
+ if (c.price) b.body.appendChild(kv('PRICE', c.price.text, c.price.age + ' old'));
98
+ b.body.appendChild(c.verdict ? kv('VERDICT', c.verdict.label + (c.verdict.rug ? ' \\u00b7 rug heuristic ' + c.verdict.rug : ''), c.verdict.age + ' old') : kv('VERDICT', 'not run yet'));
99
+ if (c.larp) {
100
+ b.body.appendChild(kv('LARP', c.larp.flags + ' evidence flag' + (c.larp.flags === 1 ? '' : 's') + ' (evidence, not a verdict)', c.larp.age + ' old'));
101
+ c.larp.items.slice(0, 3).forEach(function(i){ b.body.appendChild(el('div', 'small', ' \\u00b7 ' + i)); });
102
+ } else b.body.appendChild(kv('LARP', 'not checked yet'));
103
+ if (c.holders) {
104
+ b.body.appendChild(kv('HOLDERS', c.holders.text, c.holders.age + ' old'));
105
+ var bar = el('div', 'bar'); var f = el('div', 'fill'); f.style.width = Math.max(0, Math.min(100, c.holders.top10Pct)) + '%'; bar.appendChild(f); b.body.appendChild(bar);
106
+ b.body.appendChild(el('div', 'small', c.holders.basis));
107
+ } else b.body.appendChild(kv('HOLDERS', 'not read yet'));
108
+ if (c.flags.length) {
109
+ b.body.appendChild(el('div', 'k', 'LIVE SIGNALS'));
110
+ c.flags.forEach(function(s){ b.body.appendChild(el('div', 'flag ' + s.level, '\\u25cf ' + s.title + ' \\u00b7 ' + s.text + ' \\u00b7 ' + s.source + ' \\u00b7 ' + s.age + ' old' + (s.stale ? ' (stale)' : ''))); });
111
+ } else b.body.appendChild(kv('SIGNALS', 'none fired yet'));
112
+ },
113
+ pnl: function(b, v){
114
+ clear(b.body);
115
+ b.stamp.hidden = !v.stamp;
116
+ if (v.stamp) { b.stamp.textContent = v.strip || (v.stamp === 'LIVE' ? 'LIVE' : 'PAPER \\u00b7 SIMULATED'); b.stamp.className = 'chip ' + (v.stamp === 'LIVE' ? 'live' : 'paper'); }
117
+ if (!v.png) { b.body.appendChild(el('div', 'empty', v.note)); return; }
118
+ var card = el('div', 'card');
119
+ var img = new Image(v.w, v.h); img.alt = (v.stamp || '') + ' PnL card'; img.src = 'data:image/png;base64,' + v.png;
120
+ card.appendChild(img);
121
+ if (v.sample) card.appendChild(el('div', 'banner', 'SAMPLE DATA \\u00b7 REPLAY OF A SAVED SESSION'));
122
+ b.body.appendChild(card);
123
+ b.body.appendChild(el('div', 'small', v.note));
124
+ }
125
+ };
126
+ var ws = new WebSocket(WS_URL);
127
+ ws.onmessage = function(m){
128
+ var msg; try { msg = JSON.parse(m.data); } catch (e) { return; }
129
+ if (!msg || msg.type !== 'view' || !boxes[msg.scene] || !render[msg.scene]) return;
130
+ var b = boxes[msg.scene];
131
+ header(b, msg.view);
132
+ render[msg.scene](b, msg.view);
133
+ };
134
+ ws.onclose = function(){ SCENES.forEach(function(s){ boxes[s].conn.hidden = false; boxes[s].conn.textContent = 'disconnected \\u00b7 run dexterm overlay for a fresh URL'; }); };
135
+ })();
136
+ `;
137
+ const htmlEscape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
138
+ /** The full document for one scene page (or 'all'). Values substituted here are ours (theme tokens, ws url). */
139
+ export function renderOverlayPage(o) {
140
+ const t = OVERLAY_THEMES[o.theme];
141
+ const css = CSS.replace(/__([A-Z]+)__/g, (_m, k) => t[k.toLowerCase()] ?? '');
142
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">`
143
+ + `<meta name="dexterm-page" content="${htmlEscape(o.page)}"><meta name="dexterm-ws" content="${htmlEscape(o.wsUrl)}">`
144
+ + `<title>dexterm overlay · ${htmlEscape(o.page)}</title><style nonce="${o.nonce}">${css}</style></head>`
145
+ + `<body><main id="root"></main><script nonce="${o.nonce}">${JS}</script></body></html>`;
146
+ }
@@ -0,0 +1,75 @@
1
+ // dexterm — STREAMER OVERLAY PnL scene (task-1427). Draws the coin's result with the task-1407 card renderer, so
2
+ // the stamp (PAPER / LIVE), the provenance block, the data ages and the "not financial advice" footer are the
3
+ // renderer's own and cannot be turned off here.
4
+ //
5
+ // Two sources, never mixed:
6
+ // - the spectator stream (default): the agent's PAPER fills on the coin, exactly as the paper desk booked them.
7
+ // The stream only ever carries paper fills (an agent can never trade for real), so this card is always PAPER.
8
+ // - the trade ledger (`--ledger <tradeId>`): one round trip of the user's own unified ledger, read from the local
9
+ // file (no network). Its wallet mode picks the stamp: paper → PAPER, devnet / live → LIVE. A real-mode row is
10
+ // refused by the adapter (dexterm only modelled that fill).
11
+ // Addresses are masked before they reach the card (the stream copy is already redacted; a ledger mint is masked
12
+ // here), the size is a band, and the wallet is never drawn. A ledger row's tx signatures stay in the card input so
13
+ // the LIVE note counts them truthfully; the renderer prints a signature only with showWallet, which the overlay
14
+ // never sets, and only the PNG leaves this module.
15
+ import { computeCard, cardPng, stampOf, CardError, CARD_GEOMETRY, ledgerCardInput } from "../pnl-card.js";
16
+ import { maskAddress } from '../../../agents/src/index.js';
17
+ import { THEMES } from '../../../tui/src/index.js';
18
+ import { fmtAgeShort } from "./model.js";
19
+ /** Paper fills carry whole-token amounts; the card's integer token maths uses this many decimals for them. */
20
+ const STREAM_DECIMALS = 6;
21
+ /** Card input from the stream's PAPER fills on one coin. The mark (open position) is the newest stream price. */
22
+ export function streamCardInput(state, mint) {
23
+ const p = state.paper.get(mint);
24
+ if (!p || !p.fills.length)
25
+ return null;
26
+ const scale = 10 ** STREAM_DECIMALS;
27
+ const fills = p.fills.map((f) => ({
28
+ t: f.t, side: f.side, solDelta: (f.side === 'buy' ? -1 : 1) * Math.abs(f.sol),
29
+ tokenDelta: BigInt(Math.round(f.qty * scale)) * (f.side === 'buy' ? 1n : -1n), feeSol: null, sig: null,
30
+ }));
31
+ // Mark: the newest price the stream carried, in SOL via the SOL/USD rate the newest priced fill implies
32
+ // (its USD price ÷ its SOL price). Without a later price, the last fill's own SOL price marks the position.
33
+ const last = p.fills[p.fills.length - 1];
34
+ const lastSolPx = Math.abs(last.sol) / last.qty;
35
+ const priced = [...p.fills].reverse().find((f) => f.priceUsd !== null && f.priceUsd > 0);
36
+ const c = state.coins.get(mint);
37
+ let mark = { priceSol: lastSolPx, at: last.t, via: 'last paper fill' };
38
+ if (priced && c?.priceUsd && c.priceUsd.at > last.t) {
39
+ const solUsd = priced.priceUsd / (Math.abs(priced.sol) / priced.qty);
40
+ if (solUsd > 0 && Number.isFinite(solUsd))
41
+ mark = { priceSol: c.priceUsd.value / solUsd, at: c.priceUsd.at - c.priceUsd.ageSec * 1000, via: 'stream price, SOL/USD implied by the paper fill' };
42
+ }
43
+ return {
44
+ source: 'paper', mint, symbol: p.symbol, name: null, decimals: STREAM_DECIMALS, fills,
45
+ ledger: 'agent paper desk via the spectator stream', wallet: null, mark,
46
+ excluded: [], fees: 'as booked by the paper desk (simulated)', image: null, supply: null,
47
+ };
48
+ }
49
+ /** Card input from one ledger row, the mint masked first (the overlay never shows a full address). */
50
+ export function ledgerOverlayInput(row, solUsd) {
51
+ const input = ledgerCardInput(row, { solUsd });
52
+ return { ...input, mint: maskAddress(row.mint) };
53
+ }
54
+ function header(state, s, now) {
55
+ const ageSec = s ? Math.max(0, now - (s.open && s.input.mark ? s.input.mark.at : s.lastT)) / 1000 : null;
56
+ return { sample: state.sample, ageSec, age: ageSec === null ? 'no data yet' : `data age ${fmtAgeShort(ageSec)}` };
57
+ }
58
+ /** Render the PnL scene. `input` null → the stream's current coin. Never throws: a refusal becomes the note. */
59
+ export function pnlView(state, now, input) {
60
+ const g = CARD_GEOMETRY.wide;
61
+ const inp = input ?? (state.paperCurrent ? streamCardInput(state, state.paperCurrent) : null);
62
+ if (!inp)
63
+ return { scene: 'pnl', ...header(state, null, now), stamp: null, strip: null, png: null, w: g.w, h: g.h, note: 'no PAPER trade by the agent yet' };
64
+ let s;
65
+ try {
66
+ s = computeCard(inp, now);
67
+ }
68
+ catch (e) {
69
+ if (e instanceof CardError)
70
+ return { scene: 'pnl', ...header(state, null, now), stamp: null, strip: null, png: null, w: g.w, h: g.h, note: e.message };
71
+ throw e;
72
+ }
73
+ const png = cardPng(s, { aspect: 'wide' }).toString('base64');
74
+ return { scene: 'pnl', ...header(state, s, now), stamp: inp.source === 'paper' ? 'PAPER' : 'LIVE', strip: stampOf(s, THEMES.cyberspace).strip, png, w: g.w, h: g.h, note: inp.ledger };
75
+ }