@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,309 @@
1
+ import QRCode from "qrcode";
2
+ import { Contract, JsonRpcProvider, FetchRequest, id as eventId } from "ethers";
3
+ import { AgentError } from "./contracts.js";
4
+ import { cash, D, uid, now } from "./domain.js";
5
+ const chains = {
6
+ Ethereum: "1",
7
+ Arbitrum: "42161",
8
+ Base: "8453",
9
+ Polygon: "137",
10
+ };
11
+ const bridgeUrl = "https://bridge.polymarket.com";
12
+ const validAddress = (value) => typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value);
13
+ export class DepositReads {
14
+ core;
15
+ network;
16
+ request;
17
+ constructor(core, network, request = fetch) {
18
+ this.core = core;
19
+ this.network = network;
20
+ this.request = request;
21
+ }
22
+ async bridge(path, body) {
23
+ const response = await this.request(`${bridgeUrl}/${path}`, {
24
+ method: body ? "POST" : "GET",
25
+ headers: { "Content-Type": "application/json" },
26
+ ...(body ? { body: JSON.stringify(body) } : {}),
27
+ signal: AbortSignal.timeout(12000),
28
+ redirect: "error",
29
+ });
30
+ if (!response.ok)
31
+ throw new AgentError("DEPOSIT_PROVIDER_UNAVAILABLE", "Deposit provider did not return verified funding details.", true, 503);
32
+ return response.json();
33
+ }
34
+ async options() {
35
+ const routes = this.network === "mainnet"
36
+ ? [
37
+ {
38
+ market: "perps",
39
+ asset: "USDC",
40
+ network: "Arbitrum",
41
+ destination: "Ride funding wallet",
42
+ activation_required: true,
43
+ },
44
+ ]
45
+ : [];
46
+ const supported = await this.bridge("supported-assets");
47
+ for (const item of supported.supportedAssets ??
48
+ supported.supported_assets ??
49
+ []) {
50
+ const symbol = item.token?.symbol ?? item.symbol, chain = String(item.chainId ?? item.chain_id);
51
+ const network = Object.keys(chains).find((n) => chains[n] === chain);
52
+ if (network &&
53
+ ["USDC", "USDT"].includes(symbol) &&
54
+ validAddress(item.token?.address))
55
+ routes.push({
56
+ market: "prediction",
57
+ asset: symbol,
58
+ network,
59
+ min_deposit_usd: item.minCheckoutUsd ?? item.min_deposit_usd ?? null,
60
+ destination: "Prediction account",
61
+ activation_required: false,
62
+ });
63
+ }
64
+ return {
65
+ routes,
66
+ notes: [
67
+ "Only verified provider routes are offered. Sending to a funding wallet does not fund Hyperliquid until the existing Ride deposit step is completed.",
68
+ ],
69
+ };
70
+ }
71
+ async walletBalance(address) {
72
+ const request = new FetchRequest(process.env.RIDE_AGENT_ARBITRUM_RPC_URL ?? "https://arb1.arbitrum.io/rpc");
73
+ request.timeout = 12000;
74
+ const rpc = new JsonRpcProvider(request, 42161, { staticNetwork: true });
75
+ try {
76
+ if (Number(await rpc.send("eth_chainId", [])) !== 42161)
77
+ throw new AgentError("DEPOSIT_NETWORK_MISMATCH", "Funding RPC returned a different chain; no deposit address is shown.", true, 503);
78
+ const token = new Contract("0xaf88d065e77c8cC2239327C5EDb3A432268e5831", ["function balanceOf(address) view returns (uint256)"], rpc);
79
+ const balance = await token.balanceOf(address);
80
+ return cash(D(balance.toString()).div(1e6));
81
+ }
82
+ finally {
83
+ rpc.destroy();
84
+ }
85
+ }
86
+ async address(user, input, portfolioId) {
87
+ if (input.market === "perps") {
88
+ if (this.network !== "mainnet" ||
89
+ input.asset !== "USDC" ||
90
+ input.network !== "Arbitrum")
91
+ throw new AgentError("UNSUPPORTED_DEPOSIT_ROUTE", "Perps funding currently accepts native USDC on Arbitrum into the Ride funding wallet; complete the venue deposit in Ride.", false, 400);
92
+ let owned;
93
+ try {
94
+ owned = await this.core("perps_wallet", { user_id: user });
95
+ }
96
+ catch {
97
+ owned = await this.core("perps_funding_prepare", { user_id: user });
98
+ }
99
+ if (!validAddress(owned.master))
100
+ throw new AgentError("ACCOUNT_NOT_READY", "Prepare your existing Ride trading account before requesting an address.");
101
+ return {
102
+ id: uid("deposit"),
103
+ market: "perps",
104
+ asset: "USDC",
105
+ network: "Arbitrum",
106
+ address: owned.master,
107
+ provider: "wallet",
108
+ created_at: now(),
109
+ baseline_wallet_usdc: await this.walletBalance(owned.master),
110
+ ...(input.amount_usdc ? { amount_usdc: cash(input.amount_usdc) } : {}),
111
+ };
112
+ }
113
+ const supported = await this.bridge("supported-assets");
114
+ const assetContracts = (supported.supportedAssets ?? [])
115
+ .filter((item) => String(item.chainId) === chains[input.network] &&
116
+ item.token?.symbol === input.asset &&
117
+ validAddress(item.token?.address))
118
+ .map((item) => item.token.address.toLowerCase());
119
+ const route = (await this.options()).routes.find((r) => r.market === input.market &&
120
+ r.asset === input.asset &&
121
+ r.network === input.network);
122
+ if (!route)
123
+ throw new AgentError("UNSUPPORTED_DEPOSIT_ROUTE", "This asset/network route is not currently supported by the provider.", false, 400);
124
+ if (input.amount_usdc &&
125
+ route.min_deposit_usd &&
126
+ D(input.amount_usdc).lt(route.min_deposit_usd))
127
+ throw new AgentError("DEPOSIT_BELOW_MINIMUM", "The amount is below the provider's current deposit minimum.", false, 400);
128
+ const owned = await this.core("prediction_wallet", {
129
+ user_id: user,
130
+ portfolio_id: portfolioId ?? null,
131
+ });
132
+ if (!validAddress(owned.wallet))
133
+ throw new AgentError("ACCOUNT_NOT_READY", "Prepare your existing prediction account in Ride first.");
134
+ const response = await this.bridge("deposit", { address: owned.wallet });
135
+ const address = response.address?.evm ?? response.addresses?.evm;
136
+ if (!validAddress(address))
137
+ throw new AgentError("DEPOSIT_PROVIDER_UNAVAILABLE", "The provider returned an invalid deposit address.", true, 503);
138
+ return {
139
+ id: uid("deposit"),
140
+ market: "prediction",
141
+ asset: input.asset,
142
+ network: input.network,
143
+ address,
144
+ provider: "polymarket_bridge",
145
+ asset_contracts: assetContracts,
146
+ created_at: now(),
147
+ ...(input.amount_usdc ? { amount_usdc: cash(input.amount_usdc) } : {}),
148
+ };
149
+ }
150
+ async status(deposit, user) {
151
+ if (deposit.provider === "wallet") {
152
+ const balance = await this.walletBalance(deposit.address), received = D(balance).minus(deposit.baseline_wallet_usdc ?? balance);
153
+ let trading = {
154
+ trading_balance_usdc: null,
155
+ available_to_copy: false,
156
+ trading_observation: "unavailable",
157
+ };
158
+ if (user) {
159
+ const owned = await this.core("perps_wallet", { user_id: user });
160
+ if (!validAddress(owned.master) ||
161
+ owned.master.toLowerCase() !== deposit.address.toLowerCase())
162
+ throw new AgentError("FUNDING_WALLET_CHANGED", "This receiving account is no longer the active trading account. Review a new request.");
163
+ const response = await this.request("https://api.hyperliquid.xyz/info", {
164
+ method: "POST",
165
+ headers: { "Content-Type": "application/json" },
166
+ body: JSON.stringify({
167
+ type: "clearinghouseState",
168
+ user: owned.master,
169
+ }),
170
+ signal: AbortSignal.timeout(12000),
171
+ redirect: "error",
172
+ });
173
+ if (!response.ok)
174
+ throw new AgentError("TRADING_BALANCE_UNAVAILABLE", "Trading balance could not be verified.", true, 503);
175
+ const state = (await response.json());
176
+ if (typeof state.withdrawable !== "string" ||
177
+ !state.marginSummary ||
178
+ !Array.isArray(state.assetPositions) ||
179
+ !D(state.withdrawable).isFinite() ||
180
+ D(state.withdrawable).lt(0))
181
+ throw new AgentError("TRADING_BALANCE_UNAVAILABLE", "Trading balance evidence is incomplete.", true, 503);
182
+ const active = owned.status === 4 || owned.status === "active";
183
+ trading = {
184
+ trading_balance_usdc: cash(state.withdrawable),
185
+ trading_observed_at: now(),
186
+ available_to_copy: active && D(state.withdrawable).gt(0),
187
+ sufficient_for_amount: D(state.withdrawable).gte(deposit.funding?.amount_usdc ?? deposit.amount_usdc ?? "5"),
188
+ activation_required: !active,
189
+ trading_observation: "verified",
190
+ };
191
+ }
192
+ let fundingReceipt = "unavailable", verifiedCredit = false;
193
+ const f = deposit.funding;
194
+ if (f?.transaction_hash) {
195
+ const response = await this.request(process.env.RIDE_AGENT_ARBITRUM_RPC_URL ??
196
+ "https://arb1.arbitrum.io/rpc", {
197
+ method: "POST",
198
+ headers: { "Content-Type": "application/json" },
199
+ body: JSON.stringify({
200
+ jsonrpc: "2.0",
201
+ id: 1,
202
+ method: "eth_getTransactionReceipt",
203
+ params: [f.transaction_hash],
204
+ }),
205
+ signal: AbortSignal.timeout(12000),
206
+ redirect: "error",
207
+ });
208
+ if (!response.ok)
209
+ throw new AgentError("FUNDING_RECEIPT_UNAVAILABLE", "Funding receipt is not available; do not resubmit.", true, 503);
210
+ const receipt = (await response.json()).result;
211
+ if (!receipt)
212
+ fundingReceipt = "pending";
213
+ else if (receipt.status === "0x0")
214
+ fundingReceipt = "reverted";
215
+ else if (receipt.status === "0x1" &&
216
+ receipt.transactionHash?.toLowerCase() ===
217
+ f.transaction_hash.toLowerCase() &&
218
+ receipt.to?.toLowerCase() ===
219
+ "0x2df1c51e09aecf9cacb7bc98cb1742757f163df7") {
220
+ const amountRaw = BigInt(D(f.amount_usdc).times(1e6).toFixed(0));
221
+ const transferred = (receipt.logs ?? []).some((log) => log.address?.toLowerCase() ===
222
+ "0xaf88d065e77c8cc2239327c5edb3a432268e5831" &&
223
+ log.topics?.[0] ===
224
+ eventId("Transfer(address,address,uint256)") &&
225
+ log.topics?.[1]?.slice(-40).toLowerCase() ===
226
+ deposit.address.slice(2).toLowerCase() &&
227
+ log.topics?.[2]?.slice(-40).toLowerCase() ===
228
+ "2df1c51e09aecf9cacb7bc98cb1742757f163df7" &&
229
+ /^0x[a-fA-F0-9]{64}$/.test(log.data) &&
230
+ BigInt(log.data) === amountRaw);
231
+ if (transferred) {
232
+ fundingReceipt = "confirmed";
233
+ verifiedCredit =
234
+ trading.trading_observation === "verified" &&
235
+ D(trading.trading_balance_usdc).gte(D(f.quote.baseline_trading_available_usdc ?? "0").plus(f.amount_usdc));
236
+ }
237
+ }
238
+ }
239
+ return {
240
+ deposit_id: deposit.id,
241
+ status: trading.available_to_copy
242
+ ? "trading_ready"
243
+ : D(trading.trading_balance_usdc ?? "0").gt(0)
244
+ ? "activation_required"
245
+ : received.gt(0)
246
+ ? "wallet_balance_increased"
247
+ : "awaiting_deposit",
248
+ wallet_balance_usdc: balance,
249
+ received_usdc: received.gt(0) ? cash(received) : null,
250
+ ...trading,
251
+ funding_status: deposit.funding?.status ?? null,
252
+ funding_receipt: fundingReceipt,
253
+ funding_credit_verified: verifiedCredit,
254
+ transaction_hash: deposit.funding?.transaction_hash ?? null,
255
+ next_action: trading.available_to_copy
256
+ ? "Trading balance is verified. Refresh the saved plan before confirming copying."
257
+ : trading.activation_required &&
258
+ D(trading.trading_balance_usdc ?? "0").gt(0)
259
+ ? "Open the Ride funding confirmation page to activate this funded trading account."
260
+ : "Use prepare_deposit with the exact amount to open Ride's funding confirmation page. Wallet receipt alone is not trading credit. Do not repeat an unresolved submission.",
261
+ };
262
+ }
263
+ const response = await this.bridge(`status/${deposit.address}`);
264
+ const transactions = (response.transactions ?? [])
265
+ .filter((t) => Number(t.createdTimeMs) >= deposit.created_at &&
266
+ String(t.fromChainId) === chains[deposit.network] &&
267
+ !!deposit.asset_contracts?.includes(String(t.fromTokenAddress).toLowerCase()))
268
+ .map((t) => ({
269
+ status: String(t.status),
270
+ at: Number(t.createdTimeMs),
271
+ transaction_hash: /^0x[a-fA-F0-9]{64}$/.test(t.txHash)
272
+ ? t.txHash
273
+ : null,
274
+ }));
275
+ return {
276
+ deposit_id: deposit.id,
277
+ status: transactions.some((t) => t.status === "COMPLETED")
278
+ ? "provider_completed"
279
+ : transactions.some((t) => t.status === "FAILED")
280
+ ? "failed"
281
+ : transactions.length
282
+ ? "processing"
283
+ : "awaiting_deposit",
284
+ transactions,
285
+ available_to_copy: null,
286
+ next_action: "Refresh the saved plan; only verified available account collateral can authorize copying.",
287
+ };
288
+ }
289
+ }
290
+ export async function depositView(deposit) {
291
+ return {
292
+ ...deposit,
293
+ baseline_wallet_usdc: undefined,
294
+ asset_contracts: undefined,
295
+ funding: undefined,
296
+ qr_data_url: await QRCode.toDataURL(deposit.address, {
297
+ errorCorrectionLevel: "M",
298
+ margin: 2,
299
+ width: 240,
300
+ }),
301
+ activation_required: deposit.provider === "wallet",
302
+ destination: deposit.provider === "wallet"
303
+ ? "Ride funding wallet"
304
+ : "Prediction bridge",
305
+ instructions: deposit.provider === "wallet"
306
+ ? "Send only native USDC on Arbitrum to this owned funding wallet. Then open the Ride funding confirmation page to move the chosen amount to the trading account, activate it if needed, and verify trading balance."
307
+ : "Use only the selected supported asset and network. Check provider status before refreshing available collateral.",
308
+ };
309
+ }
@@ -0,0 +1,205 @@
1
+ import { AgentError } from "./contracts.js";
2
+ import { recommend, eligibleCandidates, fingerprint, now, uid, cash, D, maximumUserLeverage, } from "./domain.js";
3
+ import { compatibility } from "./conversational.js";
4
+ export function choosePlanPreferences(p, pool, selected) {
5
+ if (selected)
6
+ return {
7
+ ...p,
8
+ portfolio_kind: selected.length === 1 ? "single" : "basket",
9
+ basket_size: selected.length,
10
+ };
11
+ if (p.portfolio_kind === "single" || p.basket_size != null)
12
+ return {
13
+ ...p,
14
+ portfolio_kind: p.portfolio_kind === "single" || p.basket_size === 1
15
+ ? "single"
16
+ : "basket",
17
+ };
18
+ // Prefer up to four feasible sleeves. This is a disclosed suggestion, never a minimum count.
19
+ for (let count = 4; count >= 1; count--) {
20
+ const trial = {
21
+ ...p,
22
+ portfolio_kind: count === 1 ? "single" : "basket",
23
+ basket_size: count,
24
+ };
25
+ try {
26
+ recommend(trial, pool);
27
+ return trial;
28
+ }
29
+ catch (e) {
30
+ if (!(e instanceof AgentError) || e.code !== "NO_EXECUTABLE_PLAN")
31
+ throw e;
32
+ }
33
+ }
34
+ return { ...p, portfolio_kind: "single", basket_size: 1 };
35
+ }
36
+ export function saveDiscovery(s, pref, pool) {
37
+ const at = now();
38
+ s.discovery = {
39
+ id: uid("discovery"),
40
+ preference_hash: fingerprint(pref),
41
+ created_at: at,
42
+ expires_at: Math.min(at + 5 * 60_000, ...pool.filter((c) => c.expires_at > at).map((c) => c.expires_at)),
43
+ candidates: pool,
44
+ };
45
+ }
46
+ export function traderRow(c) {
47
+ return {
48
+ trader_id: c.id,
49
+ nickname: c.handle,
50
+ market: c.market,
51
+ score: c.score,
52
+ score_version: c.score_version,
53
+ assets: c.assets,
54
+ style: c.style,
55
+ historical_roi_pct: c.source_roi_pct,
56
+ historical_drawdown_pct: c.source_drawdown_pct,
57
+ metric_window: c.metric_window ?? null,
58
+ metric_provenance: c.metric_provenance ?? null,
59
+ metric_unavailable_reason: c.metric_unavailable_reason ?? null,
60
+ eligibility: c.eligibility,
61
+ scored_at: c.scored_at,
62
+ };
63
+ }
64
+ export function browse(s, pref, args, user, cursor) {
65
+ const snapshot = s.discovery;
66
+ if (!snapshot ||
67
+ snapshot.expires_at <= now() ||
68
+ snapshot.preference_hash !== fingerprint(pref))
69
+ throw new AgentError("DISCOVERY_REFRESH_REQUIRED", "The candidate snapshot expired or preferences changed. Call recommend_traders to refresh; your choices and saved plan remain available.", true);
70
+ const sort = args.sort_by ?? "score", direction = args.direction ?? (sort === "drawdown" ? "asc" : "desc");
71
+ const query = fingerprint({
72
+ snapshot: snapshot.id,
73
+ sort,
74
+ direction,
75
+ compare_ids: args.compare_ids,
76
+ });
77
+ const decoded = args.cursor ? cursor.decode(user, args.cursor) : null;
78
+ if (decoded && (decoded.id !== query || decoded.at < 0))
79
+ throw new AgentError("INVALID_CURSOR", "Restart this sort or comparison without a cursor; the saved plan is unchanged.");
80
+ const allowed = eligibleCandidates(pref, snapshot.candidates);
81
+ const ids = args.compare_ids;
82
+ const rows = ids
83
+ ? snapshot.candidates.filter((c) => ids.includes(c.id))
84
+ : allowed;
85
+ const metric = (c) => sort === "score"
86
+ ? c.score
87
+ : sort === "drawdown"
88
+ ? c.source_drawdown_pct
89
+ : c.source_roi_pct;
90
+ rows.sort((a, b) => {
91
+ const av = metric(a), bv = metric(b);
92
+ if (av == null && bv == null)
93
+ return a.id.localeCompare(b.id);
94
+ if (av == null)
95
+ return 1;
96
+ if (bv == null)
97
+ return -1;
98
+ return ((direction === "asc" ? av - bv : bv - av) || a.id.localeCompare(b.id));
99
+ });
100
+ const offset = decoded?.at ?? 0, limit = args.limit ?? 5, page = rows.slice(offset, offset + limit);
101
+ const next = offset + page.length;
102
+ return {
103
+ candidates: page,
104
+ candidate_table: page.map(traderRow),
105
+ candidates_meta: {
106
+ total: rows.length,
107
+ returned: page.length,
108
+ offset,
109
+ has_more: next < rows.length,
110
+ next_cursor: next < rows.length ? cursor.encode(user, next, query) : null,
111
+ sort_by: sort,
112
+ direction,
113
+ snapshot_at: snapshot.created_at,
114
+ expires_at: snapshot.expires_at,
115
+ scope: "Entire current verified source pool, not just this page. The production Perps source profiles at most the top 30 by Copy Score; this is not all market traders.",
116
+ historical_metric_note: "Source history is not follower profit. Compare periods and score versions before drawing conclusions; unknown periods are unavailable, never assumed equal.",
117
+ period_comparable: page.length > 0 &&
118
+ page.every((c) => c.metric_window != null) &&
119
+ new Set(page.map((c) => fingerprint(c.metric_window))).size === 1,
120
+ },
121
+ missing_trader_ids: ids?.filter((id) => !rows.some((c) => c.id === id)) ?? [],
122
+ selected_trader_ids: s.selected_trader_ids ?? [],
123
+ saved_plan_id: s.plans.at(-1)?.id ?? null,
124
+ plan_unchanged: true,
125
+ comparison: ids
126
+ ? {
127
+ rows: page.map((c) => ({
128
+ ...traderRow(c),
129
+ matches_preferences: allowed.some((a) => a.id === c.id),
130
+ })),
131
+ overlaps: compatibility({
132
+ allocations: rows.map((c) => ({ trader: c, amount_usdc: "1" })),
133
+ }).pairs,
134
+ historical_loss_correlation: null,
135
+ }
136
+ : null,
137
+ };
138
+ }
139
+ export function conversationView(plan, collection, candidates, selectedByUser) {
140
+ return {
141
+ presentation: "native_conversation",
142
+ preference_collection: collection,
143
+ recommended_portfolio: plan?.allocations.map((a) => ({
144
+ ...traderRow(a.trader),
145
+ amount_usdc: a.amount_usdc,
146
+ leverage_mode: a.leverage_mode ?? "cap",
147
+ leverage_cap: a.leverage_cap,
148
+ maximum_leverage_cap: maximumUserLeverage(a.trader),
149
+ reason: `Copy Score ${a.trader.score}; ${a.trader.style}; ${a.trader.assets.join("/")}; ${plan.allocations.length === 1 ? "single-trader concentration" : `capital spread across ${plan.allocations.length} selected traders`}.`,
150
+ })) ?? [],
151
+ candidate_table: candidates.slice(0, 5).map(traderRow),
152
+ selection_origin: selectedByUser ? "user" : "system_recommendation",
153
+ planning_constraints: {
154
+ max_traders: 8,
155
+ minimum_allocation_usdc: candidates.length && candidates.every((c) => c.market === "prediction")
156
+ ? "50"
157
+ : null,
158
+ perps_product_minimum_usdc: null,
159
+ prediction_product_minimum_usdc: "50",
160
+ type: "Positive Perps USDC allocation; live venue minimums apply to each sized order, not to allocated collateral. Prediction allocation rules are unchanged.",
161
+ order_minimum_validation: "after_netting_and_quantity_rounding_at_execution",
162
+ leverage_mode: plan?.allocations.some((a) => a.leverage_mode === "follow_source")
163
+ ? "follow_source"
164
+ : "cap",
165
+ follow_source_leverage_supported: true,
166
+ follow_source_requires_risk_cap: false,
167
+ follow_source_requires_venue_limits: true,
168
+ risk_controls_required: true,
169
+ maximum_agent_leverage: null,
170
+ maximum_custom_leverage_cap: 40,
171
+ default_perps_leverage_cap: null,
172
+ custom_leverage_cap_supported: true,
173
+ },
174
+ final_review: plan
175
+ ? {
176
+ budget_usdc: plan.preferences.budget_usdc,
177
+ allocated_usdc: cash(plan.allocations.reduce((n, a) => n.plus(a.amount_usdc), D(0))),
178
+ unallocated_usdc: cash(D(plan.preferences.budget_usdc).minus(plan.allocations.reduce((n, a) => n.plus(a.amount_usdc), D(0)))),
179
+ market: plan.preferences.market,
180
+ selected_markets: [
181
+ ...new Set(plan.allocations.map((a) => a.trader.market)),
182
+ ],
183
+ portfolio_loss_trigger_pct: plan.preferences.loss_trigger_pct,
184
+ portfolio_loss_trigger_usdc: cash(D(plan.preferences.budget_usdc)
185
+ .times(plan.preferences.loss_trigger_pct)
186
+ .div(100)),
187
+ traders: plan.allocations.map((a) => ({
188
+ trader_id: a.trader.id,
189
+ amount_usdc: a.amount_usdc,
190
+ copy_mode: a.copy_mode ?? "ride_recommends",
191
+ leverage_cap: a.leverage_cap,
192
+ leverage_mode: a.leverage_mode ?? "cap",
193
+ maximum_leverage_cap: maximumUserLeverage(a.trader),
194
+ stop_loss_pct: a.stop_loss_pct,
195
+ loss_policy: a.loss_policy ?? "exit",
196
+ emergency_exit_pct: a.emergency_exit_pct ?? null,
197
+ })),
198
+ funds_verified: false,
199
+ execution_authorized: false,
200
+ requires_ride_confirmation: true,
201
+ }
202
+ : null,
203
+ instruction: "Show two compact chat tables: recommended portfolio (trader, amount, reason) and top 5 candidates (ID, score, assets, style, history). A follow_source allocation with leverage_cap null follows verified source leverage without an extra user cap by default; say this explicitly. A numeric cap is user-editable independently of the recommendation (Perps 1–40x), and null clears it. Actual leverage still respects the approved optional cap, fresh venue limits, budget and risk checks. Never invent a 3x or 10x default cap. Disclose defaults and risk settings. Offer accept/customize; browse_traders pages or compares without replacing the plan. No funding needed for this preview. Missing history is unavailable. Best means prioritized within current conditions, not highest guaranteed returns.",
204
+ };
205
+ }