chain-insights 0.44.1 → 0.46.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/dist/cache-location-BO2XkfgI.mjs +11 -0
- package/dist/cache-location-BO2XkfgI.mjs.map +1 -0
- package/dist/cache-location-CBVzUIxq.cjs +23 -0
- package/dist/{capabilities-A8fsfP24.cjs → capabilities-BGkL9yAr.cjs} +6 -0
- package/dist/{capabilities-DfWL6n_g.mjs → capabilities-Cur8x6c7.mjs} +2 -2
- package/dist/capabilities-Cur8x6c7.mjs.map +1 -0
- package/dist/cli.cjs +32 -6
- package/dist/cli.mjs +32 -6
- package/dist/cli.mjs.map +1 -1
- package/dist/graph-schema-D1orCe5F.mjs +914 -0
- package/dist/graph-schema-D1orCe5F.mjs.map +1 -0
- package/dist/graph-schema-znb_9MYq.cjs +972 -0
- package/dist/mcp-proxy.cjs +74 -480
- package/dist/mcp-proxy.d.cts.map +1 -1
- package/dist/mcp-proxy.d.mts.map +1 -1
- package/dist/mcp-proxy.mjs +66 -472
- package/dist/mcp-proxy.mjs.map +1 -1
- package/dist/public-tools-CZyeptJQ.mjs.map +1 -1
- package/dist/{schema-cache-DJdU4MIP.mjs → schema-cache-BT-kHy_r.mjs} +4 -5
- package/dist/schema-cache-BT-kHy_r.mjs.map +1 -0
- package/dist/{schema-cache-n7Gbhvsj.cjs → schema-cache-DwddPLcY.cjs} +3 -5
- package/docs/architecture.md +2 -0
- package/docs/graph-query-compatibility.md +56 -46
- package/docs/graph-tools.md +87 -37
- package/docs/mcp-proxy.md +13 -0
- package/docs/stability.md +2 -1
- package/package.json +1 -1
- package/skills/chain-insights-cypher/SKILL.md +108 -44
- package/dist/capabilities-DfWL6n_g.mjs.map +0 -1
- package/dist/schema-cache-DJdU4MIP.mjs.map +0 -1
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
|
|
2
|
+
import { t as PaymentRequiredError } from "./client-gsLi528K.mjs";
|
|
3
|
+
import { n as findNetworkCapability, r as mirrorGraphNetworkCapabilities } from "./capabilities-Cur8x6c7.mjs";
|
|
4
|
+
import { n as chainInsightsHome, t as CACHE_TTL_MS } from "./cache-location-BO2XkfgI.mjs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
//#region src/mcp/flows.ts
|
|
9
|
+
/**
|
|
10
|
+
* graph_expand, the one dedicated tool of the local proxy, called by the Chain
|
|
11
|
+
* Insights view (ui://chain-insights/view) when the investigator clicks. Its
|
|
12
|
+
* _meta says visibility ["app"], so a host keeps it away from the model: the
|
|
13
|
+
* model writes its own graph_query, and the view draws the rows by their column
|
|
14
|
+
* names.
|
|
15
|
+
*
|
|
16
|
+
* Two forms, told apart by the arguments:
|
|
17
|
+
*
|
|
18
|
+
* - node, {network, address, in_offset?, out_offset?}: one address with its
|
|
19
|
+
* newest senders and receivers, a text summary and chain-insights.flows.v1 in
|
|
20
|
+
* structuredContent. Composed from three anchored graph_query reads on the
|
|
21
|
+
* topology graph (the node, its outgoing FLOWS_TO and its incoming FLOWS_TO,
|
|
22
|
+
* newest first).
|
|
23
|
+
* - link, {network, from, to, day}: the transfers between two known addresses
|
|
24
|
+
* on one UTC day, newest first, at most 50, a short text summary and
|
|
25
|
+
* chain-insights.transfers.v1 in structuredContent. One anchored USE facts
|
|
26
|
+
* graph_query read.
|
|
27
|
+
*
|
|
28
|
+
* Every read goes through the proxy's remote client, the same path and payment
|
|
29
|
+
* wrapping a graph_query call takes, and is billed as a graph_query. Every
|
|
30
|
+
* argument is checked before the first read, so a refused call reaches no graph
|
|
31
|
+
* endpoint and costs nothing.
|
|
32
|
+
*/
|
|
33
|
+
const FLOWS_SCHEMA = "chain-insights.flows.v1";
|
|
34
|
+
const GRAPH_EXPAND_TOOL = "graph_expand";
|
|
35
|
+
const FLOWS_NETWORK = "robinhood";
|
|
36
|
+
/** Where a graph_expand page may start on each side, at most. */
|
|
37
|
+
const FLOWS_MAX_OFFSET = 1e4;
|
|
38
|
+
const TRANSFERS_SCHEMA = "chain-insights.transfers.v1";
|
|
39
|
+
const INVALID_ADDRESS_TEXT = "invalid_address: a full 0x address of 40 hexadecimal characters is needed, for example 0x04911a118f11c75667e4d0dfb8e640af5a353550";
|
|
40
|
+
const OFFSET_TOO_LARGE_TEXT = `invalid_offset: in_offset and out_offset must each be at most ${FLOWS_MAX_OFFSET}`;
|
|
41
|
+
const OFFSET_NEGATIVE_TEXT = "invalid_offset: in_offset and out_offset must each be a whole number, 0 or more";
|
|
42
|
+
const BOTH_FORMS_TEXT = "invalid_arguments: pass address (with in_offset and out_offset) to expand a node, or from, to and day to list the transfers of a link, not both";
|
|
43
|
+
const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/;
|
|
44
|
+
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
45
|
+
const CODE_PREFIX = /^([a-z][a-z0-9_]*):\s*/;
|
|
46
|
+
/**
|
|
47
|
+
* The address lower-cased, or null when it is not 0x and 40 hexadecimal
|
|
48
|
+
* characters. Only an address that passes is ever written into a read, so the
|
|
49
|
+
* reads can carry it as a literal.
|
|
50
|
+
*/
|
|
51
|
+
function normalizeFlowsAddress(raw) {
|
|
52
|
+
if (typeof raw !== "string") return null;
|
|
53
|
+
const address = raw.trim().toLowerCase();
|
|
54
|
+
return ADDRESS_PATTERN.test(address) ? address : null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The day as YYYY-MM-DD when it is a real calendar date, or null. Only a day
|
|
58
|
+
* that passes is ever written into a read.
|
|
59
|
+
*/
|
|
60
|
+
function normalizeFlowsDay(raw) {
|
|
61
|
+
if (typeof raw !== "string") return null;
|
|
62
|
+
const day = raw.trim();
|
|
63
|
+
if (!DAY_PATTERN.test(day)) return null;
|
|
64
|
+
const time = Date.parse(`${day}T00:00:00Z`);
|
|
65
|
+
if (Number.isNaN(time) || new Date(time).toISOString().slice(0, 10) !== day) return null;
|
|
66
|
+
return day;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The one facts read of a link: the transfers from one address to another on
|
|
70
|
+
* one day. A facts read names the pair with one day, takes no ORDER BY and
|
|
71
|
+
* holds at most 200 rows, so the read asks for the whole cap and the order is
|
|
72
|
+
* made in buildTransfersView. Both addresses and the day have passed their
|
|
73
|
+
* checks, so they are written into the read as literals.
|
|
74
|
+
*/
|
|
75
|
+
function transfersReadFor(from, to, day) {
|
|
76
|
+
return `USE facts MATCH (a:Address {address: "${from}"})-[t:TRANSFER]->(b:Address {address: "${to}"}) WHERE t.block_date = "${day}" RETURN t.tx_id AS tx_id, t.block_timestamp AS block_timestamp, t.amount AS amount, t.asset_symbol AS asset_symbol, t.amount_usd AS amount_usd LIMIT 200`;
|
|
77
|
+
}
|
|
78
|
+
const NODE_FIELDS = "b.address AS address, b.labels AS labels, b.is_exchange AS is_exchange, b.is_sanctioned AS is_sanctioned, b.is_scam AS is_scam, b.is_victim AS is_victim, b:Pool AS pool, b.total_in_usd AS total_in_usd, b.total_out_usd AS total_out_usd, b.degree_in AS degree_in, b.degree_out AS degree_out";
|
|
79
|
+
const PARTY_FIELDS = NODE_FIELDS + ", f.amount_usd_sum AS usd, f.tx_count AS tx_count, " + "f.first_seen_timestamp AS first_seen, f.last_seen_timestamp AS last_seen";
|
|
80
|
+
const PAGE_ORDER = " ORDER BY f.last_seen_timestamp DESC, b.address";
|
|
81
|
+
/**
|
|
82
|
+
* The three anchored reads of one page. Each side asks for one row more than
|
|
83
|
+
* it shows: the extra row says another page exists.
|
|
84
|
+
*/
|
|
85
|
+
function flowsReadsFor(address, inOffset, outOffset) {
|
|
86
|
+
const page = (offset) => `${PAGE_ORDER}${offset > 0 ? ` SKIP ${offset}` : ""} LIMIT 13`;
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
id: "node",
|
|
90
|
+
query: `USE topology MATCH (b:Address {address: '${address}'}) RETURN ${NODE_FIELDS} LIMIT 1`
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "out",
|
|
94
|
+
query: `USE topology MATCH (a:Address {address: '${address}'})-[f:FLOWS_TO]->(b:Address) RETURN ${PARTY_FIELDS}${page(outOffset)}`
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "in",
|
|
98
|
+
query: `USE topology MATCH (b:Address)-[f:FLOWS_TO]->(a:Address {address: '${address}'}) RETURN ${PARTY_FIELDS}${page(inOffset)}`
|
|
99
|
+
}
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
/** The role word of an address, first match wins. */
|
|
103
|
+
function flowsRole(row) {
|
|
104
|
+
if (row.is_exchange === true) return "exchange";
|
|
105
|
+
if (row.is_sanctioned === true) return "sanctioned";
|
|
106
|
+
if (row.is_scam === true) return "scam";
|
|
107
|
+
if (row.is_victim === true) return "victim";
|
|
108
|
+
if (row.pool === true) return "pool";
|
|
109
|
+
return "other";
|
|
110
|
+
}
|
|
111
|
+
function flowsFloat(value) {
|
|
112
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
113
|
+
}
|
|
114
|
+
function flowsInt(value) {
|
|
115
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 0;
|
|
116
|
+
}
|
|
117
|
+
function flowsNodeOf(row) {
|
|
118
|
+
const labels = Array.isArray(row.labels) ? row.labels.filter((label) => typeof label === "string") : [];
|
|
119
|
+
return {
|
|
120
|
+
address: typeof row.address === "string" ? row.address : "",
|
|
121
|
+
role: flowsRole(row),
|
|
122
|
+
labels,
|
|
123
|
+
total_in_usd: flowsFloat(row.total_in_usd),
|
|
124
|
+
total_out_usd: flowsFloat(row.total_out_usd),
|
|
125
|
+
degree_in: flowsInt(row.degree_in),
|
|
126
|
+
degree_out: flowsInt(row.degree_out)
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function assembleFlowView(network, page, senders, receivers, truncated) {
|
|
130
|
+
const center = flowsNodeOf(page.center);
|
|
131
|
+
const view = {
|
|
132
|
+
schema: FLOWS_SCHEMA,
|
|
133
|
+
network,
|
|
134
|
+
center: center.address,
|
|
135
|
+
nodes: [center],
|
|
136
|
+
edges: [],
|
|
137
|
+
cursor: {
|
|
138
|
+
in_offset: page.inOffset + senders.length,
|
|
139
|
+
out_offset: page.outOffset + receivers.length
|
|
140
|
+
},
|
|
141
|
+
truncated
|
|
142
|
+
};
|
|
143
|
+
const seen = /* @__PURE__ */ new Set([center.address]);
|
|
144
|
+
const add = (row, incoming) => {
|
|
145
|
+
const node = flowsNodeOf(row);
|
|
146
|
+
if (!seen.has(node.address)) {
|
|
147
|
+
seen.add(node.address);
|
|
148
|
+
view.nodes.push(node);
|
|
149
|
+
}
|
|
150
|
+
view.edges.push({
|
|
151
|
+
from: incoming ? node.address : center.address,
|
|
152
|
+
to: incoming ? center.address : node.address,
|
|
153
|
+
usd: flowsFloat(row.usd),
|
|
154
|
+
tx_count: flowsInt(row.tx_count),
|
|
155
|
+
first_seen_ms: flowsInt(row.first_seen),
|
|
156
|
+
last_seen_ms: flowsInt(row.last_seen)
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
for (const row of senders) add(row, true);
|
|
160
|
+
for (const row of receivers) add(row, false);
|
|
161
|
+
return view;
|
|
162
|
+
}
|
|
163
|
+
function fitsFlowBudget(view) {
|
|
164
|
+
return view.nodes.length <= 60 && JSON.stringify(view).length < 4e4;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* flows.v1 from one page: the centre first, then the senders and the
|
|
168
|
+
* receivers, each newest first, at most FLOWS_PER_SIDE a side. When the view
|
|
169
|
+
* data would hold more than FLOWS_MAX_NODES addresses or reach FLOWS_MAX_CHARS
|
|
170
|
+
* characters, the oldest links go first, the view is marked truncated, and the
|
|
171
|
+
* cursor still says where the next page starts.
|
|
172
|
+
*/
|
|
173
|
+
function buildFlowView(network, page) {
|
|
174
|
+
let truncated = page.senders.length > 12 || page.receivers.length > 12;
|
|
175
|
+
let senders = page.senders.slice(0, 12);
|
|
176
|
+
let receivers = page.receivers.slice(0, 12);
|
|
177
|
+
for (;;) {
|
|
178
|
+
const view = assembleFlowView(network, page, senders, receivers, truncated);
|
|
179
|
+
if (fitsFlowBudget(view) || senders.length + receivers.length === 0) return view;
|
|
180
|
+
truncated = true;
|
|
181
|
+
if (senders.length >= receivers.length) senders = senders.slice(0, -1);
|
|
182
|
+
else receivers = receivers.slice(0, -1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function formatFlowsUsd(value) {
|
|
186
|
+
if (!(value > 0)) return "$0";
|
|
187
|
+
if (value < .01) return "under $0.01";
|
|
188
|
+
const whole = Math.floor(value);
|
|
189
|
+
const cents = Math.round((value - whole) * 100);
|
|
190
|
+
if (cents === 100) return formatFlowsUsd(whole + 1);
|
|
191
|
+
return `$${whole.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${String(cents).padStart(2, "0")}`;
|
|
192
|
+
}
|
|
193
|
+
function formatFlowsAge(lastSeenMs, now) {
|
|
194
|
+
if (lastSeenMs <= 0) return "at an unknown time";
|
|
195
|
+
const date = new Date(lastSeenMs).toISOString().slice(0, 10);
|
|
196
|
+
const days = Math.trunc((now.getTime() - lastSeenMs) / 864e5);
|
|
197
|
+
if (days < 1) return `${date} (less than a day ago)`;
|
|
198
|
+
if (days === 1) return `${date} (1 day ago)`;
|
|
199
|
+
return `${date} (${days} days ago)`;
|
|
200
|
+
}
|
|
201
|
+
/** The role word, with up to three address labels beside it. */
|
|
202
|
+
function roleText(node) {
|
|
203
|
+
if (!node) return "other";
|
|
204
|
+
if (node.labels.length === 0) return node.role;
|
|
205
|
+
return `${node.role} (${node.labels.slice(0, 3).join(", ")})`;
|
|
206
|
+
}
|
|
207
|
+
function sideLines(title, parties) {
|
|
208
|
+
if (parties.length === 0) return [`${title}, newest first: none.`];
|
|
209
|
+
const lines = [`${title}, newest first (${parties.length}):`];
|
|
210
|
+
const perLine = parties.length > 6 ? 2 : 1;
|
|
211
|
+
for (let start = 0; start < parties.length; start += perLine) lines.push(`- ${parties.slice(start, start + perLine).join("; ")}`);
|
|
212
|
+
return lines;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The text of a node answer: the centre, its role and lifetime totals, then
|
|
216
|
+
* every counterparty of the view with its USD, transaction count and last-seen
|
|
217
|
+
* date and age. At most FLOWS_SUMMARY_MAX_LINES lines.
|
|
218
|
+
*/
|
|
219
|
+
function flowsSummary(view, now) {
|
|
220
|
+
const nodes = new Map(view.nodes.map((node) => [node.address, node]));
|
|
221
|
+
const center = nodes.get(view.center);
|
|
222
|
+
const party = (address, edge) => `${address} ${roleText(nodes.get(address))}, ${formatFlowsUsd(edge.usd)} in ${edge.tx_count} tx, last seen ${formatFlowsAge(edge.last_seen_ms, now)}`;
|
|
223
|
+
const senders = [];
|
|
224
|
+
const receivers = [];
|
|
225
|
+
for (const edge of view.edges) if (edge.to === view.center) senders.push(party(edge.from, edge));
|
|
226
|
+
else receivers.push(party(edge.to, edge));
|
|
227
|
+
const body = [
|
|
228
|
+
`Money flows of ${view.center} on ${view.network}: ${roleText(center)}; lifetime ${formatFlowsUsd(center?.total_in_usd ?? 0)} in, ${formatFlowsUsd(center?.total_out_usd ?? 0)} out; ${center?.degree_in ?? 0} senders and ${center?.degree_out ?? 0} receivers in the topology graph.`,
|
|
229
|
+
...sideLines("Recent senders", senders),
|
|
230
|
+
...sideLines("Recent receivers", receivers)
|
|
231
|
+
].slice(0, 18);
|
|
232
|
+
const footer = pagingLines(view, senders.length, receivers.length, center);
|
|
233
|
+
return [...body, ...footer].join("\n");
|
|
234
|
+
}
|
|
235
|
+
/** Where this page sits, and that the picture loads the rest when an address is clicked. */
|
|
236
|
+
function pagingLines(view, shownSenders, shownReceivers, center) {
|
|
237
|
+
const totalIn = center?.degree_in ?? 0;
|
|
238
|
+
const totalOut = center?.degree_out ?? 0;
|
|
239
|
+
const { in_offset: nextIn, out_offset: nextOut } = view.cursor;
|
|
240
|
+
const range = (shown, next, total) => shown === 0 ? `none of ${total}` : `${next - shown + 1} to ${next} of ${total}`;
|
|
241
|
+
const where = `Showing senders ${range(shownSenders, nextIn, totalIn)} and receivers ${range(shownReceivers, nextOut, totalOut)}, newest first.`;
|
|
242
|
+
if (!view.truncated && nextIn >= totalIn && nextOut >= totalOut) return [where];
|
|
243
|
+
return [where, "More senders and receivers load when an address in the picture is clicked."];
|
|
244
|
+
}
|
|
245
|
+
/** A finite number, or a decimal string read as one, or null. */
|
|
246
|
+
function flowsNumberOrNull(value) {
|
|
247
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
248
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
249
|
+
const parsed = Number(value);
|
|
250
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
function transferOf(row) {
|
|
255
|
+
const amount = typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : typeof row.amount === "string" && row.amount.trim() !== "" ? row.amount : null;
|
|
256
|
+
return {
|
|
257
|
+
tx_id: typeof row.tx_id === "string" ? row.tx_id : "",
|
|
258
|
+
block_timestamp: flowsNumberOrNull(row.block_timestamp),
|
|
259
|
+
amount,
|
|
260
|
+
asset_symbol: typeof row.asset_symbol === "string" && row.asset_symbol !== "" ? row.asset_symbol : null,
|
|
261
|
+
amount_usd: flowsNumberOrNull(row.amount_usd)
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* chain-insights.transfers.v1 from the rows of one facts read: newest first,
|
|
266
|
+
* at most TRANSFERS_MAX, truncated when more rows than that came back. The
|
|
267
|
+
* facts layer takes no ORDER BY, so the order is made here.
|
|
268
|
+
*/
|
|
269
|
+
function buildTransfersView(network, from, to, day, rows) {
|
|
270
|
+
const newestFirst = rows.map(transferOf).sort((a, b) => (b.block_timestamp ?? -Infinity) - (a.block_timestamp ?? -Infinity));
|
|
271
|
+
return {
|
|
272
|
+
schema: TRANSFERS_SCHEMA,
|
|
273
|
+
network,
|
|
274
|
+
from,
|
|
275
|
+
to,
|
|
276
|
+
day,
|
|
277
|
+
transfers: newestFirst.slice(0, 50),
|
|
278
|
+
truncated: newestFirst.length > 50
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function transferLine(transfer) {
|
|
282
|
+
return `- ${transfer.block_timestamp === null ? "at an unknown time" : `${new Date(transfer.block_timestamp).toISOString().slice(0, 19).replace("T", " ")} UTC`}, ${transfer.amount === null ? "an unknown amount" : String(transfer.amount)}${transfer.asset_symbol ? ` ${transfer.asset_symbol}` : ""}, ${transfer.amount_usd === null ? "no USD price" : formatFlowsUsd(transfer.amount_usd)}, tx ${transfer.tx_id}`;
|
|
283
|
+
}
|
|
284
|
+
/** The short text of a link answer: the pair, the day, the count and the newest few transfers. */
|
|
285
|
+
function transfersSummary(view) {
|
|
286
|
+
const head = `Transfers from ${view.from} to ${view.to} on ${view.day} (UTC) on ${view.network}`;
|
|
287
|
+
if (view.transfers.length === 0) return `${head}: none.`;
|
|
288
|
+
const lines = [`${head}: ${view.truncated ? `the newest ${view.transfers.length}, and the day holds more` : `${view.transfers.length}`}, newest first.`, ...view.transfers.slice(0, 5).map(transferLine)];
|
|
289
|
+
const rest = view.transfers.length - 5;
|
|
290
|
+
if (rest > 0) lines.push(`- and ${rest} more in the picture.`);
|
|
291
|
+
return lines.join("\n");
|
|
292
|
+
}
|
|
293
|
+
function errorResult$1(text) {
|
|
294
|
+
return {
|
|
295
|
+
content: [{
|
|
296
|
+
type: "text",
|
|
297
|
+
text
|
|
298
|
+
}],
|
|
299
|
+
isError: true
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function isRecord$1(value) {
|
|
303
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
304
|
+
}
|
|
305
|
+
function answerText(answer) {
|
|
306
|
+
return (answer.content ?? []).map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").filter(Boolean).join("\n").trim();
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The refusal of a read that did not answer, naming its code first:
|
|
310
|
+
* error_detail.code when the graph endpoint sent its typed refusal, the code
|
|
311
|
+
* its text starts with otherwise, and graph_query_failed when it names none.
|
|
312
|
+
* The view marks the address it was expanding.
|
|
313
|
+
*/
|
|
314
|
+
function refusalText(answer) {
|
|
315
|
+
const text = answerText(answer);
|
|
316
|
+
const detail = isRecord$1(answer.structuredContent) ? answer.structuredContent.error_detail : void 0;
|
|
317
|
+
const detailCode = isRecord$1(detail) && typeof detail.code === "string" ? detail.code : "";
|
|
318
|
+
const textCode = CODE_PREFIX.exec(text)?.[1] ?? "";
|
|
319
|
+
const code = detailCode || textCode || "graph_query_failed";
|
|
320
|
+
const rest = textCode === code ? text.replace(CODE_PREFIX, "") : text;
|
|
321
|
+
return rest ? `${code}: ${rest}` : `${code}: the graph endpoint refused the read`;
|
|
322
|
+
}
|
|
323
|
+
function rowsOf(answer) {
|
|
324
|
+
const facts = isRecord$1(answer.structuredContent) ? answer.structuredContent.facts : void 0;
|
|
325
|
+
const query = isRecord$1(facts) ? facts.query : void 0;
|
|
326
|
+
const results = isRecord$1(query) ? query.results : void 0;
|
|
327
|
+
return Array.isArray(results) ? results.filter(isRecord$1) : null;
|
|
328
|
+
}
|
|
329
|
+
function checkNetwork(args) {
|
|
330
|
+
const rawNetwork = typeof args.network === "string" ? args.network.trim().toLowerCase() : "";
|
|
331
|
+
if (!rawNetwork) return { error: "invalid_network: network is required; pass robinhood" };
|
|
332
|
+
if (rawNetwork !== "robinhood") return { error: `invalid_network: graph_expand reads the robinhood graph only; got "${String(args.network).trim()}"` };
|
|
333
|
+
return FLOWS_NETWORK;
|
|
334
|
+
}
|
|
335
|
+
/** The address of a node or of one end of a link; `name` says which end in a link refusal. */
|
|
336
|
+
function checkAddress(raw, name) {
|
|
337
|
+
const address = normalizeFlowsAddress(raw);
|
|
338
|
+
if (address) return address;
|
|
339
|
+
return { error: name ? INVALID_ADDRESS_TEXT.replace("invalid_address:", `invalid_address (${name}):`) : INVALID_ADDRESS_TEXT };
|
|
340
|
+
}
|
|
341
|
+
function checkDay(raw) {
|
|
342
|
+
return normalizeFlowsDay(raw) ?? { error: "invalid_day: day must be a calendar date written YYYY-MM-DD (UTC), for example 2026-07-10" };
|
|
343
|
+
}
|
|
344
|
+
function checkOffset(value) {
|
|
345
|
+
if (value === void 0 || value === null) return 0;
|
|
346
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return { error: OFFSET_NEGATIVE_TEXT };
|
|
347
|
+
if (value > 1e4) return { error: OFFSET_TOO_LARGE_TEXT };
|
|
348
|
+
return value;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Read and answer one node page. The reads run one after another: the node
|
|
352
|
+
* first, so an address the graph does not hold costs one read, not three; then
|
|
353
|
+
* the receivers and the senders. The first read that does not answer ends the
|
|
354
|
+
* call.
|
|
355
|
+
*/
|
|
356
|
+
async function runNode(deps, network, address, inOffset, outOffset) {
|
|
357
|
+
const unavailable = deps.unavailable?.();
|
|
358
|
+
if (unavailable) return errorResult$1(unavailable);
|
|
359
|
+
const rows = {};
|
|
360
|
+
for (const read of flowsReadsFor(address, inOffset, outOffset)) {
|
|
361
|
+
let answer;
|
|
362
|
+
try {
|
|
363
|
+
answer = await deps.graphQuery({
|
|
364
|
+
network,
|
|
365
|
+
query: read.query
|
|
366
|
+
});
|
|
367
|
+
} catch (err) {
|
|
368
|
+
return errorResult$1(deps.describeFailure(err));
|
|
369
|
+
}
|
|
370
|
+
if (answer.isError === true) return errorResult$1(refusalText(answer));
|
|
371
|
+
const found = rowsOf(answer);
|
|
372
|
+
if (!found) return errorResult$1("graph_query_failed: the graph endpoint answered without chain-insights.result.v1 rows");
|
|
373
|
+
rows[read.id] = found;
|
|
374
|
+
if (read.id === "node" && found.length === 0) return errorResult$1(`${address} is not in the ${network} topology graph`);
|
|
375
|
+
}
|
|
376
|
+
const view = buildFlowView(network, {
|
|
377
|
+
center: rows.node?.[0] ?? {},
|
|
378
|
+
senders: rows.in ?? [],
|
|
379
|
+
receivers: rows.out ?? [],
|
|
380
|
+
inOffset,
|
|
381
|
+
outOffset
|
|
382
|
+
});
|
|
383
|
+
return {
|
|
384
|
+
content: [{
|
|
385
|
+
type: "text",
|
|
386
|
+
text: flowsSummary(view, deps.now ? deps.now() : /* @__PURE__ */ new Date())
|
|
387
|
+
}],
|
|
388
|
+
structuredContent: view,
|
|
389
|
+
isError: false
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
/** Read and answer the transfers of one link on one day: a single facts read. */
|
|
393
|
+
async function runLink(deps, network, from, to, day) {
|
|
394
|
+
const unavailable = deps.unavailable?.();
|
|
395
|
+
if (unavailable) return errorResult$1(unavailable);
|
|
396
|
+
let answer;
|
|
397
|
+
try {
|
|
398
|
+
answer = await deps.graphQuery({
|
|
399
|
+
network,
|
|
400
|
+
query: transfersReadFor(from, to, day)
|
|
401
|
+
});
|
|
402
|
+
} catch (err) {
|
|
403
|
+
return errorResult$1(deps.describeFailure(err));
|
|
404
|
+
}
|
|
405
|
+
if (answer.isError === true) return errorResult$1(refusalText(answer));
|
|
406
|
+
const rows = rowsOf(answer);
|
|
407
|
+
if (!rows) return errorResult$1("graph_query_failed: the graph endpoint answered without chain-insights.result.v1 rows");
|
|
408
|
+
const view = buildTransfersView(network, from, to, day, rows);
|
|
409
|
+
return {
|
|
410
|
+
content: [{
|
|
411
|
+
type: "text",
|
|
412
|
+
text: transfersSummary(view)
|
|
413
|
+
}],
|
|
414
|
+
structuredContent: view,
|
|
415
|
+
isError: false
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function given(record, keys) {
|
|
419
|
+
return keys.some((key) => record[key] !== void 0 && record[key] !== null);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* graph_expand, the node form {network, address, in_offset?, out_offset?} or the
|
|
423
|
+
* link form {network, from, to, day}. Every argument is checked before the
|
|
424
|
+
* first read.
|
|
425
|
+
*/
|
|
426
|
+
async function handleGraphExpand(args, deps) {
|
|
427
|
+
const record = isRecord$1(args) ? args : {};
|
|
428
|
+
const network = checkNetwork(record);
|
|
429
|
+
if (typeof network !== "string") return errorResult$1(network.error);
|
|
430
|
+
const isLink = given(record, [
|
|
431
|
+
"from",
|
|
432
|
+
"to",
|
|
433
|
+
"day"
|
|
434
|
+
]);
|
|
435
|
+
if (isLink && given(record, [
|
|
436
|
+
"address",
|
|
437
|
+
"in_offset",
|
|
438
|
+
"out_offset"
|
|
439
|
+
])) return errorResult$1(BOTH_FORMS_TEXT);
|
|
440
|
+
if (isLink) {
|
|
441
|
+
const from = checkAddress(record.from, "from");
|
|
442
|
+
if (typeof from !== "string") return errorResult$1(from.error);
|
|
443
|
+
const to = checkAddress(record.to, "to");
|
|
444
|
+
if (typeof to !== "string") return errorResult$1(to.error);
|
|
445
|
+
const day = checkDay(record.day);
|
|
446
|
+
if (typeof day !== "string") return errorResult$1(day.error);
|
|
447
|
+
return runLink(deps, network, from, to, day);
|
|
448
|
+
}
|
|
449
|
+
const address = checkAddress(record.address);
|
|
450
|
+
if (typeof address !== "string") return errorResult$1(address.error);
|
|
451
|
+
const inOffset = checkOffset(record.in_offset);
|
|
452
|
+
if (typeof inOffset !== "number") return errorResult$1(inOffset.error);
|
|
453
|
+
const outOffset = checkOffset(record.out_offset);
|
|
454
|
+
if (typeof outOffset !== "number") return errorResult$1(outOffset.error);
|
|
455
|
+
return runNode(deps, network, address, inOffset, outOffset);
|
|
456
|
+
}
|
|
457
|
+
const GRAPH_EXPAND_TITLE = "Expand a node or a link in the picture";
|
|
458
|
+
const GRAPH_EXPAND_DESCRIPTION = "Expand an open Chain Insights picture. Called by the view when the investigator clicks, never by the model. A node, {network, address, in_offset?, out_offset?}, loads the newest senders and receivers of one address (12 a side, from the offsets); billed as three graph_query topology reads. A link, {network, from, to, day}, lists the transfers between two addresses on one UTC day (YYYY-MM-DD), newest first, at most 50; billed as one graph_query facts read. Read-only; an argument refused before a read costs nothing.";
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region src/mcp/graph-schema-cache.ts
|
|
461
|
+
/** How long a schema with a failed read stays fresh. */
|
|
462
|
+
const PARTIAL_SCHEMA_TTL_MS = 6e5;
|
|
463
|
+
function endpointHash(endpoint) {
|
|
464
|
+
return createHash("sha256").update(endpoint).digest("hex").slice(0, 12);
|
|
465
|
+
}
|
|
466
|
+
/** The cache file of a network on an endpoint. The network is an identifier already checked by the caller. */
|
|
467
|
+
function graphSchemaCachePath(network, endpoint) {
|
|
468
|
+
return path.join(chainInsightsHome(), "cache", `schema-${network}-${endpointHash(endpoint)}.json`);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* The cached schema, marked cached, or null when there is none, it is stale, or
|
|
472
|
+
* the file cannot be read. A cache is derived data: a damaged file is a miss and
|
|
473
|
+
* the next build replaces it.
|
|
474
|
+
*/
|
|
475
|
+
async function loadGraphSchemaCache(network, endpoint, now) {
|
|
476
|
+
let parsed;
|
|
477
|
+
try {
|
|
478
|
+
parsed = JSON.parse(await readFile(graphSchemaCachePath(network, endpoint), "utf8"));
|
|
479
|
+
} catch {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
const file = parsed;
|
|
483
|
+
if (!file || typeof file.cached_at !== "number" || file.endpoint !== endpoint || file.document?.schema !== "chain-insights.graph-schema.v1") return null;
|
|
484
|
+
const lifetime = file.complete === true ? CACHE_TTL_MS : PARTIAL_SCHEMA_TTL_MS;
|
|
485
|
+
const age = now.getTime() - file.cached_at;
|
|
486
|
+
if (age < 0 || age > lifetime) return null;
|
|
487
|
+
return {
|
|
488
|
+
...file.document,
|
|
489
|
+
cached: true
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
/** `network` is the name the caller asked for, the same one a later load asks with. */
|
|
493
|
+
async function saveGraphSchemaCache(network, document, endpoint, complete, now) {
|
|
494
|
+
const target = graphSchemaCachePath(network, endpoint);
|
|
495
|
+
const file = {
|
|
496
|
+
cached_at: now.getTime(),
|
|
497
|
+
complete,
|
|
498
|
+
endpoint,
|
|
499
|
+
document: {
|
|
500
|
+
...document,
|
|
501
|
+
cached: false
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
505
|
+
const scratch = `${target}.${process.pid}.tmp`;
|
|
506
|
+
await writeFile(scratch, JSON.stringify(file, null, 2) + "\n", { mode: 384 });
|
|
507
|
+
await rename(scratch, target);
|
|
508
|
+
}
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/mcp/graph-schema.ts
|
|
511
|
+
/**
|
|
512
|
+
* meta_schema: the live graph schema of one network, read from the graph
|
|
513
|
+
* endpoint and kept on disk for 24 hours.
|
|
514
|
+
*
|
|
515
|
+
* The graph serves four read-only catalog statements on `USE topology` through
|
|
516
|
+
* the ordinary graph_query tool: `CALL db.labels()`, `CALL db.relationshipTypes()`,
|
|
517
|
+
* `CALL db.propertyKeys()` and `SHOW INDEXES`. They name the labels, the link
|
|
518
|
+
* types, every property key and the indexes. They do not say which keys belong to
|
|
519
|
+
* which kind, so the main kinds are sampled with the two probe forms the server
|
|
520
|
+
* admits (`keys(a)` on addresses, `keys(r)` on a link). The facts relationships
|
|
521
|
+
* and the chain lookups come from the network's published capabilities.
|
|
522
|
+
*
|
|
523
|
+
* One build is one MCP session and runs its reads one after another: the public
|
|
524
|
+
* endpoint allows about 30 requests a minute for each IP address, and opening a
|
|
525
|
+
* session costs three of them. A read that fails leaves its section empty and
|
|
526
|
+
* adds a note. It never fails the tool.
|
|
527
|
+
*/
|
|
528
|
+
var graph_schema_exports = /* @__PURE__ */ __exportAll({
|
|
529
|
+
ADDRESS_SAMPLE_LIMIT: () => 20,
|
|
530
|
+
FACTS_TRANSFER_COLUMNS: () => FACTS_TRANSFER_COLUMNS,
|
|
531
|
+
GRAPH_SCHEMA_ID: () => GRAPH_SCHEMA_ID,
|
|
532
|
+
GRAPH_SCHEMA_SOURCE: () => GRAPH_SCHEMA_SOURCE,
|
|
533
|
+
GraphSchemaError: () => GraphSchemaError,
|
|
534
|
+
LINK_SAMPLE_LIMIT: () => 5,
|
|
535
|
+
META_SCHEMA_DESCRIPTION: () => META_SCHEMA_DESCRIPTION,
|
|
536
|
+
META_SCHEMA_TITLE: () => META_SCHEMA_TITLE,
|
|
537
|
+
SAMPLED_LINK_TYPES: () => SAMPLED_LINK_TYPES,
|
|
538
|
+
addressSampleQuery: () => addressSampleQuery,
|
|
539
|
+
buildGraphSchema: () => buildGraphSchema,
|
|
540
|
+
formatGraphSchemaText: () => formatGraphSchemaText,
|
|
541
|
+
getGraphSchema: () => getGraphSchema,
|
|
542
|
+
handleMetaSchema: () => handleMetaSchema,
|
|
543
|
+
linkSampleQuery: () => linkSampleQuery,
|
|
544
|
+
normalizeSchemaNetwork: () => normalizeSchemaNetwork
|
|
545
|
+
});
|
|
546
|
+
const GRAPH_SCHEMA_ID = "chain-insights.graph-schema.v1";
|
|
547
|
+
const GRAPH_SCHEMA_SOURCE = "live catalog";
|
|
548
|
+
const META_SCHEMA_TITLE = "Graph schema";
|
|
549
|
+
const META_SCHEMA_DESCRIPTION = "Return the live graph schema of one network: the labels, the link types, the field names of an address and of each main link, the indexes, the USE facts relationships with the TRANSFER columns, and the USE chain lookups. Call it first when you need field names. It is cached for 24 hours; pass refresh=true to rebuild it.";
|
|
550
|
+
/** The link types an agent queries. Each is sampled only when the graph lists it. */
|
|
551
|
+
const SAMPLED_LINK_TYPES = [
|
|
552
|
+
"FLOWS_TO",
|
|
553
|
+
"OPERATED_BY",
|
|
554
|
+
"LINKED",
|
|
555
|
+
"SWAPPED",
|
|
556
|
+
"BRIDGED",
|
|
557
|
+
"ADDED_LIQUIDITY",
|
|
558
|
+
"REMOVED_LIQUIDITY"
|
|
559
|
+
];
|
|
560
|
+
const FACTS_TRANSFER_COLUMNS = [
|
|
561
|
+
"tx_id",
|
|
562
|
+
"block_date",
|
|
563
|
+
"block_height",
|
|
564
|
+
"block_timestamp",
|
|
565
|
+
"event_index",
|
|
566
|
+
"edge_index",
|
|
567
|
+
"kind",
|
|
568
|
+
"asset_contract",
|
|
569
|
+
"asset_symbol",
|
|
570
|
+
"amount",
|
|
571
|
+
"amount_usd",
|
|
572
|
+
"price_usd",
|
|
573
|
+
"price_missing",
|
|
574
|
+
"token_id",
|
|
575
|
+
"token_standard",
|
|
576
|
+
"operator_address",
|
|
577
|
+
"raw_amount",
|
|
578
|
+
"decimals"
|
|
579
|
+
];
|
|
580
|
+
const CATALOG_READS = {
|
|
581
|
+
labels: {
|
|
582
|
+
label: "labels",
|
|
583
|
+
query: "USE topology CALL db.labels()"
|
|
584
|
+
},
|
|
585
|
+
linkTypes: {
|
|
586
|
+
label: "link types",
|
|
587
|
+
query: "USE topology CALL db.relationshipTypes()"
|
|
588
|
+
},
|
|
589
|
+
propertyKeys: {
|
|
590
|
+
label: "property keys",
|
|
591
|
+
query: "USE topology CALL db.propertyKeys()"
|
|
592
|
+
},
|
|
593
|
+
indexes: {
|
|
594
|
+
label: "indexes",
|
|
595
|
+
query: "USE topology SHOW INDEXES"
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
function addressSampleQuery() {
|
|
599
|
+
return `USE topology MATCH (a:Address) RETURN keys(a) AS keys LIMIT 20`;
|
|
600
|
+
}
|
|
601
|
+
function linkSampleQuery(linkType) {
|
|
602
|
+
return `USE topology MATCH ()-[r:${linkType}]->() RETURN keys(r) AS keys LIMIT 5`;
|
|
603
|
+
}
|
|
604
|
+
/** A refusal with its own words for the caller: a bad argument, an unserved network, an endpoint that is down. */
|
|
605
|
+
var GraphSchemaError = class extends Error {
|
|
606
|
+
constructor(message) {
|
|
607
|
+
super(message);
|
|
608
|
+
this.name = "GraphSchemaError";
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
function isRecord(value) {
|
|
612
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
613
|
+
}
|
|
614
|
+
function sortedUnique(values) {
|
|
615
|
+
return [...new Set(values)].sort();
|
|
616
|
+
}
|
|
617
|
+
function stringList(value) {
|
|
618
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string" && entry !== "") : [];
|
|
619
|
+
}
|
|
620
|
+
const NETWORK_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
621
|
+
/**
|
|
622
|
+
* The network as a lower-case identifier. It becomes part of a file name and of
|
|
623
|
+
* every query, so only an identifier passes.
|
|
624
|
+
*/
|
|
625
|
+
function normalizeSchemaNetwork(raw) {
|
|
626
|
+
const network = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
|
627
|
+
if (!network) throw new GraphSchemaError("invalid_network: network is required. Pass the identifier of a served network, for example robinhood.");
|
|
628
|
+
if (!NETWORK_PATTERN.test(network)) throw new GraphSchemaError(`invalid_network: "${String(raw).trim()}" is not a network identifier. Pass the identifier of a served network, for example robinhood.`);
|
|
629
|
+
return network;
|
|
630
|
+
}
|
|
631
|
+
function queryBlock(structured) {
|
|
632
|
+
const facts = isRecord(structured) ? structured.facts : void 0;
|
|
633
|
+
const query = isRecord(facts) ? facts.query : void 0;
|
|
634
|
+
return isRecord(query) ? query : null;
|
|
635
|
+
}
|
|
636
|
+
/** The `facts.query` block of one graph_query reply, from structuredContent or from the JSON text. */
|
|
637
|
+
function answerQuery(answer) {
|
|
638
|
+
const fromStructured = queryBlock(answer.structuredContent);
|
|
639
|
+
if (fromStructured) return fromStructured;
|
|
640
|
+
for (const block of answer.content ?? []) {
|
|
641
|
+
if (block.type !== "text" || typeof block.text !== "string") continue;
|
|
642
|
+
try {
|
|
643
|
+
const parsed = queryBlock(JSON.parse(block.text));
|
|
644
|
+
if (parsed) return parsed;
|
|
645
|
+
} catch {}
|
|
646
|
+
}
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
function haltOfError(err) {
|
|
650
|
+
if (err instanceof PaymentRequiredError) return "payment";
|
|
651
|
+
const code = isRecord(err) ? err.code : void 0;
|
|
652
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
653
|
+
if (code === 429 || /\b429\b|too many requests|rate.?limit/i.test(message)) return "rate_limit";
|
|
654
|
+
if (/\b402\b|payment required/i.test(message)) return "payment";
|
|
655
|
+
}
|
|
656
|
+
function haltOfAnswer(answer) {
|
|
657
|
+
const detail = isRecord(answer.structuredContent) ? answer.structuredContent.error_detail : void 0;
|
|
658
|
+
return isRecord(detail) && detail.class === "capacity" ? "rate_limit" : void 0;
|
|
659
|
+
}
|
|
660
|
+
function describeIndex(row) {
|
|
661
|
+
const name = typeof row.name === "string" ? row.name : "";
|
|
662
|
+
const entity = row.entityType === "NODE" ? "node" : row.entityType === "RELATIONSHIP" ? "link" : null;
|
|
663
|
+
const properties = stringList(row.properties);
|
|
664
|
+
if (!name || !entity || properties.length === 0) return null;
|
|
665
|
+
return {
|
|
666
|
+
name,
|
|
667
|
+
entity,
|
|
668
|
+
types: stringList(row.labelsOrTypes),
|
|
669
|
+
properties,
|
|
670
|
+
state: typeof row.state === "string" && row.state ? row.state : "unknown"
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
async function buildGraphSchema(options) {
|
|
674
|
+
const { client } = options;
|
|
675
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
676
|
+
const pauseMs = options.pauseMs ?? 0;
|
|
677
|
+
const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
678
|
+
const problems = [];
|
|
679
|
+
const remarks = [];
|
|
680
|
+
let billed = 0;
|
|
681
|
+
let calls = 0;
|
|
682
|
+
let halted = false;
|
|
683
|
+
async function pace() {
|
|
684
|
+
if (calls > 0 && pauseMs > 0) await sleep(pauseMs);
|
|
685
|
+
calls += 1;
|
|
686
|
+
}
|
|
687
|
+
function fail(label, reason, halt) {
|
|
688
|
+
problems.push(`${label} could not be read: ${reason}`);
|
|
689
|
+
if (halt && !halted) {
|
|
690
|
+
halted = true;
|
|
691
|
+
if (halt === "payment") problems.push("The endpoint asks for payment, so the reads after it were not sent. Fix the payment setup, then call again with refresh=true.");
|
|
692
|
+
else {
|
|
693
|
+
const wait = /retry after (\d+) seconds?/i.exec(reason)?.[1];
|
|
694
|
+
problems.push(`The endpoint is limiting requests, so the reads after it were not sent. Call again after ${wait ? `${wait} seconds` : "a minute"} with refresh=true.`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
async function graphRead(network, label, query) {
|
|
700
|
+
if (halted) return null;
|
|
701
|
+
await pace();
|
|
702
|
+
let answer;
|
|
703
|
+
try {
|
|
704
|
+
answer = await client.callTool({
|
|
705
|
+
name: "graph_query",
|
|
706
|
+
arguments: {
|
|
707
|
+
network,
|
|
708
|
+
query
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
} catch (err) {
|
|
712
|
+
return fail(label, err instanceof Error ? err.message : String(err), haltOfError(err));
|
|
713
|
+
}
|
|
714
|
+
const block = answerQuery(answer ?? {});
|
|
715
|
+
if (block && typeof block.billable_units === "number") billed += block.billable_units;
|
|
716
|
+
if (answer?.isError === true) return fail(label, refusalText(answer), haltOfAnswer(answer));
|
|
717
|
+
if (!block || !Array.isArray(block.results)) return fail(label, "the reply holds no rows");
|
|
718
|
+
return block.results.filter(isRecord);
|
|
719
|
+
}
|
|
720
|
+
let network = options.network;
|
|
721
|
+
let capability;
|
|
722
|
+
await pace();
|
|
723
|
+
try {
|
|
724
|
+
const answer = await client.callTool({
|
|
725
|
+
name: "network_capabilities",
|
|
726
|
+
arguments: {}
|
|
727
|
+
});
|
|
728
|
+
if (answer?.isError === true) fail("network capabilities", refusalText(answer), haltOfAnswer(answer));
|
|
729
|
+
else {
|
|
730
|
+
const facts = isRecord(answer?.structuredContent) ? answer.structuredContent.facts : void 0;
|
|
731
|
+
const capabilities = isRecord(facts) ? facts.capabilities : void 0;
|
|
732
|
+
const networks = isRecord(capabilities) && Array.isArray(capabilities.networks) ? capabilities.networks : null;
|
|
733
|
+
if (!networks) fail("network capabilities", "the reply lists no networks");
|
|
734
|
+
else {
|
|
735
|
+
const document = mirrorGraphNetworkCapabilities({ networks });
|
|
736
|
+
capability = findNetworkCapability(document, options.network);
|
|
737
|
+
if (!capability) {
|
|
738
|
+
const served = document.networks.map((entry) => entry.network).join(", ");
|
|
739
|
+
throw new GraphSchemaError(`network_not_served: "${options.network}" is not a network this endpoint serves. Served networks: ${served || "none"}.`);
|
|
740
|
+
}
|
|
741
|
+
network = capability.network;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
} catch (err) {
|
|
745
|
+
if (err instanceof GraphSchemaError) throw err;
|
|
746
|
+
fail("network capabilities", err instanceof Error ? err.message : String(err), haltOfError(err));
|
|
747
|
+
}
|
|
748
|
+
const topology = {
|
|
749
|
+
labels: [],
|
|
750
|
+
link_types: [],
|
|
751
|
+
property_keys: [],
|
|
752
|
+
indexes: [],
|
|
753
|
+
samples: {}
|
|
754
|
+
};
|
|
755
|
+
if (capability?.layers?.topology?.enabled === false) remarks.push("The topology layer is off on this network.");
|
|
756
|
+
else {
|
|
757
|
+
const labels = await graphRead(network, CATALOG_READS.labels.label, CATALOG_READS.labels.query);
|
|
758
|
+
if (labels) topology.labels = sortedUnique(labels.flatMap((row) => stringList([row.label])));
|
|
759
|
+
const linkTypes = await graphRead(network, CATALOG_READS.linkTypes.label, CATALOG_READS.linkTypes.query);
|
|
760
|
+
if (linkTypes) topology.link_types = sortedUnique(linkTypes.flatMap((row) => stringList([row.relationshipType])));
|
|
761
|
+
const keys = await graphRead(network, CATALOG_READS.propertyKeys.label, CATALOG_READS.propertyKeys.query);
|
|
762
|
+
if (keys) topology.property_keys = sortedUnique(keys.flatMap((row) => stringList([row.propertyKey])));
|
|
763
|
+
const indexes = await graphRead(network, CATALOG_READS.indexes.label, CATALOG_READS.indexes.query);
|
|
764
|
+
if (indexes) topology.indexes = indexes.map(describeIndex).filter((index) => index !== null).sort((a, b) => a.name.localeCompare(b.name));
|
|
765
|
+
if (!(labels !== null) || topology.labels.includes("Address")) {
|
|
766
|
+
const rows = await graphRead(network, "the Address sample", addressSampleQuery());
|
|
767
|
+
if (rows) topology.samples["Address"] = unionOfKeys(rows);
|
|
768
|
+
}
|
|
769
|
+
const sampled = SAMPLED_LINK_TYPES.filter((type) => topology.link_types.includes(type));
|
|
770
|
+
if (linkTypes === null && !halted) problems.push("The link samples were skipped: the link types are unknown.");
|
|
771
|
+
for (const type of sampled) {
|
|
772
|
+
const rows = await graphRead(network, `the ${type} sample`, linkSampleQuery(type));
|
|
773
|
+
if (rows) topology.samples[type] = unionOfKeys(rows);
|
|
774
|
+
}
|
|
775
|
+
const unsampled = topology.link_types.filter((type) => !SAMPLED_LINK_TYPES.includes(type));
|
|
776
|
+
if (unsampled.length > 0) remarks.push(`Link types with no sample: ${unsampled.join(", ")}. Read the keys of one with USE topology MATCH ()-[r:<TYPE>]->() RETURN keys(r) AS keys LIMIT 5.`);
|
|
777
|
+
}
|
|
778
|
+
const facts = {
|
|
779
|
+
relationships: [],
|
|
780
|
+
transfer_columns: []
|
|
781
|
+
};
|
|
782
|
+
const chain = { lookups: [] };
|
|
783
|
+
if (capability) {
|
|
784
|
+
if (capability.layers?.facts?.enabled === false) remarks.push("The facts layer is off on this network.");
|
|
785
|
+
else {
|
|
786
|
+
facts.relationships = stringList(capability.layers?.facts?.relationships);
|
|
787
|
+
if (facts.relationships.includes("TRANSFER")) facts.transfer_columns = [...FACTS_TRANSFER_COLUMNS];
|
|
788
|
+
}
|
|
789
|
+
const admission = capability.chain_admission;
|
|
790
|
+
if (admission?.enabled === false) remarks.push(`The chain layer is off (status ${admission.status ?? "unknown"}).`);
|
|
791
|
+
else chain.lookups = stringList(admission?.lookups);
|
|
792
|
+
}
|
|
793
|
+
const words = [...topology.property_keys, ...Object.values(topology.samples).flat()];
|
|
794
|
+
if (words.includes("synced_through_height") || words.includes("pair_key")) remarks.push("synced_through_height and pair_key are sync bookkeeping: never filter or sort on them.");
|
|
795
|
+
remarks.push("Fields come from a sample of the live graph. A rare field may be missing.");
|
|
796
|
+
return {
|
|
797
|
+
complete: problems.length === 0,
|
|
798
|
+
document: {
|
|
799
|
+
schema: GRAPH_SCHEMA_ID,
|
|
800
|
+
network,
|
|
801
|
+
built_at: now().toISOString(),
|
|
802
|
+
source: GRAPH_SCHEMA_SOURCE,
|
|
803
|
+
cached: false,
|
|
804
|
+
billed_units: billed,
|
|
805
|
+
layers: {
|
|
806
|
+
topology,
|
|
807
|
+
facts,
|
|
808
|
+
chain
|
|
809
|
+
},
|
|
810
|
+
notes: [...problems, ...remarks]
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
function unionOfKeys(rows) {
|
|
815
|
+
return sortedUnique(rows.flatMap((row) => stringList(row.keys)));
|
|
816
|
+
}
|
|
817
|
+
const building = /* @__PURE__ */ new Map();
|
|
818
|
+
async function getGraphSchema(options) {
|
|
819
|
+
const network = normalizeSchemaNetwork(options.network);
|
|
820
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
821
|
+
if (options.refresh !== true) {
|
|
822
|
+
const cached = await loadGraphSchemaCache(network, options.endpoint, now());
|
|
823
|
+
if (cached) return cached;
|
|
824
|
+
}
|
|
825
|
+
const key = `${options.endpoint}\n${network}`;
|
|
826
|
+
const running = building.get(key);
|
|
827
|
+
if (running) return running;
|
|
828
|
+
const build = (async () => {
|
|
829
|
+
const { document, complete } = await options.withClient((client) => buildGraphSchema({
|
|
830
|
+
client,
|
|
831
|
+
network,
|
|
832
|
+
now,
|
|
833
|
+
...options.pauseMs === void 0 ? {} : { pauseMs: options.pauseMs },
|
|
834
|
+
...options.sleep ? { sleep: options.sleep } : {}
|
|
835
|
+
}));
|
|
836
|
+
try {
|
|
837
|
+
await saveGraphSchemaCache(network, document, options.endpoint, complete, now());
|
|
838
|
+
} catch (err) {
|
|
839
|
+
document.notes.push(`The schema could not be saved to the cache: ${err instanceof Error ? err.message : String(err)}`);
|
|
840
|
+
}
|
|
841
|
+
return document;
|
|
842
|
+
})();
|
|
843
|
+
building.set(key, build);
|
|
844
|
+
try {
|
|
845
|
+
return await build;
|
|
846
|
+
} finally {
|
|
847
|
+
building.delete(key);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
function listLine(title, values, empty = "none read") {
|
|
851
|
+
return `${title}: ${values.length > 0 ? values.join(", ") : empty}`;
|
|
852
|
+
}
|
|
853
|
+
/** The schema as the model and the terminal read it. */
|
|
854
|
+
function formatGraphSchemaText(document) {
|
|
855
|
+
const { topology, facts, chain } = document.layers;
|
|
856
|
+
const origin = document.cached ? "from the cache, kept 24 hours; refresh=true rebuilds it" : `read now for ${document.billed_units} billed units and cached for 24 hours`;
|
|
857
|
+
const lines = [
|
|
858
|
+
`Graph schema, network ${document.network}`,
|
|
859
|
+
`Source: ${document.source}, built ${document.built_at} (${origin})`,
|
|
860
|
+
"",
|
|
861
|
+
"USE topology",
|
|
862
|
+
listLine("Labels", topology.labels),
|
|
863
|
+
listLine("Link types", topology.link_types),
|
|
864
|
+
listLine(`Property keys (${topology.property_keys.length})`, topology.property_keys)
|
|
865
|
+
];
|
|
866
|
+
lines.push(topology.indexes.length > 0 ? "Indexes:" : "Indexes: none read");
|
|
867
|
+
for (const index of topology.indexes) lines.push(`- ${index.entity} ${index.types.join("|")}(${index.properties.join(", ")}) ${index.state}`);
|
|
868
|
+
const sampled = Object.entries(topology.samples);
|
|
869
|
+
lines.push(sampled.length > 0 ? "Fields, from a sample:" : "Fields, from a sample: none read");
|
|
870
|
+
for (const [kind, fields] of sampled) lines.push(`- ${kind}: ${fields.join(", ")}`);
|
|
871
|
+
lines.push("", "USE facts", listLine("Relationships", facts.relationships), listLine("TRANSFER columns", facts.transfer_columns), "", "USE chain", listLine("Lookups", chain.lookups));
|
|
872
|
+
if (document.notes.length > 0) lines.push("", "Notes:", ...document.notes.map((note) => `- ${note}`));
|
|
873
|
+
return lines.join("\n");
|
|
874
|
+
}
|
|
875
|
+
function errorResult(text) {
|
|
876
|
+
return {
|
|
877
|
+
content: [{
|
|
878
|
+
type: "text",
|
|
879
|
+
text
|
|
880
|
+
}],
|
|
881
|
+
isError: true
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
/** meta_schema, {network, refresh?}. Every argument is checked before the first read. */
|
|
885
|
+
async function handleMetaSchema(args, deps) {
|
|
886
|
+
const record = isRecord(args) ? args : {};
|
|
887
|
+
const refresh = record.refresh;
|
|
888
|
+
if (refresh !== void 0 && refresh !== null && typeof refresh !== "boolean") return errorResult("invalid_refresh: refresh must be true or false");
|
|
889
|
+
try {
|
|
890
|
+
const document = await getGraphSchema({
|
|
891
|
+
network: record.network,
|
|
892
|
+
refresh: refresh === true,
|
|
893
|
+
endpoint: deps.endpoint,
|
|
894
|
+
withClient: deps.withClient,
|
|
895
|
+
...deps.now ? { now: deps.now } : {},
|
|
896
|
+
...deps.pauseMs === void 0 ? {} : { pauseMs: deps.pauseMs },
|
|
897
|
+
...deps.sleep ? { sleep: deps.sleep } : {}
|
|
898
|
+
});
|
|
899
|
+
return {
|
|
900
|
+
content: [{
|
|
901
|
+
type: "text",
|
|
902
|
+
text: formatGraphSchemaText(document)
|
|
903
|
+
}],
|
|
904
|
+
structuredContent: document,
|
|
905
|
+
isError: false
|
|
906
|
+
};
|
|
907
|
+
} catch (err) {
|
|
908
|
+
return errorResult(err instanceof GraphSchemaError ? err.message : deps.describeFailure(err));
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
//#endregion
|
|
912
|
+
export { handleMetaSchema as a, GRAPH_EXPAND_TITLE as c, graph_schema_exports as i, GRAPH_EXPAND_TOOL as l, META_SCHEMA_DESCRIPTION as n, FLOWS_MAX_OFFSET as o, META_SCHEMA_TITLE as r, GRAPH_EXPAND_DESCRIPTION as s, GraphSchemaError as t, handleGraphExpand as u };
|
|
913
|
+
|
|
914
|
+
//# sourceMappingURL=graph-schema-D1orCe5F.mjs.map
|