@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.
- package/.env.example +31 -0
- package/LICENSE +21 -0
- package/README.md +174 -2
- package/dist/account_balances.js +435 -0
- package/dist/account_reads.js +199 -0
- package/dist/agent/adapter.js +115 -0
- package/dist/agent/analytics.js +174 -0
- package/dist/agent/backend.js +237 -0
- package/dist/agent/card_hosts.js +9 -0
- package/dist/agent/contracts.js +276 -0
- package/dist/agent/conversational.js +86 -0
- package/dist/agent/core_bridge.js +1155 -0
- package/dist/agent/deposits.js +309 -0
- package/dist/agent/discovery.js +205 -0
- package/dist/agent/domain.js +507 -0
- package/dist/agent/firebase.js +34 -0
- package/dist/agent/funding.js +230 -0
- package/dist/agent/http.js +40 -0
- package/dist/agent/human.js +298 -0
- package/dist/agent/human_handoffs.js +39 -0
- package/dist/agent/ledger.js +137 -0
- package/dist/agent/mcp.js +245 -0
- package/dist/agent/order_rules.js +61 -0
- package/dist/agent/perps_account.js +41 -0
- package/dist/agent/prediction.js +607 -0
- package/dist/agent/prediction_equity_history.js +910 -0
- package/dist/agent/prediction_history.js +336 -0
- package/dist/agent/preferences.js +114 -0
- package/dist/agent/repository.js +106 -0
- package/dist/agent/runner.js +683 -0
- package/dist/agent/service.js +1347 -0
- package/dist/backend.js +6 -0
- package/dist/bridge.js +6 -0
- package/dist/cli/clients.js +99 -0
- package/dist/cli/commands.js +332 -0
- package/dist/cli/onboarding.js +53 -0
- package/dist/cli/session.js +224 -0
- package/dist/cli/setup.js +256 -0
- package/dist/cli.js +16 -0
- package/dist/guide.js +92 -0
- package/dist/http_security.js +79 -0
- package/dist/mcp-cli.js +7 -0
- package/dist/oauth.js +1032 -0
- package/dist/server.js +171 -0
- package/dist/start.js +18 -0
- package/dist/version.js +25 -0
- package/docs/confirmation-cards.md +118 -0
- package/docs/conversation-v050.md +30 -0
- package/docs/conversational-v17.md +48 -0
- package/docs/deployment.md +58 -0
- package/docs/perps-leverage.md +43 -0
- package/docs/prediction-history.md +28 -0
- package/docs/preferences-funding-v042.md +17 -0
- package/docs/ride-agent-migration.md +127 -0
- package/docs/v17-completion-checklist.md +31 -0
- package/docs/v17-rollout.md +44 -0
- package/docs/widget-account-v043.md +17 -0
- package/package.json +75 -4
- package/public/agent.html +2313 -0
- package/public/confirm.html +2373 -0
- package/public/fund.html +563 -0
- package/skills/ride/SKILL.md +18 -0
- package/skills/ride/references/commands.md +47 -0
- package/skills/ride/references/workflow.md +47 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { unzipSync, strFromU8 } from "fflate";
|
|
2
|
+
import { D, now } from "./domain.js";
|
|
3
|
+
import { Gate, } from "./prediction_equity_history.js";
|
|
4
|
+
const DAY = 86_400_000;
|
|
5
|
+
const BASIS = "polymarket_economic_nav_1m_daily_cashflow_adjusted_v1";
|
|
6
|
+
const validNumber = (x) => typeof x === "number" && Number.isFinite(x);
|
|
7
|
+
function requireData(ok, reason) {
|
|
8
|
+
if (!ok)
|
|
9
|
+
throw new Error(reason);
|
|
10
|
+
}
|
|
11
|
+
function sameWallet(a, b) {
|
|
12
|
+
return typeof a === "string" && a.toLowerCase() === b.toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
export function accountingEquity(zip) {
|
|
15
|
+
const files = unzipSync(zip, {
|
|
16
|
+
filter: (f) => {
|
|
17
|
+
if (f.name !== "equity.csv")
|
|
18
|
+
return false;
|
|
19
|
+
requireData(f.originalSize > 0 && f.originalSize <= 4096, "invalid_equity_snapshot");
|
|
20
|
+
return true;
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
requireData(files["equity.csv"], "invalid_equity_snapshot");
|
|
24
|
+
const lines = strFromU8(files["equity.csv"]).trim().split(/\r?\n/);
|
|
25
|
+
requireData(lines.length === 2 &&
|
|
26
|
+
lines[0] === "cashBalance,positionsValue,equity,valuationTime", "invalid_equity_snapshot");
|
|
27
|
+
const fields = lines[1].split(",");
|
|
28
|
+
requireData(fields.length === 4 &&
|
|
29
|
+
fields.slice(0, 3).every((x) => /^\d+(\.\d+)?$/.test(x)), "invalid_equity_snapshot");
|
|
30
|
+
const [cash, positions, equity] = fields.slice(0, 3).map((x) => D(x));
|
|
31
|
+
const at = Date.parse(fields[3]);
|
|
32
|
+
requireData(Number.isFinite(at) &&
|
|
33
|
+
equity.gt(0) &&
|
|
34
|
+
cash.plus(positions).minus(equity).abs().lte("0.000002"), "invalid_equity_snapshot");
|
|
35
|
+
return { equity, at };
|
|
36
|
+
}
|
|
37
|
+
// The PnL endpoint currently leaves cumulative funding null. Its separately
|
|
38
|
+
// paginated DEPOSIT/WITHDRAWAL ledger supplies the missing capital, from genesis.
|
|
39
|
+
// PnL alone (or PnL / current balance) is never treated as an equity return.
|
|
40
|
+
export function predictionMetrics(wallet, series, flows, snapshot, at) {
|
|
41
|
+
requireData(sameWallet(series?.proxy_wallet, wallet) &&
|
|
42
|
+
series.interval === "1m" &&
|
|
43
|
+
series.fidelity === "1d" &&
|
|
44
|
+
series.source_fidelity === "1d", "invalid_history_identity");
|
|
45
|
+
const points = series.points;
|
|
46
|
+
requireData(Array.isArray(points) && points.length >= 29 && points.length <= 33, "insufficient_history");
|
|
47
|
+
requireData(snapshot.at <= at + 60_000 && at - snapshot.at <= 5 * 60_000, "stale_equity_snapshot");
|
|
48
|
+
let priorTime = 0, priorBlock = 0;
|
|
49
|
+
for (const p of points) {
|
|
50
|
+
requireData(Number.isSafeInteger(p.timestamp) &&
|
|
51
|
+
p.timestamp > priorTime &&
|
|
52
|
+
Number.isSafeInteger(p.source_block) &&
|
|
53
|
+
p.source_block > priorBlock &&
|
|
54
|
+
p.source_block > 0, "invalid_or_carried_history_observation");
|
|
55
|
+
requireData(!priorTime || (p.timestamp - priorTime) * 1000 <= 26 * 3_600_000, "history_gap");
|
|
56
|
+
requireData(validNumber(p.economic_pnl), "missing_economic_pnl");
|
|
57
|
+
// Cross-check the source composition; do not substitute zero for an atom.
|
|
58
|
+
requireData(validNumber(p.position_pnl) &&
|
|
59
|
+
validNumber(p.wallet_income) &&
|
|
60
|
+
D(p.position_pnl)
|
|
61
|
+
.plus(p.wallet_income)
|
|
62
|
+
.minus(p.economic_pnl)
|
|
63
|
+
.abs()
|
|
64
|
+
.lte("0.000002"), "invalid_pnl_composition");
|
|
65
|
+
priorTime = p.timestamp;
|
|
66
|
+
priorBlock = p.source_block;
|
|
67
|
+
}
|
|
68
|
+
const first = points[0], last = points.at(-1);
|
|
69
|
+
requireData(snapshot.at >= last.timestamp * 1000, "equity_snapshot_precedes_history");
|
|
70
|
+
requireData(last.timestamp * 1000 <= at + 60_000 &&
|
|
71
|
+
at - last.timestamp * 1000 <= 36 * 3_600_000 &&
|
|
72
|
+
(last.timestamp - first.timestamp) * 1000 >= 28 * DAY &&
|
|
73
|
+
at - first.timestamp * 1000 <= 32 * DAY, "stale_or_short_history");
|
|
74
|
+
const ordered = [...flows].sort((a, b) => a.timestamp - b.timestamp);
|
|
75
|
+
requireData(ordered.length > 0 &&
|
|
76
|
+
ordered.some((f) => f.type === "DEPOSIT" &&
|
|
77
|
+
f.timestamp <= first.timestamp &&
|
|
78
|
+
f.usdc_size > 0), "missing_initial_capital");
|
|
79
|
+
for (const f of ordered) {
|
|
80
|
+
requireData(sameWallet(f.proxy_wallet, wallet) &&
|
|
81
|
+
(f.type === "DEPOSIT" || f.type === "WITHDRAWAL") &&
|
|
82
|
+
Number.isSafeInteger(f.timestamp) &&
|
|
83
|
+
f.timestamp > 0 &&
|
|
84
|
+
f.timestamp * 1000 <= at + 60_000 &&
|
|
85
|
+
validNumber(f.usdc_size) &&
|
|
86
|
+
f.usdc_size >= 0, "invalid_funding_ledger");
|
|
87
|
+
}
|
|
88
|
+
let index = 0, deposits = D(0), withdrawals = D(0), nav = D(1), peak = D(1), drawdown = D(0);
|
|
89
|
+
let previous = null;
|
|
90
|
+
for (const p of points) {
|
|
91
|
+
while (index < ordered.length && ordered[index].timestamp <= p.timestamp) {
|
|
92
|
+
const f = ordered[index++];
|
|
93
|
+
if (f.type === "DEPOSIT")
|
|
94
|
+
deposits = deposits.plus(f.usdc_size);
|
|
95
|
+
else
|
|
96
|
+
withdrawals = withdrawals.plus(f.usdc_size);
|
|
97
|
+
}
|
|
98
|
+
for (const [field, computed] of [
|
|
99
|
+
["deposits", deposits],
|
|
100
|
+
["withdrawals", withdrawals],
|
|
101
|
+
["cashflow_net", deposits.minus(withdrawals)],
|
|
102
|
+
]) {
|
|
103
|
+
requireData(p[field] == null ||
|
|
104
|
+
(validNumber(p[field]) &&
|
|
105
|
+
D(p[field]).minus(computed).abs().lte("0.01")), "conflicting_funding_sources");
|
|
106
|
+
}
|
|
107
|
+
const equity = deposits.minus(withdrawals).plus(p.economic_pnl);
|
|
108
|
+
requireData(equity.gt(0), "nonpositive_historical_equity");
|
|
109
|
+
if (previous) {
|
|
110
|
+
// Daily source observations cannot place returns around intraday flows.
|
|
111
|
+
// Gross deposits go at the start and withdrawals at the end of the day;
|
|
112
|
+
// retain this timing estimate explicitly in the metric provenance.
|
|
113
|
+
nav = nav
|
|
114
|
+
.times(equity.plus(withdrawals.minus(previous.withdrawals)))
|
|
115
|
+
.div(previous.equity.plus(deposits.minus(previous.deposits)));
|
|
116
|
+
peak = D.max(peak, nav);
|
|
117
|
+
drawdown = D.max(drawdown, D(1).minus(nav.div(peak)));
|
|
118
|
+
}
|
|
119
|
+
previous = { equity, deposits, withdrawals };
|
|
120
|
+
}
|
|
121
|
+
// Independent current equity catches absent/partial ledgers and unaccounted
|
|
122
|
+
// transfers. Daily marks can move since the last PnL point: allow at most 1%
|
|
123
|
+
// reconciliation difference, disclose it, and reject larger discrepancies.
|
|
124
|
+
let expectedCurrent = previous.equity;
|
|
125
|
+
for (const f of ordered
|
|
126
|
+
.slice(index)
|
|
127
|
+
.filter((f) => f.timestamp * 1000 <= snapshot.at))
|
|
128
|
+
expectedCurrent = expectedCurrent.plus(f.type === "DEPOSIT" ? f.usdc_size : -f.usdc_size);
|
|
129
|
+
const difference = expectedCurrent
|
|
130
|
+
.minus(snapshot.equity)
|
|
131
|
+
.abs()
|
|
132
|
+
.div(snapshot.equity)
|
|
133
|
+
.times(100);
|
|
134
|
+
requireData(difference.lte(1), "equity_reconciliation_failed");
|
|
135
|
+
return {
|
|
136
|
+
source_roi_pct: nav.minus(1).times(100).toNumber(),
|
|
137
|
+
source_drawdown_pct: drawdown.times(100).toNumber(),
|
|
138
|
+
metric_window: {
|
|
139
|
+
start_at: first.timestamp * 1000,
|
|
140
|
+
end_at: last.timestamp * 1000,
|
|
141
|
+
basis: BASIS,
|
|
142
|
+
},
|
|
143
|
+
metric_provenance: {
|
|
144
|
+
source: "polymarket_data_v2_and_accounting_snapshot",
|
|
145
|
+
sampling: "daily",
|
|
146
|
+
estimated: true,
|
|
147
|
+
cashflow_adjusted: true,
|
|
148
|
+
flow_timing: "deposits_at_start_withdrawals_at_end",
|
|
149
|
+
reconciliation_difference_pct: difference.toNumber(),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export class PredictionHistory {
|
|
154
|
+
equity;
|
|
155
|
+
cache = new Map();
|
|
156
|
+
pending = new Map();
|
|
157
|
+
reconstructed = new Map();
|
|
158
|
+
hydrated = new Map();
|
|
159
|
+
reconstructing = new Map();
|
|
160
|
+
retries = new Map();
|
|
161
|
+
jobs = new Gate(2);
|
|
162
|
+
constructor(equity) {
|
|
163
|
+
this.equity = equity;
|
|
164
|
+
}
|
|
165
|
+
async metrics(wallet) {
|
|
166
|
+
if (!this.equity)
|
|
167
|
+
return this.nativeMetrics(wallet);
|
|
168
|
+
const key = this.equity.key(wallet);
|
|
169
|
+
let hydration = this.hydrated.get(key);
|
|
170
|
+
if (!hydration) {
|
|
171
|
+
hydration = this.equity
|
|
172
|
+
.read(key)
|
|
173
|
+
.then((record) => {
|
|
174
|
+
if (record && this.validRecord(record))
|
|
175
|
+
this.reconstructed.set(key, record);
|
|
176
|
+
})
|
|
177
|
+
.catch(() => { });
|
|
178
|
+
this.hydrated.set(key, hydration);
|
|
179
|
+
}
|
|
180
|
+
await hydration;
|
|
181
|
+
let record = this.reconstructed.get(key);
|
|
182
|
+
if (record && !this.validRecord(record)) {
|
|
183
|
+
this.reconstructed.delete(key);
|
|
184
|
+
record = undefined;
|
|
185
|
+
}
|
|
186
|
+
if (record && record.refresh_at > now())
|
|
187
|
+
return record.metrics;
|
|
188
|
+
const retry = this.retries.get(key);
|
|
189
|
+
if (!this.reconstructing.has(key) && (!retry || retry.at <= now())) {
|
|
190
|
+
const job = this.jobs
|
|
191
|
+
.run(async () => {
|
|
192
|
+
try {
|
|
193
|
+
const result = await this.equity.loader.load(wallet);
|
|
194
|
+
const record = {
|
|
195
|
+
version: 2,
|
|
196
|
+
computed_at: now(),
|
|
197
|
+
refresh_at: now() + 3600_000,
|
|
198
|
+
...result,
|
|
199
|
+
};
|
|
200
|
+
if (!this.validRecord(record))
|
|
201
|
+
throw new Error("invalid_reconstructed_metrics");
|
|
202
|
+
this.reconstructed.set(key, record);
|
|
203
|
+
await this.equity.write(key, record);
|
|
204
|
+
this.retries.delete(key);
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
this.retries.set(key, {
|
|
208
|
+
at: now() + 5 * 60_000,
|
|
209
|
+
reason: e instanceof Error && /^[a-z_]+$/.test(e.message)
|
|
210
|
+
? e.message
|
|
211
|
+
: "equity_reconstruction_unavailable",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
.finally(() => this.reconstructing.delete(key));
|
|
216
|
+
this.reconstructing.set(key, job);
|
|
217
|
+
}
|
|
218
|
+
if (record)
|
|
219
|
+
return record.metrics;
|
|
220
|
+
// Keep the existing verified path available while bounded reconstruction
|
|
221
|
+
// warms. Missing risk data never becomes zero or a smaller risk threshold.
|
|
222
|
+
const fallback = await this.nativeMetrics(wallet);
|
|
223
|
+
const completed = this.reconstructed.get(key);
|
|
224
|
+
if (completed && this.validRecord(completed))
|
|
225
|
+
return completed.metrics;
|
|
226
|
+
if (fallback.metric_window)
|
|
227
|
+
return fallback;
|
|
228
|
+
return {
|
|
229
|
+
...fallback,
|
|
230
|
+
metric_unavailable_reason: this.retries.get(key)?.reason ??
|
|
231
|
+
"equity_history_reconstruction_pending",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
validRecord(r) {
|
|
235
|
+
const m = r.metrics, window = m?.metric_window;
|
|
236
|
+
return (r.version === 2 &&
|
|
237
|
+
Number.isFinite(r.computed_at) &&
|
|
238
|
+
r.computed_at <= now() + 60_000 &&
|
|
239
|
+
Number.isFinite(r.refresh_at) &&
|
|
240
|
+
window?.basis === "polygon_inventory_nav_1m_daily_v2" &&
|
|
241
|
+
window.end_at <= now() + 60_000 &&
|
|
242
|
+
now() - window.end_at < 36 * 3600_000 &&
|
|
243
|
+
window.end_at - window.start_at >= 28 * DAY &&
|
|
244
|
+
validNumber(m.source_drawdown_pct) &&
|
|
245
|
+
m.source_drawdown_pct >= 0 &&
|
|
246
|
+
m.source_drawdown_pct <= 100 &&
|
|
247
|
+
validNumber(m.source_roi_pct) &&
|
|
248
|
+
m.source_roi_pct >= -100 &&
|
|
249
|
+
m.metric_provenance?.source ===
|
|
250
|
+
"polygon_transfers_and_polymarket_historical_marks");
|
|
251
|
+
}
|
|
252
|
+
async nativeMetrics(wallet) {
|
|
253
|
+
const key = wallet.toLowerCase(), cached = this.cache.get(key);
|
|
254
|
+
if (cached && cached.until > now())
|
|
255
|
+
return cached.value;
|
|
256
|
+
const existing = this.pending.get(key);
|
|
257
|
+
if (existing)
|
|
258
|
+
return existing;
|
|
259
|
+
const promise = this.load(key)
|
|
260
|
+
.catch((e) => ({
|
|
261
|
+
source_roi_pct: null,
|
|
262
|
+
source_drawdown_pct: null,
|
|
263
|
+
metric_unavailable_reason: e instanceof Error && /^[a-z_]+$/.test(e.message)
|
|
264
|
+
? e.message
|
|
265
|
+
: "history_source_unavailable",
|
|
266
|
+
}))
|
|
267
|
+
.then((value) => {
|
|
268
|
+
this.cache.set(key, {
|
|
269
|
+
until: value.metric_window
|
|
270
|
+
? Math.min(now() + 10 * 60_000, value.metric_window.end_at + 36 * 3_600_000)
|
|
271
|
+
: now() + 60_000,
|
|
272
|
+
value,
|
|
273
|
+
});
|
|
274
|
+
// This is a bounded display cache, never an execution source snapshot.
|
|
275
|
+
if (this.cache.size > 100)
|
|
276
|
+
this.cache.delete(this.cache.keys().next().value);
|
|
277
|
+
return value;
|
|
278
|
+
})
|
|
279
|
+
.finally(() => this.pending.delete(key));
|
|
280
|
+
this.pending.set(key, promise);
|
|
281
|
+
return promise;
|
|
282
|
+
}
|
|
283
|
+
async load(wallet) {
|
|
284
|
+
const signal = AbortSignal.timeout(6000);
|
|
285
|
+
const read = async (path, params, zip = false) => {
|
|
286
|
+
const url = new URL(path, "https://data-api.polymarket.com");
|
|
287
|
+
for (const [k, v] of Object.entries(params))
|
|
288
|
+
url.searchParams.set(k, v);
|
|
289
|
+
const r = await fetch(url, { signal, redirect: "error" });
|
|
290
|
+
requireData(r.ok && r.body, "history_source_unavailable");
|
|
291
|
+
let size = 0;
|
|
292
|
+
const chunks = [];
|
|
293
|
+
for await (const chunk of r.body) {
|
|
294
|
+
size += chunk.length;
|
|
295
|
+
requireData(size <= 4 * 1024 * 1024, "history_response_too_large");
|
|
296
|
+
chunks.push(chunk);
|
|
297
|
+
}
|
|
298
|
+
const body = Buffer.concat(chunks);
|
|
299
|
+
return zip ? accountingEquity(body) : JSON.parse(body.toString("utf8"));
|
|
300
|
+
};
|
|
301
|
+
const [pnl, snapshot] = await Promise.all([
|
|
302
|
+
read("/v2/user-pnl", { user: wallet, interval: "1m", fidelity: "1d" }),
|
|
303
|
+
read("/v1/accounting/snapshot", { user: wallet }, true),
|
|
304
|
+
]);
|
|
305
|
+
const flows = [], seen = new Set();
|
|
306
|
+
let cursor = null;
|
|
307
|
+
for (let page = 0; page < 10; page++) {
|
|
308
|
+
const response = await read("/v2/activity", {
|
|
309
|
+
user: wallet,
|
|
310
|
+
start: "1",
|
|
311
|
+
end: String(Math.ceil(snapshot.at / 1000)),
|
|
312
|
+
type: "DEPOSIT,WITHDRAWAL",
|
|
313
|
+
exclude_deposits_withdrawals: "false",
|
|
314
|
+
limit: "1000",
|
|
315
|
+
sort_direction: "ASC",
|
|
316
|
+
...(cursor ? { cursor } : {}),
|
|
317
|
+
});
|
|
318
|
+
requireData(Array.isArray(response.data) &&
|
|
319
|
+
response.data.length <= 1000 &&
|
|
320
|
+
typeof response.pagination?.has_more === "boolean", "incomplete_funding_ledger");
|
|
321
|
+
flows.push(...response.data);
|
|
322
|
+
const next = response.pagination.next_cursor;
|
|
323
|
+
if (!response.pagination.has_more) {
|
|
324
|
+
requireData(next === null, "incomplete_funding_ledger");
|
|
325
|
+
return predictionMetrics(wallet, pnl.data, flows, snapshot, now());
|
|
326
|
+
}
|
|
327
|
+
requireData(typeof next === "string" &&
|
|
328
|
+
next.length > 0 &&
|
|
329
|
+
!seen.has(next) &&
|
|
330
|
+
response.data.length > 0, "incomplete_funding_ledger");
|
|
331
|
+
seen.add(next);
|
|
332
|
+
cursor = next;
|
|
333
|
+
}
|
|
334
|
+
throw new Error("funding_ledger_page_limit");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { preferences } from "./contracts.js";
|
|
2
|
+
// Product planning presets, not inferred user consent or guaranteed loss caps.
|
|
3
|
+
export const suggestedLoss = { low: 10, medium: 20, high: 30 };
|
|
4
|
+
const questions = {
|
|
5
|
+
budget_usdc: { question: "这次计划投入多少 USDC?", unit: "USDC" },
|
|
6
|
+
risk_tolerance: {
|
|
7
|
+
question: "风险偏好是稳健、平衡,还是进取?",
|
|
8
|
+
options: ["low", "medium", "high"],
|
|
9
|
+
},
|
|
10
|
+
fixed_margin_usdc: {
|
|
11
|
+
question: "你选择了固定保证金模式,每次新开仓使用多少 USDC?",
|
|
12
|
+
unit: "USDC",
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
// Never reinterpret an already approved legacy portfolio's asset semantics.
|
|
16
|
+
export function normalizedAssetPreferences(value, version = 2) {
|
|
17
|
+
return version !== 2 && ["any", "rwa"].includes(value.asset_category ?? "")
|
|
18
|
+
? { ...value, assets: [], allow_altcoins: true }
|
|
19
|
+
: value;
|
|
20
|
+
}
|
|
21
|
+
export function preferenceCollection(s, answers = {}) {
|
|
22
|
+
const previous = s.preferences ??
|
|
23
|
+
s.plans.at(-1)?.preferences ??
|
|
24
|
+
s.portfolios.find((p) => p.state !== "closed")?.preferences ??
|
|
25
|
+
{};
|
|
26
|
+
const previousVersion = s.preferences
|
|
27
|
+
? s.preference_asset_filter_version
|
|
28
|
+
: (s.plans.at(-1)?.asset_filter_version ??
|
|
29
|
+
s.portfolios.find((p) => p.state !== "closed")?.asset_filter_version);
|
|
30
|
+
const defaults = {
|
|
31
|
+
market: "both",
|
|
32
|
+
assets: [],
|
|
33
|
+
allow_altcoins: true,
|
|
34
|
+
asset_category: "any",
|
|
35
|
+
copy_mode: "ride_recommends",
|
|
36
|
+
};
|
|
37
|
+
const saved = {
|
|
38
|
+
...defaults,
|
|
39
|
+
...normalizedAssetPreferences(previous, previousVersion ?? 1),
|
|
40
|
+
...(s.preference_draft ?? {}),
|
|
41
|
+
...answers,
|
|
42
|
+
};
|
|
43
|
+
const sources = {
|
|
44
|
+
market: "default",
|
|
45
|
+
assets: "default",
|
|
46
|
+
allow_altcoins: "default",
|
|
47
|
+
asset_category: "default",
|
|
48
|
+
copy_mode: "default",
|
|
49
|
+
};
|
|
50
|
+
// Existing explicit numeric risk settings remain explicit, including old plans.
|
|
51
|
+
for (const key of Object.keys(previous))
|
|
52
|
+
if (previous[key] != null)
|
|
53
|
+
sources[key] = "user";
|
|
54
|
+
Object.assign(sources, s.preference_sources);
|
|
55
|
+
for (const key of Object.keys(answers))
|
|
56
|
+
if (answers[key] != null)
|
|
57
|
+
sources[key] = "user";
|
|
58
|
+
if (saved.risk_tolerance &&
|
|
59
|
+
(saved.loss_trigger_pct == null ||
|
|
60
|
+
sources.loss_trigger_pct === "recommended")) {
|
|
61
|
+
saved.loss_trigger_pct = suggestedLoss[saved.risk_tolerance];
|
|
62
|
+
sources.loss_trigger_pct = "recommended";
|
|
63
|
+
}
|
|
64
|
+
const missing = [];
|
|
65
|
+
if (saved.budget_usdc == null)
|
|
66
|
+
missing.push("budget_usdc");
|
|
67
|
+
// An existing user-supplied numerical loss constraint is a custom risk answer.
|
|
68
|
+
if (!saved.risk_tolerance &&
|
|
69
|
+
!(saved.loss_trigger_pct != null && sources.loss_trigger_pct === "user"))
|
|
70
|
+
missing.push("risk_tolerance");
|
|
71
|
+
if (!missing.length &&
|
|
72
|
+
saved.copy_mode === "fixed" &&
|
|
73
|
+
saved.fixed_margin_usdc == null)
|
|
74
|
+
missing.push("fixed_margin_usdc");
|
|
75
|
+
return {
|
|
76
|
+
ready: missing.length === 0,
|
|
77
|
+
answers: saved,
|
|
78
|
+
sources,
|
|
79
|
+
missing_fields: missing,
|
|
80
|
+
questions: missing.map((field) => ({ field, ...questions[field] })),
|
|
81
|
+
defaults,
|
|
82
|
+
suggestions: sources.loss_trigger_pct === "recommended"
|
|
83
|
+
? {
|
|
84
|
+
loss_trigger_pct: saved.loss_trigger_pct,
|
|
85
|
+
source: "Ride planning preset v1",
|
|
86
|
+
requires_final_confirmation: true,
|
|
87
|
+
explanation: "建议的组合亏损退出触发值,可修改;是尽力执行的退出触发条件,不是最大亏损保证。",
|
|
88
|
+
}
|
|
89
|
+
: null,
|
|
90
|
+
optional_choices: {
|
|
91
|
+
market: ["perps", "prediction", "both"],
|
|
92
|
+
risk_tolerance: ["low", "medium", "high"],
|
|
93
|
+
asset_category: ["crypto", "rwa", "any"],
|
|
94
|
+
copy_mode: ["ride_recommends", "fixed", "proportional"],
|
|
95
|
+
},
|
|
96
|
+
instruction: missing.length
|
|
97
|
+
? "Ask only the returned missing questions in conversation. Save partial user answers. Do not open a full form, ask for trader count or funding, or invent budget or risk preference."
|
|
98
|
+
: "Recommend now. Disclose defaults and label numeric risk parameters as suggestions for final Ride confirmation. Preserve explicit choices; a budget or preview never authorizes funds or trades.",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function savePreferenceCollection(s, collection) {
|
|
102
|
+
s.preference_sources = collection.sources;
|
|
103
|
+
if (collection.ready) {
|
|
104
|
+
s.preferences = preferences.parse(collection.answers);
|
|
105
|
+
s.preference_asset_filter_version = 2;
|
|
106
|
+
s.preference_draft = undefined;
|
|
107
|
+
}
|
|
108
|
+
else
|
|
109
|
+
s.preference_draft = collection.answers;
|
|
110
|
+
}
|
|
111
|
+
export function completePreferences(s, answers = {}) {
|
|
112
|
+
const collection = preferenceCollection(s, answers);
|
|
113
|
+
return collection.ready ? preferences.parse(collection.answers) : null;
|
|
114
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import pg from "pg";
|
|
2
|
+
import { randomBytes, timingSafeEqual, createHmac } from "node:crypto";
|
|
3
|
+
import { AgentError } from "./contracts.js";
|
|
4
|
+
export const emptyState = () => ({
|
|
5
|
+
preferences: null,
|
|
6
|
+
plans: [],
|
|
7
|
+
portfolios: [],
|
|
8
|
+
proposals: [],
|
|
9
|
+
delegation: null,
|
|
10
|
+
updates: [],
|
|
11
|
+
checkpoints: [],
|
|
12
|
+
});
|
|
13
|
+
export class PgRepository {
|
|
14
|
+
pool;
|
|
15
|
+
constructor(url) {
|
|
16
|
+
this.pool = new pg.Pool({
|
|
17
|
+
connectionString: url,
|
|
18
|
+
max: 10,
|
|
19
|
+
connectionTimeoutMillis: 5000,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
async migrate() {
|
|
23
|
+
await this.pool.query(`CREATE TABLE IF NOT EXISTS ride_agent_state (user_id TEXT PRIMARY KEY, state JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now());`);
|
|
24
|
+
}
|
|
25
|
+
async transact(user, fn) {
|
|
26
|
+
const c = await this.pool.connect();
|
|
27
|
+
try {
|
|
28
|
+
await c.query("BEGIN");
|
|
29
|
+
await c.query("SET LOCAL lock_timeout = '5s'");
|
|
30
|
+
await c.query("SET LOCAL idle_in_transaction_session_timeout = '90s'");
|
|
31
|
+
await c.query("INSERT INTO ride_agent_state(user_id,state) VALUES($1,$2) ON CONFLICT DO NOTHING", [user, emptyState()]);
|
|
32
|
+
const r = await c.query("SELECT state FROM ride_agent_state WHERE user_id=$1 FOR UPDATE", [user]);
|
|
33
|
+
const s = r.rows[0].state;
|
|
34
|
+
const out = await fn(s);
|
|
35
|
+
await c.query("UPDATE ride_agent_state SET state=$2, updated_at=now() WHERE user_id=$1", [user, s]);
|
|
36
|
+
await c.query("COMMIT");
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
await c.query("ROLLBACK");
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
c.release();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async users() {
|
|
48
|
+
return (await this.pool.query("SELECT user_id FROM ride_agent_state WHERE EXISTS (SELECT 1 FROM jsonb_array_elements(state->'portfolios') p WHERE p->>'state' <> 'closed') OR EXISTS (SELECT 1 FROM jsonb_array_elements(state->'updates') u WHERE u->>'notification' = 'pending') OR state->'notification_preferences'->>'daily_digest' = 'true'")).rows.map((x) => x.user_id);
|
|
49
|
+
}
|
|
50
|
+
async close() {
|
|
51
|
+
await this.pool.end();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Test-only adapter; production startup requires PostgreSQL.
|
|
55
|
+
export class MemoryRepository {
|
|
56
|
+
states = new Map();
|
|
57
|
+
tails = new Map();
|
|
58
|
+
async transact(user, fn) {
|
|
59
|
+
const prior = this.tails.get(user) ?? Promise.resolve();
|
|
60
|
+
const work = prior
|
|
61
|
+
.catch(() => { })
|
|
62
|
+
.then(async () => {
|
|
63
|
+
const s = structuredClone(this.states.get(user) ?? emptyState());
|
|
64
|
+
const out = await fn(s);
|
|
65
|
+
this.states.set(user, s);
|
|
66
|
+
return out;
|
|
67
|
+
});
|
|
68
|
+
this.tails.set(user, work);
|
|
69
|
+
return work;
|
|
70
|
+
}
|
|
71
|
+
async users() {
|
|
72
|
+
return [...this.states.keys()];
|
|
73
|
+
}
|
|
74
|
+
async close() { }
|
|
75
|
+
}
|
|
76
|
+
export class Cursor {
|
|
77
|
+
key;
|
|
78
|
+
constructor(key) {
|
|
79
|
+
this.key = key;
|
|
80
|
+
if (Buffer.byteLength(key) < 32)
|
|
81
|
+
throw new Error("Cursor signing key requires 32 bytes");
|
|
82
|
+
}
|
|
83
|
+
encode(user, at, id) {
|
|
84
|
+
const data = Buffer.from(JSON.stringify({ u: user, a: at, i: id })).toString("base64url");
|
|
85
|
+
return `${data}.${this.mac(data)}`;
|
|
86
|
+
}
|
|
87
|
+
decode(user, token) {
|
|
88
|
+
try {
|
|
89
|
+
const [data, sig] = token.split(".");
|
|
90
|
+
const a = Buffer.from(sig ?? ""), b = Buffer.from(this.mac(data));
|
|
91
|
+
if (a.length !== b.length || !timingSafeEqual(a, b))
|
|
92
|
+
throw 0;
|
|
93
|
+
const v = JSON.parse(Buffer.from(data, "base64url").toString());
|
|
94
|
+
if (v.u !== user || !Number.isSafeInteger(v.a) || typeof v.i !== "string")
|
|
95
|
+
throw 0;
|
|
96
|
+
return { at: v.a, id: v.i };
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
throw new AgentError("INVALID_CURSOR", "Cursor is invalid for this account.", false, 400);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
mac(data) {
|
|
103
|
+
return createHmac("sha256", this.key).update(data).digest("base64url");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export const nonce = () => randomBytes(32).toString("base64url");
|