@onride/agent 0.0.0-stage → 0.5.1-rc.0

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 (64) hide show
  1. package/.env.example +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +174 -2
  4. package/dist/account_balances.js +435 -0
  5. package/dist/account_reads.js +199 -0
  6. package/dist/agent/adapter.js +115 -0
  7. package/dist/agent/analytics.js +174 -0
  8. package/dist/agent/backend.js +237 -0
  9. package/dist/agent/card_hosts.js +9 -0
  10. package/dist/agent/contracts.js +276 -0
  11. package/dist/agent/conversational.js +86 -0
  12. package/dist/agent/core_bridge.js +1155 -0
  13. package/dist/agent/deposits.js +309 -0
  14. package/dist/agent/discovery.js +205 -0
  15. package/dist/agent/domain.js +507 -0
  16. package/dist/agent/firebase.js +34 -0
  17. package/dist/agent/funding.js +230 -0
  18. package/dist/agent/http.js +40 -0
  19. package/dist/agent/human.js +298 -0
  20. package/dist/agent/human_handoffs.js +39 -0
  21. package/dist/agent/ledger.js +137 -0
  22. package/dist/agent/mcp.js +245 -0
  23. package/dist/agent/order_rules.js +61 -0
  24. package/dist/agent/perps_account.js +41 -0
  25. package/dist/agent/prediction.js +607 -0
  26. package/dist/agent/prediction_equity_history.js +910 -0
  27. package/dist/agent/prediction_history.js +336 -0
  28. package/dist/agent/preferences.js +114 -0
  29. package/dist/agent/repository.js +106 -0
  30. package/dist/agent/runner.js +683 -0
  31. package/dist/agent/service.js +1347 -0
  32. package/dist/backend.js +6 -0
  33. package/dist/bridge.js +6 -0
  34. package/dist/cli/clients.js +99 -0
  35. package/dist/cli/commands.js +332 -0
  36. package/dist/cli/onboarding.js +53 -0
  37. package/dist/cli/session.js +224 -0
  38. package/dist/cli/setup.js +256 -0
  39. package/dist/cli.js +16 -0
  40. package/dist/guide.js +92 -0
  41. package/dist/http_security.js +79 -0
  42. package/dist/mcp-cli.js +7 -0
  43. package/dist/oauth.js +1032 -0
  44. package/dist/server.js +171 -0
  45. package/dist/start.js +18 -0
  46. package/dist/version.js +25 -0
  47. package/docs/confirmation-cards.md +118 -0
  48. package/docs/conversation-v050.md +30 -0
  49. package/docs/conversational-v17.md +48 -0
  50. package/docs/deployment.md +58 -0
  51. package/docs/perps-leverage.md +43 -0
  52. package/docs/prediction-history.md +28 -0
  53. package/docs/preferences-funding-v042.md +17 -0
  54. package/docs/ride-agent-migration.md +127 -0
  55. package/docs/v17-completion-checklist.md +31 -0
  56. package/docs/v17-rollout.md +44 -0
  57. package/docs/widget-account-v043.md +17 -0
  58. package/package.json +75 -4
  59. package/public/agent.html +2313 -0
  60. package/public/confirm.html +2373 -0
  61. package/public/fund.html +563 -0
  62. package/skills/ride/SKILL.md +18 -0
  63. package/skills/ride/references/commands.md +47 -0
  64. package/skills/ride/references/workflow.md +47 -0
