@oracle-agent/oracle 0.3.3 → 0.3.5

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.
@@ -1,394 +0,0 @@
1
- import { constants } from "node:fs";
2
- import { chmod, mkdir, open, readFile } from "node:fs/promises";
3
- import { createHash, randomUUID } from "node:crypto";
4
- import os from "node:os";
5
- import path from "node:path";
6
- import { portfolioBalance, resolvePortfolioAddresses } from "./portfolio.mjs";
7
- import { nftInventory } from "./nft-portfolio.mjs";
8
-
9
- const MAX_HISTORY_BYTES = 10 * 1024 * 1024;
10
- const MAX_HISTORY_LIMIT = 1000;
11
-
12
- function finiteNumber(value) {
13
- if (value == null || value === "") return null;
14
- const number = Number(value);
15
- return Number.isFinite(number) ? number : null;
16
- }
17
-
18
- function roundedUsd(value) {
19
- return Number.isFinite(value) ? Number(value.toFixed(2)) : null;
20
- }
21
-
22
- function isoDate(value, label) {
23
- if (value == null || value === "") return null;
24
- const time = Date.parse(String(value));
25
- if (!Number.isFinite(time)) throw new Error(`${label} must be an ISO 8601 timestamp`);
26
- return new Date(time).toISOString();
27
- }
28
-
29
- function boundedInteger(value, fallback, min, max, label) {
30
- if (value == null || value === "") return fallback;
31
- const number = Number(value);
32
- if (!Number.isInteger(number) || number < min || number > max) {
33
- throw new Error(`${label} must be an integer from ${min} to ${max}`);
34
- }
35
- return number;
36
- }
37
-
38
- function canonicalAddresses(addresses = {}) {
39
- return ["evm", "solana", "bitcoin", "hyperliquid"]
40
- .map((family) => `${family}:${String(addresses[family] || "").trim().toLowerCase()}`)
41
- .join("|");
42
- }
43
-
44
- export function portfolioIdForAddresses(addresses = {}) {
45
- const digest = createHash("sha256").update(canonicalAddresses(addresses)).digest("hex").slice(0, 20);
46
- return `portfolio_${digest}`;
47
- }
48
-
49
- export function resolvePortfolioHistoryFile(opts = {}) {
50
- if (opts.historyFile) return path.resolve(String(opts.historyFile));
51
- const env = opts.env || process.env;
52
- if (env.ORACLE_PORTFOLIO_HISTORY_FILE) {
53
- return path.resolve(String(env.ORACLE_PORTFOLIO_HISTORY_FILE));
54
- }
55
- const hermesHome = String(env.HERMES_HOME || "").trim() || path.join(os.homedir(), ".hermes");
56
- return path.join(path.resolve(hermesHome), "state", "oracle", "portfolio-history.jsonl");
57
- }
58
-
59
- function knownLiquidValue(balance) {
60
- const value = finiteNumber(balance?.valuation?.knownUsd);
61
- if (value == null) return null;
62
- if (Number(balance?.valuation?.pricedItems || 0) > 0 || balance?.valuation?.complete === true) {
63
- return roundedUsd(value);
64
- }
65
- return null;
66
- }
67
-
68
- function knownNftValue(nfts) {
69
- if (!nfts || nfts.status === "unavailable" || nfts.status === "not-requested") return null;
70
- const value = finiteNumber(nfts?.valuation?.estimatedCurrentValueUsd);
71
- if (value == null) return null;
72
- if (Number(nfts?.valuation?.valuedItems || 0) > 0 || nfts?.valuation?.complete === true) {
73
- return roundedUsd(value);
74
- }
75
- return null;
76
- }
77
-
78
- function liquidBreakdown(balance) {
79
- const rows = [];
80
- for (const chain of balance?.chains || []) {
81
- if (chain.family === "hyperliquid") {
82
- const spot = (chain.spot?.balances || []).reduce((sum, item) => {
83
- const value = finiteNumber(item.usdValue);
84
- return value == null ? sum : sum + value;
85
- }, 0);
86
- const perps = finiteNumber(chain.perps?.accountValueUsd);
87
- const knownUsd = roundedUsd(spot + (perps ?? 0));
88
- if (knownUsd !== 0 || perps != null || (chain.spot?.balances || []).some((item) => finiteNumber(item.usdValue) != null)) {
89
- rows.push({ family: "hyperliquid", chainId: null, name: chain.name || "Hyperliquid", knownUsd });
90
- }
91
- continue;
92
- }
93
- const value = finiteNumber(chain.native?.usdValue);
94
- if (value == null) continue;
95
- rows.push({
96
- family: chain.family || "evm",
97
- chainId: chain.chainId ?? null,
98
- name: chain.name || chain.native?.symbol || "Unknown",
99
- knownUsd: roundedUsd(value),
100
- });
101
- }
102
- return rows;
103
- }
104
-
105
- function coverageSummary(balance, nfts, includeNfts) {
106
- const nftRows = Array.isArray(nfts?.coverage) ? nfts.coverage : [];
107
- return {
108
- balances: balance?.coverage || null,
109
- nfts: {
110
- requested: includeNfts,
111
- ok: nftRows.filter((row) => row.status === "ok").length,
112
- partial: nftRows.filter((row) => row.status === "partial").length,
113
- unavailable: nftRows.filter((row) => row.status === "unavailable").length,
114
- notConfigured: nftRows.filter((row) => row.status === "not-configured").length,
115
- status: !includeNfts
116
- ? "not-requested"
117
- : nfts?.status === "unavailable"
118
- ? "unavailable"
119
- : nfts?.valuation?.complete
120
- ? "ok"
121
- : "partial",
122
- },
123
- };
124
- }
125
-
126
- async function appendSnapshot(snapshot, opts) {
127
- const file = resolvePortfolioHistoryFile(opts);
128
- const line = `${JSON.stringify(snapshot)}\n`;
129
- if (Buffer.byteLength(line) > 32 * 1024) throw new Error("portfolio history snapshot exceeds 32 KiB");
130
- await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
131
- const flags = constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY | (constants.O_NOFOLLOW || 0);
132
- const handle = await open(file, flags, 0o600);
133
- try {
134
- await handle.writeFile(line, "utf8");
135
- } finally {
136
- await handle.close();
137
- }
138
- await chmod(file, 0o600);
139
- return file;
140
- }
141
-
142
- export async function portfolioSnapshot(args = {}, opts = {}) {
143
- const includeNfts = args.includeNfts !== false;
144
- const balanceImpl = opts.portfolioBalanceImpl || portfolioBalance;
145
- const nftImpl = opts.nftInventoryImpl || nftInventory;
146
- const balancePromise = balanceImpl(args, opts);
147
- const nftsPromise = includeNfts
148
- ? nftImpl({ ...args, includePnl: false }, opts).catch((error) => ({
149
- status: "unavailable",
150
- error: String(error?.message || error).slice(0, 300),
151
- }))
152
- : Promise.resolve({ status: "not-requested" });
153
- const [balance, nfts] = await Promise.all([balancePromise, nftsPromise]);
154
- const liquidKnownUsd = knownLiquidValue(balance);
155
- const nftEstimatedValueUsd = knownNftValue(nfts);
156
- const components = [liquidKnownUsd, nftEstimatedValueUsd].filter((value) => value != null);
157
- const knownUsd = components.length ? roundedUsd(components.reduce((sum, value) => sum + value, 0)) : null;
158
- const complete = Boolean(
159
- balance?.valuation?.complete &&
160
- (!includeNfts || nfts?.valuation?.complete === true),
161
- );
162
- const nowValue = typeof opts.now === "function" ? opts.now() : new Date();
163
- const recordedAt = new Date(nowValue).toISOString();
164
- const addresses = balance?.addresses || resolvePortfolioAddresses(args, opts.env || process.env);
165
- const portfolioId = portfolioIdForAddresses(addresses);
166
- const warnings = [...(balance?.warnings || [])];
167
- if (includeNfts && nfts?.status === "unavailable") warnings.push(`NFT inventory unavailable: ${nfts.error}`);
168
- if (includeNfts && nfts?.valuation && nfts.valuation.complete !== true) {
169
- warnings.push("NFT estimated value is incomplete and is not an executable bid.");
170
- }
171
- const snapshot = {
172
- schemaVersion: 1,
173
- id: `snapshot_${randomUUID()}`,
174
- portfolioId,
175
- recordedAt,
176
- sourceQueriedAt: {
177
- balances: balance?.queriedAt || null,
178
- nfts: nfts?.generatedAt || null,
179
- },
180
- configuredFamilies: Object.fromEntries(
181
- ["evm", "solana", "bitcoin", "hyperliquid"].map((family) => [family, Boolean(addresses?.[family])]),
182
- ),
183
- valuation: {
184
- knownUsd,
185
- liquidKnownUsd,
186
- nftEstimatedValueUsd,
187
- complete,
188
- label: complete
189
- ? "complete known portfolio value"
190
- : "known priced value, including provider-estimated NFTs when available, not a complete portfolio total",
191
- liquidPricedItems: Number(balance?.valuation?.pricedItems || 0),
192
- liquidUnpricedNonzeroItems: Number(balance?.valuation?.unpricedNonzeroItems || 0),
193
- nftValuedItems: Number(nfts?.valuation?.valuedItems || 0),
194
- nftUnvaluedItems: Number(nfts?.valuation?.unvaluedItems || 0),
195
- },
196
- breakdown: {
197
- liquid: liquidBreakdown(balance),
198
- nfts: {
199
- knownUsd: nftEstimatedValueUsd,
200
- visibleItems: Number(nfts?.inventory?.visibleCount || 0),
201
- flaggedItems: Number(nfts?.inventory?.flaggedCount || 0),
202
- },
203
- },
204
- coverage: coverageSummary(balance, nfts, includeNfts),
205
- warnings: [...new Set(warnings)].slice(0, 50),
206
- };
207
- const historyFile = await appendSnapshot(snapshot, opts);
208
- return {
209
- provider: "portfolio",
210
- operation: "snapshot",
211
- readOnly: true,
212
- localObservationRecorded: true,
213
- historyFile,
214
- snapshot,
215
- balance,
216
- nfts,
217
- };
218
- }
219
-
220
- async function readSnapshots(opts) {
221
- const file = resolvePortfolioHistoryFile(opts);
222
- try {
223
- const data = await readFile(file);
224
- if (data.byteLength > MAX_HISTORY_BYTES) {
225
- throw new Error(`portfolio history exceeds ${MAX_HISTORY_BYTES} bytes`);
226
- }
227
- const snapshots = [];
228
- let corruptLines = 0;
229
- for (const line of data.toString("utf8").split("\n")) {
230
- if (!line.trim()) continue;
231
- try {
232
- const row = JSON.parse(line);
233
- if (row?.schemaVersion === 1 && row?.portfolioId && row?.recordedAt) snapshots.push(row);
234
- else corruptLines += 1;
235
- } catch {
236
- corruptLines += 1;
237
- }
238
- }
239
- return { file, snapshots, corruptLines };
240
- } catch (error) {
241
- if (error?.code === "ENOENT") return { file, snapshots: [], corruptLines: 0 };
242
- throw error;
243
- }
244
- }
245
-
246
- function requestedPortfolioId(args, opts) {
247
- if (args.allPortfolios === true) return null;
248
- if (args.portfolioId) return String(args.portfolioId);
249
- const addresses = resolvePortfolioAddresses(args, opts.env || process.env);
250
- return Object.values(addresses).some(Boolean) ? portfolioIdForAddresses(addresses) : null;
251
- }
252
-
253
- export async function portfolioHistory(args = {}, opts = {}) {
254
- const limit = boundedInteger(args.limit, 100, 1, MAX_HISTORY_LIMIT, "portfolio history limit");
255
- const order = String(args.order || "desc").toLowerCase();
256
- if (!new Set(["asc", "desc"]).has(order)) throw new Error("portfolio history order must be asc or desc");
257
- const since = isoDate(args.since, "portfolio history since");
258
- const until = isoDate(args.until, "portfolio history until");
259
- const portfolioId = requestedPortfolioId(args, opts);
260
- const stored = await readSnapshots(opts);
261
- let matched = stored.snapshots.filter((row) => {
262
- if (portfolioId && row.portfolioId !== portfolioId) return false;
263
- if (since && row.recordedAt < since) return false;
264
- if (until && row.recordedAt > until) return false;
265
- return true;
266
- });
267
- matched.sort((a, b) => String(a.recordedAt).localeCompare(String(b.recordedAt)));
268
- const totalMatching = matched.length;
269
- matched = matched.slice(-limit);
270
- if (order === "desc") matched.reverse();
271
- const valued = matched.filter((row) => finiteNumber(row?.valuation?.knownUsd) != null);
272
- return {
273
- provider: "portfolio",
274
- operation: "history",
275
- readOnly: true,
276
- historyFile: stored.file,
277
- portfolioId,
278
- order,
279
- totalStored: stored.snapshots.length,
280
- totalMatching,
281
- corruptLines: stored.corruptLines,
282
- snapshots: matched,
283
- stats: {
284
- returned: matched.length,
285
- valuedSnapshots: valued.length,
286
- unpricedSnapshots: matched.length - valued.length,
287
- firstRecordedAt: matched.length ? [...matched].sort((a, b) => String(a.recordedAt).localeCompare(String(b.recordedAt)))[0].recordedAt : null,
288
- lastRecordedAt: matched.length ? [...matched].sort((a, b) => String(a.recordedAt).localeCompare(String(b.recordedAt))).at(-1).recordedAt : null,
289
- },
290
- };
291
- }
292
-
293
- function money(value) {
294
- return `$${Number(value).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
295
- }
296
-
297
- function xml(value) {
298
- return String(value)
299
- .replaceAll("&", "&amp;")
300
- .replaceAll("<", "&lt;")
301
- .replaceAll(">", "&gt;")
302
- .replaceAll('"', "&quot;")
303
- .replaceAll("'", "&apos;");
304
- }
305
-
306
- function sampled(points, maxPoints) {
307
- if (points.length <= maxPoints) return points;
308
- const output = [];
309
- for (let i = 0; i < maxPoints; i += 1) {
310
- output.push(points[Math.round((i * (points.length - 1)) / (maxPoints - 1))]);
311
- }
312
- return output;
313
- }
314
-
315
- function graphSvg(points, portfolioId) {
316
- const width = 1200;
317
- const height = 675;
318
- const left = 100;
319
- const right = 1140;
320
- const top = 100;
321
- const bottom = 570;
322
- const values = points.map((point) => point.value);
323
- let min = Math.min(...values);
324
- let max = Math.max(...values);
325
- if (min === max) {
326
- const padding = Math.max(1, Math.abs(min) * 0.05);
327
- min -= padding;
328
- max += padding;
329
- } else {
330
- const padding = (max - min) * 0.1;
331
- min = Math.max(0, min - padding);
332
- max += padding;
333
- }
334
- const x = (index) => points.length === 1 ? (left + right) / 2 : left + ((right - left) * index) / (points.length - 1);
335
- const y = (value) => bottom - ((value - min) / (max - min)) * (bottom - top);
336
- const pathData = points.map((point, index) => `${index ? "L" : "M"}${x(index).toFixed(2)},${y(point.value).toFixed(2)}`).join(" ");
337
- const grids = Array.from({ length: 5 }, (_, index) => {
338
- const ratio = index / 4;
339
- const yy = top + ratio * (bottom - top);
340
- const value = max - ratio * (max - min);
341
- return `<line x1="${left}" y1="${yy}" x2="${right}" y2="${yy}" stroke="#26313c" stroke-width="1"/><text x="${left - 14}" y="${yy + 5}" text-anchor="end" fill="#8795a5" font-size="15">${money(value)}</text>`;
342
- }).join("");
343
- const circles = points.map((point, index) => `<circle cx="${x(index)}" cy="${y(point.value)}" r="5" fill="#b8f0ff"><title>${xml(point.recordedAt)}: ${money(point.value)}</title></circle>`).join("");
344
- const firstDate = xml(points[0].recordedAt.slice(0, 10));
345
- const lastDate = xml(points.at(-1).recordedAt.slice(0, 10));
346
- const portfolioLabel = xml(portfolioId || "configured portfolio");
347
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="1200" height="675" fill="#0b1016"/><text x="60" y="50" fill="#f4f7fa" font-size="28" font-family="Inter,Arial,sans-serif" font-weight="700">Oracle Portfolio Value</text><text x="60" y="78" fill="#8795a5" font-size="15" font-family="Inter,Arial,sans-serif">Known priced value, not a complete total unless every snapshot says complete · ${portfolioLabel}</text>${grids}<line x1="${left}" y1="${bottom}" x2="${right}" y2="${bottom}" stroke="#526171" stroke-width="1"/><path d="${pathData}" fill="none" stroke="#b8f0ff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>${circles}<text x="${left}" y="610" fill="#8795a5" font-size="15">${firstDate}</text><text x="${right}" y="610" text-anchor="end" fill="#8795a5" font-size="15">${lastDate}</text><text x="60" y="650" fill="#526171" font-size="13">NFT values are provider estimates, not executable bids. Unavailable values are omitted, never plotted as zero.</text></svg>`;
348
- }
349
-
350
- export async function portfolioValueGraph(args = {}, opts = {}) {
351
- if (args.allPortfolios === true) {
352
- throw new Error("portfolio value graph requires one portfolioId or configured public-address portfolio");
353
- }
354
- const maxPoints = boundedInteger(args.maxPoints, 200, 2, 500, "portfolio graph maxPoints");
355
- const history = await portfolioHistory({ ...args, order: "asc", limit: args.limit ?? MAX_HISTORY_LIMIT }, opts);
356
- if (!history.portfolioId) {
357
- throw new Error("portfolio value graph requires one portfolioId or configured public-address portfolio");
358
- }
359
- const known = history.snapshots
360
- .map((snapshot) => ({
361
- recordedAt: snapshot.recordedAt,
362
- value: finiteNumber(snapshot?.valuation?.knownUsd),
363
- complete: snapshot?.valuation?.complete === true,
364
- }))
365
- .filter((point) => point.value != null);
366
- if (!known.length) throw new Error("portfolio value graph requires at least one snapshot with known priced value");
367
- const points = sampled(known, maxPoints);
368
- const start = points[0].value;
369
- const end = points.at(-1).value;
370
- const changeUsd = roundedUsd(end - start);
371
- const changePct = start === 0 ? null : Number((((end - start) / start) * 100).toFixed(2));
372
- const svg = graphSvg(points, history.portfolioId);
373
- return {
374
- provider: "portfolio",
375
- operation: "valueGraph",
376
- readOnly: true,
377
- portfolioId: history.portfolioId,
378
- mimeType: "image/svg+xml",
379
- dataBase64: Buffer.from(svg).toString("base64"),
380
- byteLength: Buffer.byteLength(svg),
381
- summary: {
382
- points: points.length,
383
- omittedUnavailablePoints: history.snapshots.length - known.length,
384
- startRecordedAt: points[0].recordedAt,
385
- endRecordedAt: points.at(-1).recordedAt,
386
- startKnownUsd: start,
387
- endKnownUsd: end,
388
- changeUsd,
389
- changePct,
390
- completeSnapshots: points.filter((point) => point.complete).length,
391
- label: "known priced value history; incomplete snapshots are not complete portfolio totals",
392
- },
393
- };
394
- }