@@ -0,0 +1,2313 @@
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ride Agent</title><style>/*INLINE_CSS*/:root {
2
+ font-family:
3
+ Inter,
4
+ -apple-system,
5
+ BlinkMacSystemFont,
6
+ "Segoe UI",
7
+ sans-serif;
8
+ color: #171717;
9
+ background: #fcfbf8;
10
+ font-size: 14px;
11
+ }
12
+ * {
13
+ box-sizing: border-box;
14
+ }
15
+ body {
16
+ margin: 0;
17
+ padding: 20px;
18
+ }
19
+ main {
20
+ max-width: 900px;
21
+ margin: auto;
22
+ }
23
+ .brand {
24
+ font-size: 24px;
25
+ font-weight: 850;
26
+ letter-spacing: -1px;
27
+ }
28
+ .brand span {
29
+ background: #ffd94a;
30
+ padding: 2px 8px;
31
+ border-radius: 6px;
32
+ }
33
+ header {
34
+ display: flex;
35
+ justify-content: space-between;
36
+ align-items: center;
37
+ margin-bottom: 22px;
38
+ }
39
+ h1 {
40
+ font-size: 25px;
41
+ letter-spacing: -0.7px;
42
+ margin: 8px 0 6px;
43
+ }
44
+ h2 {
45
+ font-size: 16px;
46
+ margin: 0 0 12px;
47
+ }
48
+ .muted {
49
+ color: #666;
50
+ line-height: 1.6;
51
+ }
52
+ .card {
53
+ background: white;
54
+ border: 1px solid #e2e2da;
55
+ border-radius: 16px;
56
+ padding: 18px;
57
+ margin-bottom: 12px;
58
+ }
59
+ .grid {
60
+ margin-bottom: 12px;
61
+ display: grid;
62
+ grid-template-columns: repeat(2, minmax(0, 1fr));
63
+ gap: 12px;
64
+ }
65
+ .grid .card {
66
+ margin: 0;
67
+ }
68
+ .row {
69
+ display: flex;
70
+ justify-content: space-between;
71
+ gap: 16px;
72
+ padding: 8px 0;
73
+ border-bottom: 1px solid #f0f0eb;
74
+ }
75
+ .row:last-child {
76
+ border: 0;
77
+ }
78
+ .row strong {
79
+ text-align: right;
80
+ }
81
+ .metric {
82
+ font-size: 24px;
83
+ font-weight: 750;
84
+ }
85
+ .tag {
86
+ border-radius: 99px;
87
+ background: #f1f1e9;
88
+ padding: 5px 9px;
89
+ font-size: 12px;
90
+ }
91
+ .warning {
92
+ border-left: 3px solid #f6d600;
93
+ background: #fffce2;
94
+ padding: 12px;
95
+ line-height: 1.6;
96
+ border-radius: 8px;
97
+ margin: 14px 0;
98
+ }
99
+ button,
100
+ .button {
101
+ background: #ffd94a;
102
+ color: #111;
103
+ border: 0;
104
+ border-radius: 10px;
105
+ padding: 13px 18px;
106
+ font-weight: 750;
107
+ cursor: pointer;
108
+ display: inline-block;
109
+ text-decoration: none;
110
+ }
111
+ button.secondary {
112
+ background: #eee;
113
+ }
114
+ button:disabled {
115
+ opacity: 0.5;
116
+ cursor: wait;
117
+ }
118
+ .actions {
119
+ display: flex;
120
+ gap: 10px;
121
+ flex-wrap: wrap;
122
+ margin-top: 16px;
123
+ }
124
+ .error {
125
+ color: #a32626;
126
+ }
127
+ .good {
128
+ color: #147a4b;
129
+ }
130
+ table {
131
+ border-collapse: collapse;
132
+ width: 100%;
133
+ font-size: 13px;
134
+ }
135
+ th,
136
+ td {
137
+ padding: 10px 6px;
138
+ text-align: left;
139
+ border-bottom: 1px solid #eee;
140
+ }
141
+ .table-wrap {
142
+ overflow-x: auto;
143
+ }
144
+ input,
145
+ select {
146
+ padding: 10px;
147
+ border: 1px solid #ddd;
148
+ border-radius: 8px;
149
+ font: inherit;
150
+ width: 100%;
151
+ margin: 6px 0 14px;
152
+ }
153
+ details {
154
+ margin-top: 14px;
155
+ }
156
+ pre {
157
+ overflow: auto;
158
+ white-space: pre-wrap;
159
+ word-break: break-word;
160
+ font-size: 12px;
161
+ }
162
+ footer {
163
+ margin: 18px 0;
164
+ font-size: 12px;
165
+ color: #666;
166
+ }
167
+ .status {
168
+ font-size: 12px;
169
+ letter-spacing: 0.2px;
170
+ text-transform: uppercase;
171
+ }
172
+ @media (max-width: 520px) {
173
+ body {
174
+ padding: 14px;
175
+ }
176
+ .grid {
177
+ grid-template-columns: 1fr;
178
+ }
179
+ h1 {
180
+ font-size: 22px;
181
+ }
182
+ .card {
183
+ padding: 15px;
184
+ }
185
+ .row {
186
+ gap: 8px;
187
+ }
188
+ .metric {
189
+ font-size: 21px;
190
+ }
191
+ }
192
+ [hidden] {
193
+ display: none !important;
194
+ }
195
+ .field {
196
+ display: block;
197
+ color: #666;
198
+ font-size: 12px;
199
+ margin-top: 10px;
200
+ }
201
+ .field input,
202
+ .field select {
203
+ display: block;
204
+ color: #171717;
205
+ margin-top: 6px;
206
+ }
207
+ td,
208
+ th {
209
+ overflow-wrap: anywhere;
210
+ }
211
+ .card .grid {
212
+ margin-top: 12px;
213
+ }
214
+ .card h2:not(:first-child) {
215
+ margin-top: 20px;
216
+ }
217
+ summary {
218
+ cursor: pointer;
219
+ font-weight: 700;
220
+ padding: 12px 0;
221
+ }
222
+ .grid .card button {
223
+ margin-top: 10px;
224
+ padding: 8px 12px;
225
+ font-size: 12px;
226
+ }
227
+ @media (min-width: 820px) {
228
+ .grid {
229
+ grid-template-columns: repeat(3, minmax(0, 1fr));
230
+ }
231
+ }
232
+
233
+ .performance-chart svg {
234
+ width: 100%;
235
+ height: auto;
236
+ display: block;
237
+ }
238
+ .deposit-qr {
239
+ display: block;
240
+ max-width: 100%;
241
+ height: auto;
242
+ margin: 16px auto;
243
+ }
244
+ .deposit-address {
245
+ display: block;
246
+ overflow-wrap: anywhere;
247
+ padding: 12px;
248
+ background: #f5f3ec;
249
+ border-radius: 8px;
250
+ }
251
+ .field input[type="checkbox"] {
252
+ width: auto;
253
+ }
254
+ .confirmation-frame {
255
+ width: 100%;
256
+ height: 680px;
257
+ border: 1px solid #e2e2da;
258
+ border-radius: 16px;
259
+ background: #fcfbf8;
260
+ }
261
+ </style></head><body><main><header><div class="brand"><span>Ride Agent</span></div><span class="tag">Your trading basket</span></header><div id="content"></div></main><script type="module">/*INLINE_VIEW*/const el = (tag, text, cls) => {
262
+ const node = document.createElement(tag);
263
+ if (text !== undefined && text !== null) node.textContent = String(text);
264
+ if (cls) node.className = cls;
265
+ return node;
266
+ };
267
+ const val = (v) => (v === null || v === undefined ? "Unavailable" : String(v));
268
+ const row = (label, value) => {
269
+ const r = el("div", null, "row");
270
+ r.append(el("span", label, "muted"), el("strong", val(value)));
271
+ return r;
272
+ };
273
+ const card = (title) => {
274
+ const c = el("section", null, "card");
275
+ c.append(el("h2", title));
276
+ return c;
277
+ };
278
+ const usdc = (v) => (v == null ? "Unavailable" : `${v} USDC`);
279
+ const sumCash = (amounts) => {
280
+ const n = amounts.reduce((total, v) => {
281
+ const [whole, fraction = ""] = String(v).split(".");
282
+ return total + BigInt(whole) * 1000000n + BigInt(fraction.padEnd(6, "0"));
283
+ }, 0n);
284
+ return `${n / 1000000n}.${String(n % 1000000n).padStart(6, "0")}`;
285
+ };
286
+ function sleeveCards(
287
+ sleeves,
288
+ callTool,
289
+ addTrader,
290
+ actionLabel = "Add to basket",
291
+ ) {
292
+ const grid = el("div", null, "grid");
293
+ for (const s of sleeves) {
294
+ const c = card(s.trader?.handle ?? s.copy_id);
295
+ c.append(
296
+ el("span", `${s.trader?.market ?? ""} · ${s.trader?.style ?? ""}`, "tag"),
297
+ row("Copy Score", s.trader?.score),
298
+ row("Allocation", usdc(s.amount_usdc)),
299
+ ...(s.leverage_mode != null
300
+ ? [row("Leverage mode", "Follow source leverage")]
301
+ : []),
302
+ row(
303
+ s.leverage_mode != null ? "Your leverage cap" : "Leverage cap",
304
+ s.leverage_cap == null && s.leverage_mode === "follow_source"
305
+ ? "No extra cap"
306
+ : s.leverage_cap == null
307
+ ? null
308
+ : `${s.leverage_cap}x`,
309
+ ),
310
+ row(
311
+ s.loss_policy === "pause_then_exit"
312
+ ? "Pause new exposure"
313
+ : "Sleeve exit trigger",
314
+ s.stop_loss_pct == null ? null : `${s.stop_loss_pct}%`,
315
+ ),
316
+ row("Copy mode", s.copy_mode ?? "Ride Agent recommended"),
317
+ ...(s.copy_mode === "fixed"
318
+ ? [row("Fixed margin per source entry", usdc(s.fixed_margin_usdc))]
319
+ : []),
320
+ ...(s.loss_policy === "pause_then_exit"
321
+ ? [row("Emergency exit", `${s.emergency_exit_pct ?? 70}%`)]
322
+ : []),
323
+ );
324
+ if (s.state)
325
+ c.append(
326
+ row("State", s.state),
327
+ row("Net contribution", usdc(s.net_profit_usdc)),
328
+ );
329
+ if (callTool) {
330
+ const b = el("button", "Trader details", "secondary");
331
+ b.addEventListener("click", () =>
332
+ callTool("get_trader_profile", { trader_id: s.trader.id }),
333
+ );
334
+ c.append(b);
335
+ }
336
+ if (addTrader && s.amount_usdc == null) {
337
+ const add = el("button", actionLabel, "secondary");
338
+ add.addEventListener("click", () => addTrader(s.trader));
339
+ c.append(add);
340
+ }
341
+ grid.append(c);
342
+ }
343
+ return grid;
344
+ }
345
+ function render(
346
+ root,
347
+ value,
348
+ openLink = () => {},
349
+ callTool = () => {},
350
+ confirmationCard,
351
+ ) {
352
+ root.replaceChildren();
353
+ if (!value) {
354
+ welcome(root, callTool);
355
+ return;
356
+ }
357
+ if (value.data?.preference_saved)
358
+ root.append(el("p", "Preferences saved", "status"));
359
+ else if (
360
+ ["queued", "executing", "uncertain", "settling"].includes(value.status)
361
+ )
362
+ root.append(
363
+ el(
364
+ "p",
365
+ {
366
+ queued: "Confirmed changes queued",
367
+ executing: "Execution in progress",
368
+ uncertain: "Execution status needs verification",
369
+ settling: "Settlement pending",
370
+ }[value.status],
371
+ "status",
372
+ ),
373
+ );
374
+ const d = value.data ?? {};
375
+ if (
376
+ confirmationCard &&
377
+ d.proposal &&
378
+ (d.proposal.status === "requires_confirmation" ||
379
+ d.confirmation_demo ||
380
+ d.confirmation_card)
381
+ ) {
382
+ root.append(
383
+ el(
384
+ "h1",
385
+ d.confirmation_demo
386
+ ? "确认卡片测试"
387
+ : d.proposal.status === "requires_confirmation"
388
+ ? "确认本次操作"
389
+ : "操作状态记录",
390
+ ),
391
+ );
392
+ confirmationCard(root, d.proposal, d.confirmation_demo === true);
393
+ return;
394
+ }
395
+ if (d.account?.authenticated) {
396
+ root.append(
397
+ el(
398
+ "p",
399
+ `Signed in${d.account.display_name ? `: ${d.account.display_name}` : ""}`,
400
+ "muted",
401
+ ),
402
+ );
403
+ }
404
+ const appCopies =
405
+ d.app_copies ?? (d.query_scope === "ride_app_copies" ? d : null);
406
+ if (appCopies) {
407
+ const account = card("Your Ride App copies");
408
+ for (const [name, market] of [
409
+ ["Perps", appCopies.perps],
410
+ ["Prediction", appCopies.prediction],
411
+ ]) {
412
+ account.append(
413
+ row(`${name} copy configurations`, market?.configs?.length),
414
+ row(
415
+ `${name} enabled copies`,
416
+ market?.configs?.filter((c) => c.isEnabled).length,
417
+ ),
418
+ row(`${name} current copied positions`, market?.positions?.length),
419
+ );
420
+ }
421
+ account.append(
422
+ el(
423
+ "p",
424
+ appCopies.coverage_complete
425
+ ? "Account read complete. Manage these existing copies in Ride."
426
+ : "Account read incomplete. Missing copies or positions are unavailable.",
427
+ "muted",
428
+ ),
429
+ );
430
+ root.append(account);
431
+ }
432
+ if (d.account_balances)
433
+ renderAccountBalances(root, d.account_balances, callTool);
434
+ if (value.error) root.append(el("p", value.error.message, "warning error"));
435
+ if (d.recommendation_diagnostics && (value.error || !d.plan))
436
+ renderRecommendationDiagnostics(root, d, callTool);
437
+ if (Array.isArray(d.candidates)) renderCandidates(root, d, callTool);
438
+ if (d.market_coverage)
439
+ root.append(
440
+ el(
441
+ "p",
442
+ `Allowed markets: ${d.market_coverage.requested}. Selected: ${d.market_coverage.selected.join(", ")}. Either market may be selected; both are not required.`,
443
+ "muted",
444
+ ),
445
+ );
446
+ if (d.deposit_options)
447
+ renderDepositOptions(root, d.deposit_options, callTool);
448
+ if (d.deposit) renderDeposit(root, d.deposit, callTool);
449
+ if (d.deposit_status) {
450
+ const c = card("Deposit status");
451
+ c.append(
452
+ row("Status", d.deposit_status.status),
453
+ row("Trading available", usdc(d.deposit_status.trading_balance_usdc)),
454
+ row(
455
+ "Saved plan shortfall",
456
+ usdc(d.deposit_status.saved_plan_shortfall_usdc),
457
+ ),
458
+ row("Available to copy", d.deposit_status.available_to_copy),
459
+ el("p", d.deposit_status.next_action, "muted"),
460
+ );
461
+ const check = el("button", "Check again", "secondary");
462
+ check.addEventListener("click", () =>
463
+ callTool("check_deposit_status", {
464
+ deposit_id: d.deposit_status.deposit_id,
465
+ }),
466
+ );
467
+ c.append(check);
468
+ if (d.saved_plan_id) {
469
+ const resume = el("button", "Refresh saved plan and review");
470
+ resume.addEventListener("click", () =>
471
+ callTool("recalculate_plan", { plan_id: d.saved_plan_id }),
472
+ );
473
+ c.append(resume);
474
+ }
475
+ root.append(c);
476
+ }
477
+ if (d.daily_checkin) {
478
+ const c = card(
479
+ d.daily_checkin.offer_review
480
+ ? "Welcome back · daily portfolio review"
481
+ : "Today’s check-in",
482
+ );
483
+ c.append(
484
+ el(
485
+ "p",
486
+ d.daily_checkin.offer_review
487
+ ? "Would you like to review the last 24 hours?"
488
+ : "Your daily check-in has already been offered, or no Agent portfolio is active.",
489
+ "muted",
490
+ ),
491
+ );
492
+ if (d.daily_checkin.offer_review) {
493
+ const b = el("button", "Review performance");
494
+ b.addEventListener("click", () =>
495
+ callTool("get_performance", {
496
+ portfolio_id: d.daily_checkin.portfolio_id,
497
+ period: "daily",
498
+ }),
499
+ );
500
+ c.append(b);
501
+ }
502
+ root.append(c);
503
+ }
504
+ if (d.preference_collection && !d.preference_collection.ready) {
505
+ welcome(
506
+ root,
507
+ callTool,
508
+ d.preference_collection.answers,
509
+ d.preference_collection,
510
+ );
511
+ } else if (d.preferences) {
512
+ root.append(
513
+ el(
514
+ "p",
515
+ `Assets: ${d.preferences.asset_category ?? "any"} · ${d.preferences.assets?.join(", ") || "no symbol restriction"}. Change preferences in the AI conversation.`,
516
+ "muted",
517
+ ),
518
+ );
519
+ if (d.preference_collection?.suggestions)
520
+ root.append(
521
+ el(
522
+ "p",
523
+ `Suggested portfolio loss trigger: ${d.preference_collection.suggestions.loss_trigger_pct}%. Review or change before confirming in Ride; this is not a guaranteed loss limit.`,
524
+ "warning",
525
+ ),
526
+ );
527
+ }
528
+ if (d.funding) {
529
+ const c = card("Fund your trading account"),
530
+ f = d.funding;
531
+ c.append(
532
+ row("Amount", usdc(f.amount_usdc)),
533
+ row("Status", f.status),
534
+ row("Destination", f.destination),
535
+ );
536
+ const b = el("button", "Review and confirm funding in Ride");
537
+ b.addEventListener("click", () => openLink(f.confirmation_url));
538
+ c.append(
539
+ b,
540
+ el(
541
+ "p",
542
+ "No funds move until you confirm in Ride. Submitted or uncertain requests must be checked before retrying.",
543
+ "muted",
544
+ ),
545
+ );
546
+ root.append(c);
547
+ }
548
+ if (d.plan) {
549
+ const plan = d.plan;
550
+ root.append(
551
+ el(
552
+ "h1",
553
+ d.query_scope === "agent_portfolio_and_ride_app"
554
+ ? "Saved basket preview"
555
+ : "Your trader basket",
556
+ ),
557
+ el(
558
+ "p",
559
+ `${usdc(plan.preferences.budget_usdc)} · ${plan.preferences.market} · ${plan.allocations.length} traders`,
560
+ "muted",
561
+ ),
562
+ sleeveCards(plan.allocations, callTool),
563
+ );
564
+ const risk = card("Portfolio parameters");
565
+ risk.append(
566
+ row(
567
+ "Selected allocation",
568
+ usdc(sumCash(plan.allocations.map((a) => a.amount_usdc))),
569
+ ),
570
+ row("Unallocated budget", usdc(d.unallocated_usdc ?? "0.000000")),
571
+ row("Loss trigger", `${plan.preferences.loss_trigger_pct}%`),
572
+ row(
573
+ "Historical portfolio drawdown",
574
+ d.portfolio_historical_drawdown_pct == null
575
+ ? "Unavailable"
576
+ : `${d.portfolio_historical_drawdown_pct}%`,
577
+ ),
578
+ row("Existing entry deviation", "≤ 0.2%"),
579
+ row("Plan expires", new Date(plan.expires_at).toLocaleString()),
580
+ );
581
+ root.append(risk);
582
+ const start = el("button", "Review start proposal");
583
+ start.disabled = plan.expires_at <= Date.now();
584
+ start.addEventListener("click", () =>
585
+ callTool("start_copy", {
586
+ plan_id: plan.id,
587
+ ...(start.dataset.compatibility
588
+ ? { compatibility_ack: start.dataset.compatibility }
589
+ : {}),
590
+ }),
591
+ );
592
+ const refresh = el("button", "Refresh saved plan", "secondary");
593
+ refresh.addEventListener("click", () =>
594
+ callTool("recalculate_plan", {
595
+ plan_id: plan.id,
596
+ }),
597
+ );
598
+
599
+ renderCompatibility(root, plan, d, callTool, start);
600
+ root.append(planEditor(plan, d, callTool, start));
601
+ if (d.query_scope !== "agent_portfolio_and_ride_app")
602
+ root.append(
603
+ el(
604
+ "div",
605
+ "Preview works before funding. Use the Ride funding confirmation page to fund and activate the trading account, then refresh this saved plan and review a new proposal.",
606
+ "warning",
607
+ ),
608
+ );
609
+ const actions = el("div", null, "actions");
610
+ const deposit = el("button", "Deposit into Ride", "secondary");
611
+ deposit.addEventListener("click", () =>
612
+ callTool("get_deposit_options", {}),
613
+ );
614
+ actions.append(start, refresh, deposit);
615
+ root.append(actions);
616
+ root.append(
617
+ el(
618
+ "p",
619
+ "Perps follows source leverage with no extra user cap by default. You can set or clear your cap; exchange limits, budget and risk checks still apply. A single-trader plan is concentrated. Review each trader’s pause/exit rules before confirming.",
620
+ "muted",
621
+ ),
622
+ );
623
+ }
624
+ if (d.portfolio) {
625
+ const p = d.portfolio;
626
+ root.append(
627
+ el("h1", "Your portfolio"),
628
+ el("p", `${p.state} · ${p.id}`, "muted"),
629
+ );
630
+ const overview = card("Agent trading account snapshot");
631
+ overview.append(
632
+ row("Account value", usdc(p.account_value_usdc)),
633
+ row("Reconciled net profit", usdc(p.net_profit_usdc)),
634
+ row("Available funds", usdc(p.available_usdc)),
635
+ row("Pending reserve", usdc(p.reserved_usdc)),
636
+ row("Allocated funds", usdc(p.allocated_usdc)),
637
+ row("Accounting", p.reconciliation),
638
+ );
639
+ const portfolioStop = field(
640
+ overview,
641
+ "Portfolio loss trigger · %",
642
+ "number",
643
+ p.preferences.loss_trigger_pct,
644
+ );
645
+ const changeStop = el(
646
+ "button",
647
+ "Review portfolio stop change",
648
+ "secondary",
649
+ );
650
+ changeStop.addEventListener("click", () =>
651
+ callTool("update_portfolio", {
652
+ portfolio_id: p.id,
653
+ loss_trigger_pct: Number(portfolioStop.value),
654
+ }),
655
+ );
656
+ overview.append(changeStop);
657
+ root.append(overview, sleeveCards(p.sleeves, callTool));
658
+ const actions = el("div", null, "actions");
659
+ for (const [label, name, args] of [
660
+ ["Refresh portfolio", "get_portfolio", { portfolio_id: p.id }],
661
+ ["Review portfolio", "review_portfolio", { portfolio_id: p.id }],
662
+ [
663
+ "Performance history",
664
+ "get_performance",
665
+ { portfolio_id: p.id, period: "weekly" },
666
+ ],
667
+ ["Daily report settings", "get_notification_preferences", {}],
668
+ ]) {
669
+ const b = el("button", label, "secondary");
670
+ b.addEventListener("click", () => callTool(name, args));
671
+ actions.append(b);
672
+ }
673
+ root.append(actions);
674
+ for (const s of p.sleeves) {
675
+ const controls = card(`${s.trader.handle} · manage copy`);
676
+ const diagnostic = el("button", "Explain copy performance", "secondary");
677
+ diagnostic.addEventListener("click", () => {
678
+ const end = Date.now();
679
+ callTool("diagnose_copy", {
680
+ copy_id: s.copy_id,
681
+ start_at: Math.max(
682
+ p.ledger_started_at ?? p.created_at,
683
+ end - 7 * 86400000,
684
+ ),
685
+ end_at: end,
686
+ });
687
+ });
688
+ controls.append(diagnostic);
689
+ if (s.state === "closed") {
690
+ const replacement = field(
691
+ controls,
692
+ "Replacement public trader ID",
693
+ "text",
694
+ "",
695
+ );
696
+ const b = el("button", "Review replacement proposal", "secondary");
697
+ b.addEventListener("click", () =>
698
+ callTool("update_copy", {
699
+ copy_id: s.copy_id,
700
+ replacement_trader_id: replacement.value.trim(),
701
+ }),
702
+ );
703
+ controls.append(
704
+ b,
705
+ el(
706
+ "p",
707
+ "Requires verified released funds; one replacement per rolling seven days. The previous copy remains in history.",
708
+ "muted",
709
+ ),
710
+ );
711
+ } else if (s.state === "active") {
712
+ const close = el("button", "Review immediate exit", "secondary");
713
+ close.addEventListener("click", () =>
714
+ callTool("stop_copy", { copy_id: s.copy_id, mode: "close_now" }),
715
+ );
716
+ controls.append(close);
717
+ }
718
+ const margin = field(
719
+ controls,
720
+ "Allocation · USDC",
721
+ "text",
722
+ s.amount_usdc,
723
+ ),
724
+ leverage = field(
725
+ controls,
726
+ "Leverage cap · optional",
727
+ "number",
728
+ s.leverage_cap ?? "",
729
+ ),
730
+ pause = field(
731
+ controls,
732
+ "Trader pause/exit threshold · %",
733
+ "number",
734
+ s.stop_loss_pct,
735
+ );
736
+ leverage.min = "1";
737
+ leverage.max = s.trader?.market === "prediction" ? "1" : "40";
738
+ leverage.placeholder = "No extra cap · follow source";
739
+ const mode = select(
740
+ controls,
741
+ "Copy mode",
742
+ [
743
+ ["ride_recommends", "Ride Agent recommended"],
744
+ ["fixed", "Fixed margin"],
745
+ ["proportional", "Proportional"],
746
+ ],
747
+ s.copy_mode ?? "ride_recommends",
748
+ );
749
+ const fixed = field(
750
+ controls,
751
+ "Fixed margin per source entry · USDC",
752
+ "text",
753
+ s.fixed_margin_usdc ?? "10",
754
+ ),
755
+ multiplier = field(
756
+ controls,
757
+ "Proportional multiplier",
758
+ "number",
759
+ s.proportional_multiplier ?? 1,
760
+ );
761
+ const lossPolicy = select(
762
+ controls,
763
+ "Trader loss policy",
764
+ [
765
+ ["exit", "Exit at threshold"],
766
+ ["pause_then_exit", "Pause, then emergency exit"],
767
+ ],
768
+ s.loss_policy ?? "exit",
769
+ ),
770
+ emergency = field(
771
+ controls,
772
+ "Emergency exit threshold · %",
773
+ "number",
774
+ s.emergency_exit_pct ?? 70,
775
+ );
776
+ const edit = el("button", "Review parameter change", "secondary");
777
+ edit.disabled = s.state === "closed";
778
+ edit.addEventListener("click", () =>
779
+ callTool("update_copy", {
780
+ copy_id: s.copy_id,
781
+ amount_usdc: margin.value.trim(),
782
+ leverage_cap:
783
+ leverage.value.trim() === "" ? null : Number(leverage.value),
784
+ stop_loss_pct: Number(pause.value),
785
+ copy_mode: mode.value,
786
+ ...(mode.value === "fixed"
787
+ ? { fixed_margin_usdc: fixed.value.trim() }
788
+ : { proportional_multiplier: Number(multiplier.value) }),
789
+ loss_policy: lossPolicy.value,
790
+ ...(lossPolicy.value === "pause_then_exit"
791
+ ? { emergency_exit_pct: Number(emergency.value) }
792
+ : {}),
793
+ }),
794
+ );
795
+ controls.append(edit);
796
+ if (s.state === "risk_off") {
797
+ const resume = el("button", "Review resuming new trades", "secondary");
798
+ resume.addEventListener("click", () =>
799
+ callTool("update_copy", {
800
+ copy_id: s.copy_id,
801
+ resume_new_trades: true,
802
+ }),
803
+ );
804
+ controls.append(resume);
805
+ }
806
+ root.append(controls);
807
+ }
808
+ for (const s of p.sleeves.filter((s) => s.state === "active")) {
809
+ const stop = el("button", `Wind down ${s.trader.handle}`, "secondary");
810
+ stop.addEventListener("click", () =>
811
+ callTool("stop_copy", { copy_id: s.copy_id, mode: "wind_down" }),
812
+ );
813
+ root.append(stop);
814
+ }
815
+ const positions = card("Owned positions · gross and net");
816
+ for (const a of p.positions ?? [])
817
+ positions.append(
818
+ row(
819
+ `${a.asset} · ${a.market}`,
820
+ `Gross ${usdc(a.gross_notional_usdc)} / Net ${usdc(a.net_notional_usdc)}`,
821
+ ),
822
+ );
823
+ root.append(positions);
824
+ if (p.executions?.length) {
825
+ const executions = card("Execution status");
826
+ for (const e of p.executions) executions.append(row(e.key, e.status));
827
+ root.append(executions);
828
+ }
829
+ }
830
+ if (d.trader) {
831
+ const t = d.trader;
832
+ root.append(
833
+ el("h1", t.handle),
834
+ el("p", `${t.market} · ${t.style} · ${t.direction}`, "muted"),
835
+ );
836
+ const profile = card(`Copy Score ${t.score}`);
837
+ profile.append(row("Scoring version", t.score_version));
838
+ for (const [key, value] of Object.entries(t.components ?? {}))
839
+ profile.append(row(key, value));
840
+ profile.append(
841
+ row(
842
+ "Source ROI",
843
+ t.source_roi_pct == null ? "Unavailable" : `${t.source_roi_pct}%`,
844
+ ),
845
+ row(
846
+ "Source drawdown",
847
+ t.source_drawdown_pct == null
848
+ ? "Unavailable"
849
+ : `${t.source_drawdown_pct}%`,
850
+ ),
851
+ );
852
+ const single = el("button", "Copy this trader only", "secondary");
853
+ single.addEventListener("click", () => {
854
+ root.replaceChildren();
855
+ welcome(
856
+ root,
857
+ (name, args) =>
858
+ callTool(
859
+ name,
860
+ name === "recommend_traders"
861
+ ? { ...args, trader_ids: [t.id], batch_action: undefined }
862
+ : args,
863
+ ),
864
+ {
865
+ ...(d.preferences ?? {}),
866
+ portfolio_kind: "single",
867
+ basket_size: 1,
868
+ market: d.preferences?.market ?? t.market,
869
+ },
870
+ );
871
+ });
872
+ profile.append(single);
873
+ if (d.saved_plan && d.saved_plan.preferences.portfolio_kind !== "single") {
874
+ const add = el("button", "Add to saved basket", "secondary");
875
+ add.addEventListener("click", () =>
876
+ addToPlan(d.saved_plan, t, {}, callTool),
877
+ );
878
+ profile.append(add);
879
+ }
880
+ root.append(
881
+ profile,
882
+ el(
883
+ "p",
884
+ "Source performance describes the trader, not your follower account.",
885
+ "muted",
886
+ ),
887
+ );
888
+ }
889
+ if (d.proposal) {
890
+ const q = d.proposal;
891
+ root.append(
892
+ el("h1", "Review before confirming"),
893
+ el("p", `${q.action} · ${q.status}`, "muted"),
894
+ );
895
+ if (q.preview?.portfolio?.portfolio)
896
+ renderNestedPortfolio(root, q.preview.portfolio.portfolio);
897
+ const params = card("Frozen parameters");
898
+ params.append(
899
+ row("Loss trigger amount", usdc(q.preview?.loss_trigger_amount_usdc)),
900
+ row("Existing entry deviation", "≤ 0.2%"),
901
+ row(
902
+ "Fee estimate",
903
+ q.preview?.fees?.estimate_usdc == null
904
+ ? "Unavailable · exact fee at settlement"
905
+ : usdc(q.preview.fees.estimate_usdc),
906
+ ),
907
+ row("Expires", new Date(q.expires_at).toLocaleString()),
908
+ row(
909
+ "Execution state",
910
+ q.status === "requires_confirmation"
911
+ ? "Not submitted"
912
+ : q.execution_tasks?.map((e) => e.status).join(", ") || q.status,
913
+ ),
914
+ );
915
+ const details = el("details");
916
+ details.append(
917
+ el("summary", "View exact requested changes"),
918
+ el("pre", JSON.stringify(q.arguments, null, 2)),
919
+ );
920
+ params.append(details);
921
+ root.append(params);
922
+ if (
923
+ confirmationCard &&
924
+ (q.status === "requires_confirmation" || d.confirmation_demo)
925
+ ) {
926
+ confirmationCard(root, q, d.confirmation_demo === true);
927
+ } else if (q.status === "requires_confirmation" && q.confirmation_url) {
928
+ const b = el("button", "Open Ride confirmation");
929
+ b.addEventListener("click", () => openLink(q.confirmation_url));
930
+ root.append(b);
931
+ }
932
+ }
933
+ if (d.observations) {
934
+ root.append(el("h1", "Portfolio review"));
935
+ for (const o of d.observations)
936
+ root.append(
937
+ el(
938
+ "p",
939
+ Object.entries(o)
940
+ .map(([k, v]) => `${k}: ${val(v)}`)
941
+ .join(" · "),
942
+ "card",
943
+ ),
944
+ );
945
+ if (!d.observations.length)
946
+ root.append(
947
+ el("p", "No actionable observations from current coverage.", "muted"),
948
+ );
949
+ }
950
+ if (d.updates) {
951
+ root.append(el("h1", "Ride Agent updates"));
952
+ for (const u of d.updates) {
953
+ const c = card(u.kind);
954
+ c.append(
955
+ el("p", u.message),
956
+ el("time", new Date(u.at).toLocaleString(), "muted"),
957
+ );
958
+ root.append(c);
959
+ }
960
+ if (!d.updates.length) root.append(el("p", "No new updates.", "muted"));
961
+ }
962
+ if (d.performance) renderPerformance(root, d.performance, callTool);
963
+ if (d.diagnostic) renderDiagnostic(root, d.diagnostic);
964
+ if (d.notification_preferences) renderNotifications(root, d, callTool);
965
+ for (const warning of value.warnings ?? [])
966
+ root.append(el("p", warning, "warning"));
967
+ root.append(
968
+ el(
969
+ "footer",
970
+ `Data as of ${value.as_of ?? "Unavailable"}. Configuration, submission, fill and settlement are separate states.`,
971
+ ),
972
+ );
973
+ }
974
+ const candidateSelections = new WeakMap();
975
+ function renderCandidates(root, data, callTool) {
976
+ const candidates = data.candidates,
977
+ pref = data.preferences ?? data.plan?.preferences;
978
+ const c = card("Eligible trader candidates"),
979
+ selected = new Set(
980
+ candidateSelections.get(data) ??
981
+ data.plan?.allocations.map((a) => a.trader.id) ??
982
+ [],
983
+ );
984
+ const meta = data.candidates_meta ?? { total: candidates.length };
985
+ c.append(
986
+ el(
987
+ "p",
988
+ `${candidates.length} returned · ${meta.total} eligible in total. Candidates and plan generation are separate.`,
989
+ "muted",
990
+ ),
991
+ );
992
+ if (!candidates.length)
993
+ c.append(
994
+ el(
995
+ "p",
996
+ "No eligible candidates under the current filters. Review the diagnostic rules below.",
997
+ ),
998
+ );
999
+ const rows = el("div", null, "grid"),
1000
+ selection = el("p", "Select 1–8 traders to review a plan.", "muted");
1001
+ const generate = el("button", "Generate plan from selected traders");
1002
+ const update = () => {
1003
+ candidateSelections.set(data, [...selected]);
1004
+ generate.disabled = !pref || selected.size < 1 || selected.size > 8;
1005
+ selection.textContent = `${selected.size} traders selected · ${pref?.budget_usdc ?? "Unavailable"} USDC budget · risk and loss settings unchanged`;
1006
+ };
1007
+ let shown = 0;
1008
+ const more = el("button", "Load more candidates", "secondary");
1009
+ const appendPage = () => {
1010
+ for (const t of candidates.slice(shown, shown + 8)) {
1011
+ const r = card(t.handle);
1012
+ r.append(
1013
+ row(
1014
+ "Market / category",
1015
+ `${t.market} / ${t.asset_category ?? "unavailable"}`,
1016
+ ),
1017
+ row("Copy Score", t.score),
1018
+ row("Trading assets", t.assets?.join(", ") || "Unavailable"),
1019
+ row("Qualification", t.eligibility),
1020
+ );
1021
+ const info = (
1022
+ data.recommendation_diagnostics?.candidate_evaluations ?? []
1023
+ ).find((x) => x.trader_id === t.id);
1024
+ if (info)
1025
+ r.append(
1026
+ row(
1027
+ "Fits requested per-trader budget",
1028
+ info.budget_eligible ? "Yes" : "No · review the allocation minimum",
1029
+ ),
1030
+ );
1031
+ const label = el("label", "Select trader", "field"),
1032
+ check = el("input");
1033
+ check.type = "checkbox";
1034
+ check.checked = selected.has(t.id);
1035
+ check.addEventListener("change", () => {
1036
+ if (check.checked) selected.add(t.id);
1037
+ else selected.delete(t.id);
1038
+ update();
1039
+ });
1040
+ label.append(check);
1041
+ const details = el("button", "Trader details", "secondary");
1042
+ details.addEventListener("click", () =>
1043
+ callTool("get_trader_profile", { trader_id: t.id }),
1044
+ );
1045
+ r.append(label, details);
1046
+ rows.append(r);
1047
+ }
1048
+ shown = Math.min(candidates.length, shown + 8);
1049
+ more.hidden = shown >= candidates.length;
1050
+ more.textContent = `Load remaining candidates (${candidates.length - shown})`;
1051
+ };
1052
+ more.addEventListener("click", appendPage);
1053
+ appendPage();
1054
+ update();
1055
+ generate.addEventListener("click", () =>
1056
+ callTool("recommend_traders", {
1057
+ preferences: {
1058
+ ...pref,
1059
+ portfolio_kind: selected.size === 1 ? "single" : "basket",
1060
+ basket_size: selected.size,
1061
+ },
1062
+ trader_ids: [...selected],
1063
+ }),
1064
+ );
1065
+ c.append(rows, more, selection, generate);
1066
+ root.append(c);
1067
+ }
1068
+ function renderRecommendationDiagnostics(root, data, callTool) {
1069
+ const d = data.recommendation_diagnostics,
1070
+ c = card("Why this plan is unavailable");
1071
+ c.append(
1072
+ row("Requested trader count", d.requested_basket_size),
1073
+ row("Failure stage", d.stage),
1074
+ row("Diagnostic ID", d.diagnostic_id),
1075
+ el("p", d.count_semantics ?? "", "muted"),
1076
+ );
1077
+ for (const [label, key] of [
1078
+ ["Source", "source_candidates"],
1079
+ ["Matching market", "market_candidates"],
1080
+ ["Fresh", "fresh_candidates"],
1081
+ ["Qualified", "qualified_candidates"],
1082
+ ["Matching assets", "asset_matching_candidates"],
1083
+ ["Executable risk / mode", "eligible_candidates"],
1084
+ ["Affordable at the requested count", "budget_eligible_candidates"],
1085
+ ])
1086
+ c.append(row(label, d[key]));
1087
+ for (const a of d.adjustments ?? []) {
1088
+ const b = el("button", a.label, "secondary");
1089
+ b.addEventListener("click", () =>
1090
+ callTool("recommend_traders", { preferences: a.preferences }),
1091
+ );
1092
+ c.append(b);
1093
+ }
1094
+ const details = el("details");
1095
+ details.append(el("summary", "View exclusions and affected fields"));
1096
+ for (const r of d.rules ?? [])
1097
+ details.append(
1098
+ el(
1099
+ "p",
1100
+ `${r.trader_id}: ${r.message} Field: ${r.field}. Current: ${JSON.stringify(r.current)}. Required: ${JSON.stringify(r.required)}.`,
1101
+ ),
1102
+ );
1103
+ c.append(details);
1104
+ root.append(c);
1105
+ }
1106
+ function renderAccountBalances(root, balances, callTool) {
1107
+ const c = card("Your account balances"),
1108
+ summary = balances.summary ?? {};
1109
+ c.append(
1110
+ row(
1111
+ "Complete account total · USD",
1112
+ summary.account_value_usd ?? "Unavailable · incomplete coverage",
1113
+ ),
1114
+ row("Verified subtotal · USD", summary.known_subtotal_usd),
1115
+ el(
1116
+ "p",
1117
+ summary.basis ?? "Missing account data is unavailable, never zero.",
1118
+ "muted",
1119
+ ),
1120
+ );
1121
+ for (const [name, market] of Object.entries(balances.markets ?? {})) {
1122
+ const section = card(
1123
+ name === "perps"
1124
+ ? "Perps · Stocks & Crypto"
1125
+ : name === "prediction"
1126
+ ? "Prediction"
1127
+ : "Meme · independent wallet module",
1128
+ );
1129
+ section.append(
1130
+ row("Availability", market.status === "ok" ? "Available" : market.status),
1131
+ row("Account value · USD", market.account_value_usd),
1132
+ row("Available trading collateral", usdc(market.available_trading_usdc)),
1133
+ row("Occupied / reserved", usdc(market.occupied_or_reserved_usdc)),
1134
+ row(
1135
+ market.positions_value_basis?.includes("notional")
1136
+ ? "Positions · gross notional, not account equity"
1137
+ : "Position value · USD",
1138
+ market.positions_value_usd,
1139
+ ),
1140
+ row(
1141
+ "Source time",
1142
+ market.observed_at ?? "Source does not supply a timestamp",
1143
+ ),
1144
+ row("Fetched at", market.fetched_at ?? balances.fetched_at),
1145
+ );
1146
+ if (market.cash_observed_at)
1147
+ section.append(row("Cash source time", market.cash_observed_at));
1148
+ if (market.known_subtotal_usd != null && market.account_value_usd == null)
1149
+ section.append(
1150
+ row("Verified partial value · USD", market.known_subtotal_usd),
1151
+ );
1152
+ if (
1153
+ market.retained_wallet_cash_usdc != null &&
1154
+ Number(market.retained_wallet_cash_usdc) > 0
1155
+ )
1156
+ section.append(
1157
+ row("Retained-wallet cash", usdc(market.retained_wallet_cash_usdc)),
1158
+ row("App display basis · USD", market.app_display_value_usd),
1159
+ el("p", market.app_difference_reason, "muted"),
1160
+ );
1161
+ if (market.reason) section.append(el("p", market.reason, "muted"));
1162
+ c.append(section);
1163
+ }
1164
+ const refresh = el("button", "Refresh all account balances", "secondary");
1165
+ refresh.addEventListener("click", () => callTool("get_portfolio", {}));
1166
+ c.append(refresh);
1167
+ root.append(c);
1168
+ }
1169
+ function field(parent, label, type, value) {
1170
+ const wrap = el("label", label, "field");
1171
+ const input = el("input");
1172
+ input.type = type;
1173
+ input.value = String(value);
1174
+ wrap.append(input);
1175
+ parent.append(wrap);
1176
+ return input;
1177
+ }
1178
+ function select(parent, label, options, value) {
1179
+ const wrap = el("label", label, "field"),
1180
+ input = el("select");
1181
+ for (const [id, text] of options) {
1182
+ const option = el("option", text);
1183
+ option.value = id;
1184
+ input.append(option);
1185
+ }
1186
+ input.value = value;
1187
+ wrap.append(input);
1188
+ parent.append(wrap);
1189
+ return input;
1190
+ }
1191
+ function welcome(root, callTool, pref = {}, collection) {
1192
+ root.append(
1193
+ el("h1", "Plan your copy trading in conversation"),
1194
+ el(
1195
+ "p",
1196
+ collection?.questions?.map((q) => q.question).join(" ") ||
1197
+ "Tell your AI your USDC budget and risk preference: conservative, balanced or aggressive.",
1198
+ "muted",
1199
+ ),
1200
+ el(
1201
+ "p",
1202
+ "Assets default to all Crypto and RWA; Perps and Prediction are both allowed unless you choose otherwise. Ride suggests the trader count and allocation. Review all parameters before confirming in Ride.",
1203
+ "muted",
1204
+ ),
1205
+ );
1206
+ const details = el("details");
1207
+ details.append(el("summary", "Optional: answer here"));
1208
+ const budget =
1209
+ pref.budget_usdc == null
1210
+ ? field(details, "Budget · USDC", "text", "")
1211
+ : null;
1212
+ if (budget) budget.inputMode = "decimal";
1213
+ const tolerance =
1214
+ !pref.risk_tolerance && pref.loss_trigger_pct == null
1215
+ ? select(
1216
+ details,
1217
+ "Risk preference",
1218
+ [
1219
+ ["", "Choose"],
1220
+ ["low", "Conservative"],
1221
+ ["medium", "Balanced"],
1222
+ ["high", "Aggressive"],
1223
+ ],
1224
+ "",
1225
+ )
1226
+ : null;
1227
+ const fixed = collection?.missing_fields?.includes("fixed_margin_usdc")
1228
+ ? field(details, "Fixed margin per source entry · USDC", "text", "")
1229
+ : null;
1230
+ const button = el("button", "Continue in conversation");
1231
+ const validate = () => {
1232
+ button.disabled = !!(
1233
+ (budget && !(Number(budget.value) > 0)) ||
1234
+ (tolerance && !tolerance.value) ||
1235
+ (fixed && !(Number(fixed.value) > 0))
1236
+ );
1237
+ };
1238
+ for (const input of [budget, tolerance, fixed].filter(Boolean))
1239
+ input.addEventListener("input", validate);
1240
+ validate();
1241
+ button.addEventListener("click", () =>
1242
+ callTool("recommend_traders", {
1243
+ preferences: {
1244
+ ...(budget ? { budget_usdc: budget.value.trim() } : {}),
1245
+ ...(tolerance ? { risk_tolerance: tolerance.value } : {}),
1246
+ ...(fixed ? { fixed_margin_usdc: fixed.value.trim() } : {}),
1247
+ },
1248
+ }),
1249
+ );
1250
+ details.append(button);
1251
+ root.append(details);
1252
+ }
1253
+ function planEditor(plan, data, callTool, start) {
1254
+ const c = card(
1255
+ plan.preferences.portfolio_kind === "single"
1256
+ ? "Adjust your trader"
1257
+ : "Adjust your basket",
1258
+ ),
1259
+ rows = el("div", null, "grid"),
1260
+ editors = [];
1261
+ const candidates = data.candidates ?? plan.allocations.map((a) => a.trader);
1262
+ const maximumLimits = new Map(
1263
+ (data.candidate_limits ?? []).map((x) => [
1264
+ x.trader_id,
1265
+ x.maximum_leverage_cap,
1266
+ ]),
1267
+ );
1268
+ const dirty = () => {
1269
+ start.dataset.dirty = "true";
1270
+ start.disabled = true;
1271
+ note.textContent = "Changes need recalculation before you can start.";
1272
+ };
1273
+ const note = el(
1274
+ "p",
1275
+ "Perps defaults to following source leverage with no extra user cap. Leave the cap empty for this default, or set 1–40x like the App. Live exchange limits, budget and risk checks still apply. Recalculate changes before confirming.",
1276
+ "muted",
1277
+ );
1278
+ function add(a) {
1279
+ const r = card("Trader allocation");
1280
+ const trader = select(
1281
+ r,
1282
+ "Trader",
1283
+ candidates.map((t) => [
1284
+ t.id,
1285
+ `${t.handle} · ${t.market} · Score ${t.score}`,
1286
+ ]),
1287
+ a.trader.id,
1288
+ );
1289
+ const amount = field(r, "Allocation · USDC", "text", a.amount_usdc);
1290
+ amount.inputMode = "decimal";
1291
+ const leverage = field(
1292
+ r,
1293
+ "Leverage cap · optional",
1294
+ "number",
1295
+ a.leverage_cap ?? "",
1296
+ );
1297
+ leverage.placeholder = "No extra cap · follow source";
1298
+ leverage.min = "1";
1299
+ leverage.max = String(
1300
+ maximumLimits.get(a.trader.id) ??
1301
+ (a.trader.market === "prediction" ? 1 : 40),
1302
+ );
1303
+ const loss = field(r, "Sleeve loss trigger · %", "number", a.stop_loss_pct);
1304
+ loss.min = "1";
1305
+ loss.max = "100";
1306
+ const mode = select(
1307
+ r,
1308
+ "Copy mode",
1309
+ [
1310
+ ["ride_recommends", "Ride Agent recommended"],
1311
+ ["fixed", "Fixed margin"],
1312
+ ["proportional", "Proportional"],
1313
+ ],
1314
+ a.copy_mode ?? "ride_recommends",
1315
+ ),
1316
+ fixed = field(
1317
+ r,
1318
+ "Fixed margin · USDC",
1319
+ "text",
1320
+ a.fixed_margin_usdc ?? "10",
1321
+ ),
1322
+ multiplier = field(
1323
+ r,
1324
+ "Proportional multiplier",
1325
+ "number",
1326
+ a.proportional_multiplier ?? 1,
1327
+ ),
1328
+ policy = select(
1329
+ r,
1330
+ "Loss policy",
1331
+ [
1332
+ ["exit", "Exit at threshold"],
1333
+ ["pause_then_exit", "Pause then emergency exit"],
1334
+ ],
1335
+ a.loss_policy ?? "exit",
1336
+ ),
1337
+ emergency = field(
1338
+ r,
1339
+ "Emergency exit · %",
1340
+ "number",
1341
+ a.emergency_exit_pct ?? 70,
1342
+ );
1343
+ const editor = {
1344
+ r,
1345
+ trader,
1346
+ amount,
1347
+ leverage,
1348
+ loss,
1349
+ mode,
1350
+ fixed,
1351
+ multiplier,
1352
+ policy,
1353
+ emergency,
1354
+ };
1355
+ editors.push(editor);
1356
+ r.addEventListener("input", dirty);
1357
+ trader.addEventListener("change", () => {
1358
+ const selected = candidates.find((t) => t.id === trader.value);
1359
+ const maximum =
1360
+ maximumLimits.get(trader.value) ??
1361
+ (selected?.market === "prediction" ? 1 : 40);
1362
+ leverage.max = String(maximum);
1363
+ if (selected?.market === "prediction" || leverage.value.trim() !== "")
1364
+ leverage.value = String(Math.min(Number(leverage.value), maximum));
1365
+ dirty();
1366
+ });
1367
+ const remove = el("button", "Remove", "secondary");
1368
+ remove.addEventListener("click", () => {
1369
+ if (
1370
+ editors.length <=
1371
+ (plan.preferences.portfolio_kind === "single"
1372
+ ? 1
1373
+ : plan.preferences.portfolio_kind === "basket"
1374
+ ? 2
1375
+ : 3)
1376
+ ) {
1377
+ note.textContent = "Keep the minimum number of traders for this plan.";
1378
+ return;
1379
+ }
1380
+ editors.splice(editors.indexOf(editor), 1);
1381
+ r.remove();
1382
+ dirty();
1383
+ });
1384
+ r.append(remove);
1385
+ rows.append(r);
1386
+ }
1387
+ plan.allocations.forEach(add);
1388
+ c.append(rows);
1389
+ const plus = el("button", "Add another trader", "secondary");
1390
+ plus.addEventListener("click", () => {
1391
+ if (
1392
+ editors.length >=
1393
+ (plan.preferences.portfolio_kind === "single"
1394
+ ? 1
1395
+ : plan.preferences.portfolio_kind === "basket"
1396
+ ? 8
1397
+ : 6)
1398
+ ) {
1399
+ note.textContent = "This plan has reached its trader limit.";
1400
+ return;
1401
+ }
1402
+ const trader = candidates.find(
1403
+ (t) => !editors.some((e) => e.trader.value === t.id),
1404
+ );
1405
+ if (!trader) {
1406
+ note.textContent = "No additional eligible trader is available.";
1407
+ return;
1408
+ }
1409
+ add({
1410
+ trader,
1411
+ amount_usdc: trader.market === "perps" ? "" : "50",
1412
+ leverage_cap: trader.market === "prediction" ? 1 : null,
1413
+ stop_loss_pct: 30,
1414
+ });
1415
+ dirty();
1416
+ });
1417
+ const recalc = el("button", "Recalculate and review");
1418
+ recalc.addEventListener("click", () =>
1419
+ callTool("recalculate_plan", {
1420
+ plan_id: plan.id,
1421
+ preferences: plan.preferences,
1422
+ allocations: editors.map((e) => ({
1423
+ trader_id: e.trader.value,
1424
+ amount_usdc: e.amount.value.trim(),
1425
+ leverage_cap:
1426
+ e.leverage.value.trim() === "" ? null : Number(e.leverage.value),
1427
+ stop_loss_pct: Number(e.loss.value),
1428
+ copy_mode: e.mode.value,
1429
+ ...(e.mode.value === "fixed"
1430
+ ? { fixed_margin_usdc: e.fixed.value.trim() }
1431
+ : { proportional_multiplier: Number(e.multiplier.value) }),
1432
+ loss_policy: e.policy.value,
1433
+ ...(e.policy.value === "pause_then_exit"
1434
+ ? { emergency_exit_pct: Number(e.emergency.value) }
1435
+ : {}),
1436
+ })),
1437
+ }),
1438
+ );
1439
+ const actions = el("div", null, "actions");
1440
+ actions.append(plus, recalc);
1441
+ c.append(actions, note);
1442
+ const details = el("details");
1443
+ details.append(el("summary", "Edit allocations and traders"), c);
1444
+ return details;
1445
+ }
1446
+ function table(parent, headers, rows) {
1447
+ const wrap = el("div", null, "table-wrap"),
1448
+ t = el("table"),
1449
+ head = el("tr");
1450
+ headers.forEach((v) => head.append(el("th", v)));
1451
+ t.append(head);
1452
+ for (const values of rows) {
1453
+ const r = el("tr");
1454
+ values.forEach((v) => r.append(el("td", val(v))));
1455
+ t.append(r);
1456
+ }
1457
+ wrap.append(t);
1458
+ parent.append(wrap);
1459
+ }
1460
+ function renderPerformance(root, p, callTool) {
1461
+ root.append(el("h1", "Performance history"));
1462
+ const c = card(`${p.period} · ${p.coverage}`);
1463
+ c.append(
1464
+ row("Observed-period net profit", usdc(p.net_profit_usdc)),
1465
+ row("Inception net profit", usdc(p.inception_net_profit_usdc)),
1466
+ );
1467
+ const windowText = (w) =>
1468
+ w
1469
+ ? `${new Date(w.start_at).toLocaleString()} — ${new Date(w.end_at).toLocaleString()}`
1470
+ : null;
1471
+ c.append(
1472
+ row("Requested window", windowText(p.requested_window)),
1473
+ row("Observed window", windowText(p.observed_window)),
1474
+ );
1475
+ if (p.series_truncated)
1476
+ c.append(
1477
+ el(
1478
+ "p",
1479
+ "Displaying the latest 366 daily marks; older snapshots remain stored.",
1480
+ "muted",
1481
+ ),
1482
+ );
1483
+ if (p.missing_reason) c.append(el("p", p.missing_reason, "warning"));
1484
+ const actions = el("div", null, "actions");
1485
+ for (const period of ["daily", "weekly", "inception"]) {
1486
+ const b = el("button", period, "secondary");
1487
+ b.addEventListener("click", () =>
1488
+ callTool("get_performance", { portfolio_id: p.portfolio_id, period }),
1489
+ );
1490
+ actions.append(b);
1491
+ }
1492
+ c.append(actions);
1493
+ const chart = el("div", null, "performance-chart");
1494
+ const chartActions = el("div", null, "actions");
1495
+ for (const [metric, label] of [
1496
+ ["net_profit_usdc", "Cumulative net profit"],
1497
+ ["account_value_usdc", "Account value"],
1498
+ ]) {
1499
+ const button = el("button", label, "secondary");
1500
+ button.addEventListener("click", () =>
1501
+ drawPerformance(chart, p.points, metric, label),
1502
+ );
1503
+ chartActions.append(button);
1504
+ }
1505
+ c.append(chartActions, chart);
1506
+ drawPerformance(chart, p.points, "net_profit_usdc", "Cumulative net profit");
1507
+ table(
1508
+ c,
1509
+ ["Observed at", "Net profit · USDC", "Accounting"],
1510
+ p.points.map((x) => [
1511
+ new Date(x.at).toLocaleString(),
1512
+ x.net_profit_usdc,
1513
+ x.reconciliation,
1514
+ ]),
1515
+ );
1516
+ const latest = p.points.at(-1);
1517
+ if (latest)
1518
+ table(
1519
+ c,
1520
+ ["Historical copy", "State", "Net contribution · USDC"],
1521
+ latest.sleeves.map((s) => [s.handle, s.state, s.net_profit_usdc]),
1522
+ );
1523
+ if (p.recent_fills?.length)
1524
+ table(
1525
+ c,
1526
+ [
1527
+ "Time",
1528
+ "Trader",
1529
+ "Asset",
1530
+ "Quantity",
1531
+ "Fill price",
1532
+ "Realized · USDC",
1533
+ "Fee · USDC",
1534
+ ],
1535
+ p.recent_fills.map((f) => [
1536
+ new Date(f.at).toLocaleString(),
1537
+ f.trader,
1538
+ f.asset,
1539
+ f.quantity,
1540
+ f.price,
1541
+ f.realized_usdc,
1542
+ f.fee_usdc,
1543
+ ]),
1544
+ );
1545
+ c.append(
1546
+ el(
1547
+ "p",
1548
+ "Historical points keep their original trader identities. External deposits and withdrawals are excluded from net profit; unobserved periods stay unavailable.",
1549
+ "muted",
1550
+ ),
1551
+ );
1552
+ root.append(c);
1553
+ }
1554
+
1555
+ function drawPerformance(root, points, metric, label) {
1556
+ root.replaceChildren(el("h3", label));
1557
+ const valid = points.filter(
1558
+ (p) => p[metric] != null && Number.isFinite(Number(p[metric])),
1559
+ );
1560
+ if (!valid.length) {
1561
+ root.append(el("p", "Verified history is unavailable.", "muted"));
1562
+ return;
1563
+ }
1564
+ const ns = "http://www.w3.org/2000/svg",
1565
+ svg = document.createElementNS(ns, "svg");
1566
+ svg.setAttribute("viewBox", "0 0 640 220");
1567
+ svg.setAttribute("role", "img");
1568
+ svg.setAttribute("aria-label", `${label} in USDC`);
1569
+ const min = Math.min(...valid.map((p) => Number(p[metric]))),
1570
+ max = Math.max(...valid.map((p) => Number(p[metric]))),
1571
+ range = Math.max(1, max - min);
1572
+ const first = points[0].at,
1573
+ last = points.at(-1).at,
1574
+ span = Math.max(1, last - first),
1575
+ segments = [];
1576
+ let segment = [];
1577
+ let previous = null;
1578
+ for (const p of points) {
1579
+ if (p[metric] == null || !Number.isFinite(Number(p[metric]))) {
1580
+ if (segment.length) segments.push(segment);
1581
+ segment = [];
1582
+ previous = null;
1583
+ continue;
1584
+ }
1585
+ if (previous && p.at - previous.at > 2 * 86400000) {
1586
+ if (segment.length) segments.push(segment);
1587
+ segment = [];
1588
+ }
1589
+ const x = 62 + ((p.at - first) / span) * 558,
1590
+ y = 178 - ((Number(p[metric]) - min) / range) * 140;
1591
+ segment.push([x, y]);
1592
+ previous = p;
1593
+ const dot = document.createElementNS(ns, "circle");
1594
+ dot.setAttribute("cx", x);
1595
+ dot.setAttribute("cy", y);
1596
+ dot.setAttribute("r", "3");
1597
+ dot.setAttribute("fill", "#8c740c");
1598
+ const title = document.createElementNS(ns, "title");
1599
+ title.textContent = `${new Date(p.at).toLocaleDateString()}: ${p[metric]} USDC`;
1600
+ dot.append(title);
1601
+ svg.append(dot);
1602
+ }
1603
+ if (segment.length) segments.push(segment);
1604
+ for (const values of segments) {
1605
+ const path = document.createElementNS(ns, "polyline");
1606
+ path.setAttribute("points", values.map((v) => v.join(",")).join(" "));
1607
+ path.setAttribute("fill", "none");
1608
+ path.setAttribute("stroke", "#c3a017");
1609
+ path.setAttribute("stroke-width", "2");
1610
+ svg.append(path);
1611
+ }
1612
+ for (const [value, y] of [
1613
+ [max, 38],
1614
+ [min, 178],
1615
+ ]) {
1616
+ const text = document.createElementNS(ns, "text");
1617
+ text.setAttribute("x", "4");
1618
+ text.setAttribute("y", y);
1619
+ text.setAttribute("font-size", "11");
1620
+ text.textContent = value.toFixed(2);
1621
+ svg.append(text);
1622
+ }
1623
+ root.append(
1624
+ svg,
1625
+ el(
1626
+ "p",
1627
+ metric === "account_value_usdc"
1628
+ ? "Account value includes external funding. Use net profit to assess trading performance."
1629
+ : "Net profit excludes external deposits and withdrawals. Missing periods remain gaps.",
1630
+ "muted",
1631
+ ),
1632
+ );
1633
+ }
1634
+
1635
+ function renderCompatibility(root, plan, data, callTool, start) {
1636
+ const report = data.compatibility;
1637
+ if (!report) return;
1638
+ const c = card("Portfolio compatibility check");
1639
+ c.append(
1640
+ row("Overlap flags", report.warnings),
1641
+ el("p", report.missing_evidence, "muted"),
1642
+ );
1643
+ table(
1644
+ c,
1645
+ ["Traders", "Shared assets", "Same direction", "Similar holding period"],
1646
+ report.pairs.map((p) => [
1647
+ p.traders
1648
+ .map(
1649
+ (id) =>
1650
+ plan.allocations.find((a) => a.trader.id === id)?.trader.handle ??
1651
+ id,
1652
+ )
1653
+ .join(" / "),
1654
+ p.shared_assets.join(" / "),
1655
+ p.same_direction,
1656
+ p.similar_holding_period,
1657
+ ]),
1658
+ );
1659
+ if (report.warnings && plan.preferences.portfolio_kind) {
1660
+ const ack = field(
1661
+ c,
1662
+ "Keep this selection after reviewing its overlap warnings",
1663
+ "checkbox",
1664
+ "",
1665
+ );
1666
+ start.disabled = true;
1667
+ ack.addEventListener("change", () => {
1668
+ start.disabled =
1669
+ !ack.checked ||
1670
+ start.dataset.dirty === "true" ||
1671
+ plan.expires_at <= Date.now();
1672
+ });
1673
+ start.dataset.compatibility = report.fingerprint;
1674
+ }
1675
+ for (const suggestion of data.alternatives ?? []) {
1676
+ const b = el(
1677
+ "button",
1678
+ `Replace ${plan.allocations.find((a) => a.trader.id === suggestion.outgoing_trader_id)?.trader.handle} with ${suggestion.incoming_trader.handle} · ${suggestion.fewer_flags} fewer flags`,
1679
+ "secondary",
1680
+ );
1681
+ b.addEventListener("click", () =>
1682
+ callTool("recalculate_plan", {
1683
+ plan_id: plan.id,
1684
+ allocations: plan.allocations.map((a) => ({
1685
+ trader_id:
1686
+ a.trader.id === suggestion.outgoing_trader_id
1687
+ ? suggestion.incoming_trader.id
1688
+ : a.trader.id,
1689
+ amount_usdc: a.amount_usdc,
1690
+ leverage_cap: a.leverage_cap,
1691
+ stop_loss_pct: a.stop_loss_pct,
1692
+ ...copyParameters(a),
1693
+ })),
1694
+ }),
1695
+ );
1696
+ c.append(b);
1697
+ }
1698
+ root.append(c);
1699
+ }
1700
+ const copyParameters = (a) =>
1701
+ Object.fromEntries(
1702
+ [
1703
+ "copy_mode",
1704
+ "fixed_margin_usdc",
1705
+ "proportional_multiplier",
1706
+ "loss_policy",
1707
+ "emergency_exit_pct",
1708
+ ]
1709
+ .filter((k) => a[k] != null)
1710
+ .map((k) => [k, a[k]]),
1711
+ );
1712
+
1713
+ function addToPlan(plan, trader, data, callTool) {
1714
+ if (plan.allocations.some((a) => a.trader.id === trader.id)) return;
1715
+ if (plan.preferences.portfolio_kind === "single") {
1716
+ const allocation = plan.allocations[0];
1717
+ const limit =
1718
+ data.candidate_limits?.find((x) => x.trader_id === trader.id)
1719
+ ?.maximum_leverage_cap ?? (trader.market === "prediction" ? 1 : 40);
1720
+ callTool("recalculate_plan", {
1721
+ plan_id: plan.id,
1722
+ preferences: plan.preferences,
1723
+ allocations: [
1724
+ {
1725
+ trader_id: trader.id,
1726
+ amount_usdc: allocation.amount_usdc,
1727
+ leverage_cap:
1728
+ trader.market === "prediction"
1729
+ ? 1
1730
+ : allocation.leverage_cap == null
1731
+ ? null
1732
+ : Math.min(allocation.leverage_cap, limit),
1733
+ stop_loss_pct: allocation.stop_loss_pct,
1734
+ ...copyParameters(allocation),
1735
+ },
1736
+ ],
1737
+ });
1738
+ return;
1739
+ }
1740
+ const max =
1741
+ plan.preferences.portfolio_kind === "single"
1742
+ ? 1
1743
+ : plan.preferences.portfolio_kind === "basket"
1744
+ ? 8
1745
+ : 6;
1746
+ if (plan.allocations.length >= max) return;
1747
+ const values = [
1748
+ ...plan.allocations,
1749
+ {
1750
+ trader,
1751
+ leverage_cap: trader.market === "prediction" ? 1 : null,
1752
+ stop_loss_pct: 30,
1753
+ ...copyParameters(plan.preferences),
1754
+ },
1755
+ ];
1756
+ const [whole, fraction = ""] = String(plan.preferences.budget_usdc).split(
1757
+ ".",
1758
+ ),
1759
+ total = BigInt(whole) * 1000000n + BigInt(fraction.padEnd(6, "0")),
1760
+ count = BigInt(values.length),
1761
+ base = total / count,
1762
+ remainder = total % count;
1763
+ callTool("recalculate_plan", {
1764
+ plan_id: plan.id,
1765
+ preferences: plan.preferences,
1766
+ allocations: values.map((a, i) => {
1767
+ const amount = base + (BigInt(i) < remainder ? 1n : 0n);
1768
+ return {
1769
+ trader_id: a.trader.id,
1770
+ amount_usdc: `${amount / 1000000n}.${String(amount % 1000000n).padStart(6, "0")}`,
1771
+ leverage_cap: a.leverage_cap,
1772
+ stop_loss_pct: a.stop_loss_pct,
1773
+ ...copyParameters(a),
1774
+ };
1775
+ }),
1776
+ });
1777
+ }
1778
+
1779
+ function renderDepositOptions(root, options, callTool) {
1780
+ const c = card("Deposit into your Ride account");
1781
+ const market = select(
1782
+ c,
1783
+ "Destination",
1784
+ [
1785
+ ["perps", "Trading account"],
1786
+ ["prediction", "Prediction account"],
1787
+ ],
1788
+ "perps",
1789
+ );
1790
+ const asset = select(
1791
+ c,
1792
+ "Asset",
1793
+ [
1794
+ ["USDC", "USDC"],
1795
+ ["USDT", "USDT"],
1796
+ ],
1797
+ "USDC",
1798
+ );
1799
+ const network = select(c, "Network", [], "");
1800
+ const update = () => {
1801
+ network.replaceChildren();
1802
+ for (const route of options.routes.filter(
1803
+ (r) => r.market === market.value && r.asset === asset.value,
1804
+ )) {
1805
+ const o = el("option", route.network);
1806
+ o.value = route.network;
1807
+ network.append(o);
1808
+ }
1809
+ b.disabled = !network.options.length;
1810
+ note.textContent = network.options.length
1811
+ ? "Only currently supported routes are shown."
1812
+ : "No verified route for this account and asset. Choose another option.";
1813
+ };
1814
+ const amount = field(
1815
+ c,
1816
+ "Amount · optional, used for minimum checks",
1817
+ "text",
1818
+ "",
1819
+ );
1820
+ const b = el("button", "Get my deposit address and QR"),
1821
+ note = el("p", null, "muted");
1822
+ b.addEventListener("click", () =>
1823
+ callTool("get_deposit_address", {
1824
+ market: market.value,
1825
+ asset: asset.value,
1826
+ network: network.value,
1827
+ ...(amount.value.trim() ? { amount_usdc: amount.value.trim() } : {}),
1828
+ }),
1829
+ );
1830
+ market.addEventListener("change", update);
1831
+ asset.addEventListener("change", update);
1832
+ update();
1833
+ c.append(note, b);
1834
+ for (const n of options.notes ?? []) c.append(el("p", n, "muted"));
1835
+ root.append(c);
1836
+ }
1837
+ function renderDeposit(root, deposit, callTool) {
1838
+ const c = card(`Deposit ${deposit.asset} · ${deposit.network}`);
1839
+ c.append(
1840
+ row("Destination", deposit.destination),
1841
+ row("Network", deposit.network),
1842
+ row("Asset", deposit.asset),
1843
+ );
1844
+ const image = el("img");
1845
+ image.alt = `Receiving address QR for ${deposit.asset} on ${deposit.network}`;
1846
+ image.width = 240;
1847
+ image.height = 240;
1848
+ image.src = deposit.qr_data_url;
1849
+ image.className = "deposit-qr";
1850
+ c.append(
1851
+ image,
1852
+ el("code", deposit.address, "deposit-address"),
1853
+ el("p", deposit.instructions, "warning"),
1854
+ );
1855
+ const b = el("button", "Check deposit status");
1856
+ b.addEventListener("click", () =>
1857
+ callTool("check_deposit_status", { deposit_id: deposit.id }),
1858
+ );
1859
+ c.append(b);
1860
+ if (deposit.provider === "wallet") {
1861
+ const amount = field(
1862
+ c,
1863
+ "Amount to move into trading · USDC",
1864
+ "text",
1865
+ deposit.amount_usdc ?? "",
1866
+ );
1867
+ amount.inputMode = "decimal";
1868
+ const prepare = el("button", "Review trading-account funding", "secondary");
1869
+ prepare.addEventListener("click", () =>
1870
+ callTool("prepare_deposit", {
1871
+ deposit_id: deposit.id,
1872
+ amount_usdc: amount.value.trim(),
1873
+ }),
1874
+ );
1875
+ const validate = () => {
1876
+ prepare.disabled =
1877
+ !/^\d+(\.\d{1,6})?$/.test(amount.value.trim()) ||
1878
+ Number(amount.value) < 5;
1879
+ };
1880
+ amount.addEventListener("input", validate);
1881
+ validate();
1882
+ c.append(prepare);
1883
+ }
1884
+ root.append(c);
1885
+ }
1886
+ function renderDiagnostic(root, d) {
1887
+ root.append(el("h1", `Copy diagnosis · ${d.trader}`));
1888
+ const c = card("Evidence in the same time window");
1889
+ c.append(
1890
+ row(
1891
+ "Window",
1892
+ `${new Date(d.window.start_at).toLocaleString()} — ${new Date(d.window.end_at).toLocaleString()}`,
1893
+ ),
1894
+ row("Source coverage", d.source_coverage),
1895
+ row("Follower coverage", d.follower_coverage),
1896
+ row("Attributed fees", usdc(d.fees_usdc)),
1897
+ );
1898
+ c.append(
1899
+ row(
1900
+ "Follower verified through",
1901
+ d.follower_verified_through
1902
+ ? new Date(d.follower_verified_through).toLocaleString()
1903
+ : null,
1904
+ ),
1905
+ );
1906
+ c.append(el("h2", "Source fills"));
1907
+ table(
1908
+ c,
1909
+ ["Time", "Asset", "Quantity", "Price", "Fee · USDC"],
1910
+ d.source_fills.map((f) => [
1911
+ new Date(f.at).toLocaleString(),
1912
+ f.key,
1913
+ f.quantity,
1914
+ f.price,
1915
+ f.fee_usdc,
1916
+ ]),
1917
+ );
1918
+ c.append(el("h2", "Your attributed fills"));
1919
+ table(
1920
+ c,
1921
+ [
1922
+ "Time",
1923
+ "Asset",
1924
+ "Quantity",
1925
+ "Price",
1926
+ "Fee · USDC",
1927
+ "Leverage cap at fill",
1928
+ ],
1929
+ d.follower_fills.map((r) => [
1930
+ new Date(r.fill.at).toLocaleString(),
1931
+ r.fill.key,
1932
+ r.quantity,
1933
+ r.fill.price,
1934
+ r.fee_usdc,
1935
+ r.config?.leverage_cap ??
1936
+ (r.config?.leverage_mode === "follow_source" ? "No extra cap" : null),
1937
+ ]),
1938
+ );
1939
+ c.append(el("h2", "Entry decisions"));
1940
+ table(
1941
+ c,
1942
+ ["Time", "Asset", "Reason", "Target quantity", "Leverage cap"],
1943
+ d.decisions.map((x) => [
1944
+ new Date(x.at).toLocaleString(),
1945
+ x.key,
1946
+ x.reason,
1947
+ x.target_quantity,
1948
+ x.config.leverage_cap ??
1949
+ (x.config.leverage_mode === "follow_source" ? "No extra cap" : null),
1950
+ ]),
1951
+ );
1952
+ c.append(el("h2", "Execution and funding evidence"));
1953
+ table(
1954
+ c,
1955
+ ["Asset", "Execution state", "Error"],
1956
+ (d.executions ?? []).map((e) => [e.key, e.status, e.error]),
1957
+ );
1958
+ table(
1959
+ c,
1960
+ ["Funding time", "Asset", "Amount · USDC"],
1961
+ (d.funding ?? []).map((f) => [
1962
+ new Date(f.at).toLocaleString(),
1963
+ f.key,
1964
+ f.amount_usdc,
1965
+ ]),
1966
+ );
1967
+ for (const event of d.events ?? [])
1968
+ c.append(el("p", `${event.kind} · ${event.message}`, "muted"));
1969
+ c.append(el("p", d.missing_reason, "warning"));
1970
+ if (d.source_missing_reason)
1971
+ c.append(el("p", d.source_missing_reason, "muted"));
1972
+ root.append(c);
1973
+ }
1974
+ function renderNotifications(root, d, callTool) {
1975
+ root.append(el("h1", "Daily Ride Agent report"));
1976
+ const p = d.notification_preferences,
1977
+ c = card("Notification settings");
1978
+ const enabled = select(
1979
+ c,
1980
+ "Daily report",
1981
+ [
1982
+ ["false", "Off"],
1983
+ ["true", "On · Ride App push"],
1984
+ ],
1985
+ String(p.daily_digest),
1986
+ );
1987
+ const time = field(c, "Local delivery time", "time", p.local_time);
1988
+ const zone = field(c, "Timezone", "text", p.timezone);
1989
+ c.append(
1990
+ row("Push delivery", d.delivery.status),
1991
+ row("Device registered", d.delivery.device_registered ? "Yes" : "No"),
1992
+ );
1993
+ const b = el("button", "Save notification settings");
1994
+ b.addEventListener("click", () =>
1995
+ callTool("set_notification_preferences", {
1996
+ daily_digest: enabled.value === "true",
1997
+ local_time: time.value,
1998
+ timezone: zone.value.trim(),
1999
+ }),
2000
+ );
2001
+ c.append(
2002
+ b,
2003
+ el(
2004
+ "p",
2005
+ "Reports run on the Ride server. Critical risk alerts remain enabled. A registered Ride App device and configured push service are required for delivery.",
2006
+ "muted",
2007
+ ),
2008
+ );
2009
+ root.append(c);
2010
+ }
2011
+ function renderNestedPortfolio(root, p) {
2012
+ root.append(sleeveCards(p.sleeves ?? []));
2013
+ }
2014
+
2015
+ /*INLINE_HOST*/const root = document.getElementById("content");
2016
+ const hostConfig = /*HOST_CONFIG*/{};
2017
+ let requestId = 0;
2018
+ const pending = new Map();
2019
+ const history = [];
2020
+ const frameCleanups = new Set();
2021
+ let connected = false;
2022
+ let fixedHeight = false;
2023
+ let lastHeight;
2024
+ let sizeObserver;
2025
+ let current = window.openai?.toolOutput ?? null;
2026
+ function notifySize() {
2027
+ if (!connected || fixedHeight) return;
2028
+ const height = Math.ceil(document.body.getBoundingClientRect().height);
2029
+ if (!Number.isFinite(height) || height <= 0 || height === lastHeight) return;
2030
+ lastHeight = height;
2031
+ window.parent.postMessage(
2032
+ {
2033
+ jsonrpc: "2.0",
2034
+ method: "ui/notifications/size-changed",
2035
+ params: { height },
2036
+ },
2037
+ "*",
2038
+ );
2039
+ }
2040
+ function showError(message) {
2041
+ root.querySelector(".host-error")?.remove();
2042
+ root.prepend(el("p", message, "warning error host-error"));
2043
+ }
2044
+ function accept(value) {
2045
+ if (value) {
2046
+ if (current) history.push(current);
2047
+ current = value;
2048
+ draw();
2049
+ }
2050
+ }
2051
+ function draw() {
2052
+ for (const cleanup of frameCleanups) cleanup();
2053
+ render(
2054
+ root,
2055
+ current,
2056
+ openLink,
2057
+ callTool,
2058
+ hostConfig.confirmation_origin ? confirmationCard : undefined,
2059
+ );
2060
+ if (history.length) {
2061
+ const back = el("button", "Back", "secondary");
2062
+ back.addEventListener("click", () => {
2063
+ current = history.pop();
2064
+ draw();
2065
+ });
2066
+ root.prepend(back);
2067
+ }
2068
+ }
2069
+ function confirmationCard(container, proposal, demo) {
2070
+ try {
2071
+ const base = new URL(hostConfig.confirmation_origin);
2072
+ const id = proposal.id;
2073
+ if (base.protocol !== "https:" && base.hostname !== "127.0.0.1")
2074
+ throw Error("Secure Ride origin unavailable.");
2075
+ if (
2076
+ !demo &&
2077
+ (!/^proposal_[a-zA-Z0-9_-]{8,100}$/.test(id) ||
2078
+ new URL(proposal.confirmation_url).origin !== base.origin ||
2079
+ new URL(proposal.confirmation_url).pathname !== `/agent/confirm/${id}`)
2080
+ )
2081
+ throw Error(
2082
+ "The confirmation address does not match this Ride proposal.",
2083
+ );
2084
+ const frame = document.createElement("iframe");
2085
+ frame.title = demo ? "Ride 模拟确认卡片" : "Ride 操作确认";
2086
+ frame.className = "confirmation-frame";
2087
+ frame.referrerPolicy = "no-referrer";
2088
+ frame.setAttribute(
2089
+ "sandbox",
2090
+ "allow-scripts allow-same-origin allow-popups allow-forms allow-popups-to-escape-sandbox",
2091
+ );
2092
+ frame.src = new URL(
2093
+ demo ? "/agent/card-demo" : `/agent/card/${id}`,
2094
+ base,
2095
+ ).href;
2096
+ container.append(frame);
2097
+ const state = el("p", "正在连接 Ride 确认卡片…", "muted");
2098
+ state.setAttribute("aria-live", "polite");
2099
+ container.append(state);
2100
+ const readyTimeout = setTimeout(() => {
2101
+ if (!frame.isConnected) {
2102
+ cleanup();
2103
+ return;
2104
+ }
2105
+ frame.hidden = true;
2106
+ state.textContent =
2107
+ "当前客户端未能加载内嵌确认。请在 Ride 页面中打开;有效登录会自动复用。";
2108
+ }, 8000);
2109
+ const onMessage = (event) => {
2110
+ if (!frame.isConnected) {
2111
+ cleanup();
2112
+ return;
2113
+ }
2114
+ if (
2115
+ event.source !== frame.contentWindow ||
2116
+ event.origin !== base.origin ||
2117
+ event.data?.type !== "ride:confirmation-state" ||
2118
+ (!demo && event.data.proposal_id !== id)
2119
+ )
2120
+ return;
2121
+ clearTimeout(readyTimeout);
2122
+ frame.hidden = false;
2123
+ const names = {
2124
+ demo_ready: "模拟卡片已就绪 · 不会交易",
2125
+ demo_confirmed: "模拟确认成功 · 未提交交易",
2126
+ demo_rejected: "模拟已取消 · 未提交交易",
2127
+ sign_in_required: "请在卡片中连接 Ride",
2128
+ requires_confirmation: "请核对方案并确认",
2129
+ queued: "已确认,等待处理",
2130
+ rejected: "方案已取消",
2131
+ confirmed: "已确认",
2132
+ expired: "方案已过期",
2133
+ };
2134
+ state.textContent = names[event.data.status] ?? "Ride 状态已更新";
2135
+ if (Number.isFinite(event.data.height))
2136
+ frame.style.height = `${Math.min(1400, Math.max(420, event.data.height))}px`;
2137
+ };
2138
+ const cleanup = () => {
2139
+ clearTimeout(readyTimeout);
2140
+ window.removeEventListener("message", onMessage);
2141
+ frameCleanups.delete(cleanup);
2142
+ };
2143
+ frameCleanups.add(cleanup);
2144
+ window.addEventListener("message", onMessage);
2145
+ container.append(
2146
+ el(
2147
+ "p",
2148
+ demo
2149
+ ? "0 USDC 界面测试 · 不连接账户或执行服务"
2150
+ : `操作 ${proposal.action} · 状态 ${proposal.status} · 请在 Ride 中核对完整方案`,
2151
+ "muted",
2152
+ ),
2153
+ );
2154
+ if (proposal.confirmation_url) {
2155
+ const fallback = el("button", "在 Ride 页面中打开", "secondary");
2156
+ const fallbackUrl = demo
2157
+ ? new URL("/agent/card-demo", base).href
2158
+ : proposal.confirmation_url;
2159
+ fallback.onclick = () => openLink(fallbackUrl);
2160
+ container.append(fallback);
2161
+ }
2162
+ } catch (error) {
2163
+ showError(error.message);
2164
+ }
2165
+ }
2166
+ function request(method, params) {
2167
+ const id = ++requestId;
2168
+ return new Promise((resolve, reject) => {
2169
+ const timer = setTimeout(() => {
2170
+ pending.delete(id);
2171
+ reject(
2172
+ new Error(
2173
+ "The host did not respond. Use the equivalent Ride tool or CLI command in the conversation.",
2174
+ ),
2175
+ );
2176
+ }, 30000);
2177
+ pending.set(id, { resolve, reject, timer });
2178
+ window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*");
2179
+ });
2180
+ }
2181
+ let busy = false;
2182
+ async function callTool(name, args) {
2183
+ if (busy) return;
2184
+ busy = true;
2185
+ const buttons = [...root.querySelectorAll("button,input,select")].map(
2186
+ (button) => ({ button, disabled: button.disabled }),
2187
+ );
2188
+ buttons.forEach(({ button }) => {
2189
+ button.disabled = true;
2190
+ });
2191
+ try {
2192
+ const reply = window.openai?.callTool
2193
+ ? await window.openai.callTool(name, args)
2194
+ : await request("tools/call", { name, arguments: args });
2195
+ const value = reply?.structuredContent ?? (reply?.version ? reply : null);
2196
+ if (value) accept(value);
2197
+ else {
2198
+ const text = reply?.content?.find((x) => x.type === "text")?.text;
2199
+ if (text) accept(JSON.parse(text));
2200
+ else
2201
+ throw new Error(
2202
+ "The host returned no Ride result. Use the tool in the conversation.",
2203
+ );
2204
+ }
2205
+ } catch (e) {
2206
+ showError(e.message ?? "Ride Agent request failed.");
2207
+ } finally {
2208
+ busy = false;
2209
+ buttons.forEach(({ button, disabled }) => {
2210
+ button.disabled = disabled;
2211
+ });
2212
+ }
2213
+ }
2214
+ async function openLink(url) {
2215
+ try {
2216
+ const target = new URL(url);
2217
+ if (target.protocol !== "https:" && target.hostname !== "127.0.0.1")
2218
+ throw new Error("Ride requires a secure confirmation URL.");
2219
+ const response = window.openai?.openExternal
2220
+ ? await window.openai.openExternal({ href: url })
2221
+ : await request("ui/open-link", { url });
2222
+ if (response?.isError)
2223
+ throw new Error(
2224
+ `The host could not open the link. Open Ride confirmation manually: ${url}`,
2225
+ );
2226
+ } catch (e) {
2227
+ showError(e.message);
2228
+ }
2229
+ }
2230
+ draw();
2231
+ window.addEventListener("openai:set_globals", (event) =>
2232
+ accept(event.detail?.globals?.toolOutput),
2233
+ );
2234
+ window.addEventListener("message", (event) => {
2235
+ if (event.source !== window.parent) return;
2236
+ const msg = event.data;
2237
+ if (msg?.jsonrpc !== "2.0") return;
2238
+ // Requests from the host have their own id namespace. A host ping may have
2239
+ // the same id as our pending ui/initialize; it is not its response.
2240
+ if (typeof msg.method === "string" && msg.id !== undefined) {
2241
+ const supported = ["ping", "ui/resource-teardown"].includes(msg.method);
2242
+ if (msg.method === "ui/resource-teardown") {
2243
+ connected = false;
2244
+ sizeObserver?.disconnect();
2245
+ for (const cleanup of frameCleanups) cleanup();
2246
+ for (const entry of pending.values()) clearTimeout(entry.timer);
2247
+ pending.clear();
2248
+ }
2249
+ window.parent.postMessage(
2250
+ {
2251
+ jsonrpc: "2.0",
2252
+ id: msg.id,
2253
+ ...(supported
2254
+ ? { result: {} }
2255
+ : { error: { code: -32601, message: "Method not found" } }),
2256
+ },
2257
+ "*",
2258
+ );
2259
+ return;
2260
+ }
2261
+ const entry =
2262
+ msg.method === undefined && ("result" in msg || "error" in msg)
2263
+ ? pending.get(msg.id)
2264
+ : undefined;
2265
+ if (entry) {
2266
+ clearTimeout(entry.timer);
2267
+ pending.delete(msg.id);
2268
+ if (msg.error) entry.reject(new Error(msg.error.message));
2269
+ else entry.resolve(msg.result);
2270
+ }
2271
+ if (msg?.method === "ui/notifications/tool-result")
2272
+ if (msg.params?.structuredContent) accept(msg.params.structuredContent);
2273
+ else {
2274
+ const text = msg.params?.content?.find(
2275
+ (item) => item.type === "text",
2276
+ )?.text;
2277
+ if (text) {
2278
+ try {
2279
+ accept(JSON.parse(text));
2280
+ } catch {
2281
+ showError("Ride returned an unreadable result.");
2282
+ }
2283
+ }
2284
+ }
2285
+ if (msg.method === "ui/notifications/host-context-changed") {
2286
+ fixedHeight = typeof msg.params?.containerDimensions?.height === "number";
2287
+ notifySize();
2288
+ }
2289
+ });
2290
+ if (!window.openai?.callTool)
2291
+ request("ui/initialize", {
2292
+ appInfo: { name: "Ride Agent", version: "0.5.1" },
2293
+ appCapabilities: {},
2294
+ protocolVersion: "2026-01-26",
2295
+ })
2296
+ .then((result) => {
2297
+ if (result?.protocolVersion !== "2026-01-26")
2298
+ throw Error("The host did not complete Ride card initialization.");
2299
+ connected = true;
2300
+ fixedHeight =
2301
+ typeof result.hostContext?.containerDimensions?.height === "number";
2302
+ window.parent.postMessage(
2303
+ { jsonrpc: "2.0", method: "ui/notifications/initialized" },
2304
+ "*",
2305
+ );
2306
+ if (typeof ResizeObserver !== "undefined") {
2307
+ sizeObserver = new ResizeObserver(notifySize);
2308
+ sizeObserver.observe(document.body);
2309
+ }
2310
+ notifySize();
2311
+ })
2312
+ .catch((e) => showError(e.message));
2313
+ </script></body></html>