chain-insights 0.48.0 → 0.49.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/dist/cli.cjs CHANGED
@@ -152,7 +152,7 @@ async function printNetworkCapability(name, opts) {
152
152
  async function printNetworkSchema(name, opts) {
153
153
  const { loadConfig } = await Promise.resolve().then(() => require("./config-BxtMoo9c.cjs")).then((n) => n.config_exports);
154
154
  const { resolveGraphMcpEndpoint } = await Promise.resolve().then(() => require("./client-Ypw9BNbn.cjs")).then((n) => n.client_exports);
155
- const { formatGraphSchemaText, getGraphSchema } = await Promise.resolve().then(() => require("./graph-schema-CgWgVltN.cjs")).then((n) => n.graph_schema_exports);
155
+ const { formatGraphSchemaText, getGraphSchema } = await Promise.resolve().then(() => require("./graph-schema-CXD7NtEX.cjs")).then((n) => n.graph_schema_exports);
156
156
  const config = await loadConfig();
157
157
  const schema = await getGraphSchema({
158
158
  network: name,
package/dist/cli.mjs CHANGED
@@ -150,7 +150,7 @@ async function printNetworkCapability(name, opts) {
150
150
  async function printNetworkSchema(name, opts) {
151
151
  const { loadConfig } = await import("./config-9MabJh5R.mjs").then((n) => n.t);
152
152
  const { resolveGraphMcpEndpoint } = await import("./client-gsLi528K.mjs").then((n) => n.r);
153
- const { formatGraphSchemaText, getGraphSchema } = await import("./graph-schema-DhVJiVba.mjs").then((n) => n.i);
153
+ const { formatGraphSchemaText, getGraphSchema } = await import("./graph-schema-D7wSNOT5.mjs").then((n) => n.i);
154
154
  const config = await loadConfig();
155
155
  const schema = await getGraphSchema({
156
156
  network: name,
@@ -16,11 +16,12 @@ let node_crypto = require("node:crypto");
16
16
  *
17
17
  * Two forms, told apart by the arguments:
18
18
  *
19
- * - node, {network, address, in_offset?, out_offset?}: one address with its
20
- * newest senders and receivers, a text summary and chain-insights.flows.v1 in
21
- * structuredContent. Composed from three anchored graph_query reads on the
22
- * topology graph (the node, its outgoing FLOWS_TO and its incoming FLOWS_TO,
23
- * newest first).
19
+ * - node, {network, address, in_offset?, out_offset?}: one address with up to
20
+ * 250 newest senders and 250 newest receivers, a text summary and
21
+ * chain-insights.flows.v1 in structuredContent. Composed from at most five
22
+ * anchored graph_query reads on the topology graph: the node, then its
23
+ * outgoing and its incoming FLOWS_TO, newest first, two reads a side because
24
+ * a reply holds at most 200 rows.
24
25
  * - link, {network, from, to, day}: the transfers between two known addresses
25
26
  * on one UTC day, newest first, at most 50, a short text summary and
26
27
  * chain-insights.transfers.v1 in structuredContent. One anchored USE facts
@@ -80,24 +81,35 @@ const NODE_FIELDS = "b.address AS address, b.labels AS labels, b.is_exchange AS
80
81
  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";
81
82
  const PAGE_ORDER = " ORDER BY f.last_seen_timestamp DESC, b.address";
82
83
  /**
83
- * The three anchored reads of one page. Each side asks for one row more than
84
- * it shows: the extra row says another page exists.
84
+ * The anchored reads of one page: the node, then the receivers, then the
85
+ * senders. Each side asks for one row more than it shows: the extra row says
86
+ * another page exists. A reply holds at most FLOWS_READ_ROWS rows, so a side
87
+ * is read in chunks on the same order, each starting where the last one ends.
85
88
  */
86
89
  function flowsReadsFor(address, inOffset, outOffset) {
87
- const page = (offset) => `${PAGE_ORDER}${offset > 0 ? ` SKIP ${offset}` : ""} LIMIT 13`;
90
+ const side = (id, offset, match) => {
91
+ const reads = [];
92
+ for (let done = 0; done < 251; done += 200) {
93
+ const skip = offset + done;
94
+ const limit = Math.min(200, 251 - done);
95
+ reads.push({
96
+ id,
97
+ query: `USE topology MATCH ${match} RETURN ${PARTY_FIELDS}${PAGE_ORDER}${skip > 0 ? ` SKIP ${skip}` : ""} LIMIT ${limit}`,
98
+ skip,
99
+ limit
100
+ });
101
+ }
102
+ return reads;
103
+ };
88
104
  return [
89
105
  {
90
106
  id: "node",
91
- query: `USE topology MATCH (b:Address {address: '${address}'}) RETURN ${NODE_FIELDS} LIMIT 1`
92
- },
93
- {
94
- id: "out",
95
- query: `USE topology MATCH (a:Address {address: '${address}'})-[f:FLOWS_TO]->(b:Address) RETURN ${PARTY_FIELDS}${page(outOffset)}`
107
+ query: `USE topology MATCH (b:Address {address: '${address}'}) RETURN ${NODE_FIELDS} LIMIT 1`,
108
+ skip: 0,
109
+ limit: 1
96
110
  },
97
- {
98
- id: "in",
99
- query: `USE topology MATCH (b:Address)-[f:FLOWS_TO]->(a:Address {address: '${address}'}) RETURN ${PARTY_FIELDS}${page(inOffset)}`
100
- }
111
+ ...side("out", outOffset, `(a:Address {address: '${address}'})-[f:FLOWS_TO]->(b:Address)`),
112
+ ...side("in", inOffset, `(b:Address)-[f:FLOWS_TO]->(a:Address {address: '${address}'})`)
101
113
  ];
102
114
  }
103
115
  /** The role word of an address, first match wins. */
@@ -129,17 +141,22 @@ function flowsNodeOf(row) {
129
141
  }
130
142
  function assembleFlowView(network, page, senders, receivers, truncated) {
131
143
  const center = flowsNodeOf(page.center);
144
+ const cursor = {
145
+ in_offset: page.inOffset + senders.length,
146
+ out_offset: page.outOffset + receivers.length
147
+ };
132
148
  const view = {
133
149
  schema: FLOWS_SCHEMA,
134
150
  network,
135
151
  center: center.address,
136
152
  nodes: [center],
137
153
  edges: [],
138
- cursor: {
139
- in_offset: page.inOffset + senders.length,
140
- out_offset: page.outOffset + receivers.length
141
- },
142
- truncated
154
+ cursor,
155
+ truncated,
156
+ remaining: {
157
+ senders: Math.max(0, center.degree_in - cursor.in_offset),
158
+ receivers: Math.max(0, center.degree_out - cursor.out_offset)
159
+ }
143
160
  };
144
161
  const seen = /* @__PURE__ */ new Set([center.address]);
145
162
  const add = (row, incoming) => {
@@ -162,7 +179,18 @@ function assembleFlowView(network, page, senders, receivers, truncated) {
162
179
  return view;
163
180
  }
164
181
  function fitsFlowBudget(view) {
165
- return view.nodes.length <= 60 && JSON.stringify(view).length < 4e4;
182
+ return view.nodes.length <= 500 && JSON.stringify(view).length < 14e4;
183
+ }
184
+ /**
185
+ * How many senders and receivers stay after `drops` of the oldest links go,
186
+ * one at a time from the longer side, the senders first on a tie.
187
+ */
188
+ function keptAfterDrops(senders, receivers, drops) {
189
+ let s = senders;
190
+ let r = receivers;
191
+ for (let left = Math.min(drops, s + r); left > 0; left -= 1) if (s >= r) s -= 1;
192
+ else r -= 1;
193
+ return [s, r];
166
194
  }
167
195
  /**
168
196
  * flows.v1 from one page: the centre first, then the senders and the
@@ -170,18 +198,27 @@ function fitsFlowBudget(view) {
170
198
  * data would hold more than FLOWS_MAX_NODES addresses or reach FLOWS_MAX_CHARS
171
199
  * characters, the oldest links go first, the view is marked truncated, and the
172
200
  * cursor still says where the next page starts.
201
+ *
202
+ * Each dropped link only makes the view smaller, so the fewest drops that fit
203
+ * are found by halving: about ten views are measured for a full page, not one
204
+ * per dropped link.
173
205
  */
174
206
  function buildFlowView(network, page) {
175
- let truncated = page.senders.length > 12 || page.receivers.length > 12;
176
- let senders = page.senders.slice(0, 12);
177
- let receivers = page.receivers.slice(0, 12);
178
- for (;;) {
179
- const view = assembleFlowView(network, page, senders, receivers, truncated);
180
- if (fitsFlowBudget(view) || senders.length + receivers.length === 0) return view;
181
- truncated = true;
182
- if (senders.length >= receivers.length) senders = senders.slice(0, -1);
183
- else receivers = receivers.slice(0, -1);
207
+ const overflow = page.senders.length > 250 || page.receivers.length > 250;
208
+ const senders = page.senders.slice(0, 250);
209
+ const receivers = page.receivers.slice(0, 250);
210
+ const viewAfter = (drops) => {
211
+ const [s, r] = keptAfterDrops(senders.length, receivers.length, drops);
212
+ return assembleFlowView(network, page, senders.slice(0, s), receivers.slice(0, r), overflow || drops > 0);
213
+ };
214
+ let low = 0;
215
+ let high = senders.length + receivers.length;
216
+ while (low < high) {
217
+ const mid = low + high >> 1;
218
+ if (fitsFlowBudget(viewAfter(mid))) high = mid;
219
+ else low = mid + 1;
184
220
  }
221
+ return viewAfter(low);
185
222
  }
186
223
  function formatFlowsUsd(value) {
187
224
  if (!(value > 0)) return "$0";
@@ -321,6 +358,12 @@ function refusalText(answer) {
321
358
  const rest = textCode === code ? text.replace(CODE_PREFIX, "") : text;
322
359
  return rest ? `${code}: ${rest}` : `${code}: the graph endpoint refused the read`;
323
360
  }
361
+ /** The endpoint says it cut the reply short (facts.query.truncated). */
362
+ function cutByEndpoint(answer) {
363
+ const facts = isRecord$1(answer.structuredContent) ? answer.structuredContent.facts : void 0;
364
+ const query = isRecord$1(facts) ? facts.query : void 0;
365
+ return isRecord$1(query) && query.truncated === true;
366
+ }
324
367
  function rowsOf(answer) {
325
368
  const facts = isRecord$1(answer.structuredContent) ? answer.structuredContent.facts : void 0;
326
369
  const query = isRecord$1(facts) ? facts.query : void 0;
@@ -350,15 +393,23 @@ function checkOffset(value) {
350
393
  }
351
394
  /**
352
395
  * Read and answer one node page. The reads run one after another: the node
353
- * first, so an address the graph does not hold costs one read, not three; then
354
- * the receivers and the senders. The first read that does not answer ends the
355
- * call.
396
+ * first, so an address the graph does not hold costs one read; then the
397
+ * receivers and the senders, the chunks of a side joined in order. A chunk that
398
+ * comes back short, and not cut by the endpoint, ends its side, so the next
399
+ * chunk of that side is not read or billed. The first read that does not
400
+ * answer ends the call.
356
401
  */
357
402
  async function runNode(deps, network, address, inOffset, outOffset) {
358
403
  const unavailable = deps.unavailable?.();
359
404
  if (unavailable) return errorResult$1(unavailable);
360
- const rows = {};
405
+ const rows = {
406
+ node: [],
407
+ out: [],
408
+ in: []
409
+ };
410
+ const ended = /* @__PURE__ */ new Set();
361
411
  for (const read of flowsReadsFor(address, inOffset, outOffset)) {
412
+ if (ended.has(read.id)) continue;
362
413
  let answer;
363
414
  try {
364
415
  answer = await deps.graphQuery({
@@ -371,13 +422,14 @@ async function runNode(deps, network, address, inOffset, outOffset) {
371
422
  if (answer.isError === true) return errorResult$1(refusalText(answer));
372
423
  const found = rowsOf(answer);
373
424
  if (!found) return errorResult$1("graph_query_failed: the graph endpoint answered without chain-insights.result.v1 rows");
374
- rows[read.id] = found;
425
+ rows[read.id].push(...found);
375
426
  if (read.id === "node" && found.length === 0) return errorResult$1(`${address} is not in the ${network} topology graph`);
427
+ if (found.length < read.limit && !cutByEndpoint(answer)) ended.add(read.id);
376
428
  }
377
429
  const view = buildFlowView(network, {
378
- center: rows.node?.[0] ?? {},
379
- senders: rows.in ?? [],
380
- receivers: rows.out ?? [],
430
+ center: rows.node[0] ?? {},
431
+ senders: rows.in,
432
+ receivers: rows.out,
381
433
  inOffset,
382
434
  outOffset
383
435
  });
@@ -456,7 +508,7 @@ async function handleGraphExpand(args, deps) {
456
508
  return runNode(deps, network, address, inOffset, outOffset);
457
509
  }
458
510
  const GRAPH_EXPAND_TITLE = "Expand a node or a link in the picture";
459
- 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.";
511
+ 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 (up to 250 a side, from the offsets); billed as at most five 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.";
460
512
  //#endregion
461
513
  //#region src/mcp/graph-schema-cache.ts
462
514
  /** How long a schema with a failed read stays fresh. */
@@ -15,11 +15,12 @@ import { createHash } from "node:crypto";
15
15
  *
16
16
  * Two forms, told apart by the arguments:
17
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).
18
+ * - node, {network, address, in_offset?, out_offset?}: one address with up to
19
+ * 250 newest senders and 250 newest receivers, a text summary and
20
+ * chain-insights.flows.v1 in structuredContent. Composed from at most five
21
+ * anchored graph_query reads on the topology graph: the node, then its
22
+ * outgoing and its incoming FLOWS_TO, newest first, two reads a side because
23
+ * a reply holds at most 200 rows.
23
24
  * - link, {network, from, to, day}: the transfers between two known addresses
24
25
  * on one UTC day, newest first, at most 50, a short text summary and
25
26
  * chain-insights.transfers.v1 in structuredContent. One anchored USE facts
@@ -79,24 +80,35 @@ const NODE_FIELDS = "b.address AS address, b.labels AS labels, b.is_exchange AS
79
80
  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
81
  const PAGE_ORDER = " ORDER BY f.last_seen_timestamp DESC, b.address";
81
82
  /**
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.
83
+ * The anchored reads of one page: the node, then the receivers, then the
84
+ * senders. Each side asks for one row more than it shows: the extra row says
85
+ * another page exists. A reply holds at most FLOWS_READ_ROWS rows, so a side
86
+ * is read in chunks on the same order, each starting where the last one ends.
84
87
  */
85
88
  function flowsReadsFor(address, inOffset, outOffset) {
86
- const page = (offset) => `${PAGE_ORDER}${offset > 0 ? ` SKIP ${offset}` : ""} LIMIT 13`;
89
+ const side = (id, offset, match) => {
90
+ const reads = [];
91
+ for (let done = 0; done < 251; done += 200) {
92
+ const skip = offset + done;
93
+ const limit = Math.min(200, 251 - done);
94
+ reads.push({
95
+ id,
96
+ query: `USE topology MATCH ${match} RETURN ${PARTY_FIELDS}${PAGE_ORDER}${skip > 0 ? ` SKIP ${skip}` : ""} LIMIT ${limit}`,
97
+ skip,
98
+ limit
99
+ });
100
+ }
101
+ return reads;
102
+ };
87
103
  return [
88
104
  {
89
105
  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)}`
106
+ query: `USE topology MATCH (b:Address {address: '${address}'}) RETURN ${NODE_FIELDS} LIMIT 1`,
107
+ skip: 0,
108
+ limit: 1
95
109
  },
96
- {
97
- id: "in",
98
- query: `USE topology MATCH (b:Address)-[f:FLOWS_TO]->(a:Address {address: '${address}'}) RETURN ${PARTY_FIELDS}${page(inOffset)}`
99
- }
110
+ ...side("out", outOffset, `(a:Address {address: '${address}'})-[f:FLOWS_TO]->(b:Address)`),
111
+ ...side("in", inOffset, `(b:Address)-[f:FLOWS_TO]->(a:Address {address: '${address}'})`)
100
112
  ];
101
113
  }
102
114
  /** The role word of an address, first match wins. */
@@ -128,17 +140,22 @@ function flowsNodeOf(row) {
128
140
  }
129
141
  function assembleFlowView(network, page, senders, receivers, truncated) {
130
142
  const center = flowsNodeOf(page.center);
143
+ const cursor = {
144
+ in_offset: page.inOffset + senders.length,
145
+ out_offset: page.outOffset + receivers.length
146
+ };
131
147
  const view = {
132
148
  schema: FLOWS_SCHEMA,
133
149
  network,
134
150
  center: center.address,
135
151
  nodes: [center],
136
152
  edges: [],
137
- cursor: {
138
- in_offset: page.inOffset + senders.length,
139
- out_offset: page.outOffset + receivers.length
140
- },
141
- truncated
153
+ cursor,
154
+ truncated,
155
+ remaining: {
156
+ senders: Math.max(0, center.degree_in - cursor.in_offset),
157
+ receivers: Math.max(0, center.degree_out - cursor.out_offset)
158
+ }
142
159
  };
143
160
  const seen = /* @__PURE__ */ new Set([center.address]);
144
161
  const add = (row, incoming) => {
@@ -161,7 +178,18 @@ function assembleFlowView(network, page, senders, receivers, truncated) {
161
178
  return view;
162
179
  }
163
180
  function fitsFlowBudget(view) {
164
- return view.nodes.length <= 60 && JSON.stringify(view).length < 4e4;
181
+ return view.nodes.length <= 500 && JSON.stringify(view).length < 14e4;
182
+ }
183
+ /**
184
+ * How many senders and receivers stay after `drops` of the oldest links go,
185
+ * one at a time from the longer side, the senders first on a tie.
186
+ */
187
+ function keptAfterDrops(senders, receivers, drops) {
188
+ let s = senders;
189
+ let r = receivers;
190
+ for (let left = Math.min(drops, s + r); left > 0; left -= 1) if (s >= r) s -= 1;
191
+ else r -= 1;
192
+ return [s, r];
165
193
  }
166
194
  /**
167
195
  * flows.v1 from one page: the centre first, then the senders and the
@@ -169,18 +197,27 @@ function fitsFlowBudget(view) {
169
197
  * data would hold more than FLOWS_MAX_NODES addresses or reach FLOWS_MAX_CHARS
170
198
  * characters, the oldest links go first, the view is marked truncated, and the
171
199
  * cursor still says where the next page starts.
200
+ *
201
+ * Each dropped link only makes the view smaller, so the fewest drops that fit
202
+ * are found by halving: about ten views are measured for a full page, not one
203
+ * per dropped link.
172
204
  */
173
205
  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);
206
+ const overflow = page.senders.length > 250 || page.receivers.length > 250;
207
+ const senders = page.senders.slice(0, 250);
208
+ const receivers = page.receivers.slice(0, 250);
209
+ const viewAfter = (drops) => {
210
+ const [s, r] = keptAfterDrops(senders.length, receivers.length, drops);
211
+ return assembleFlowView(network, page, senders.slice(0, s), receivers.slice(0, r), overflow || drops > 0);
212
+ };
213
+ let low = 0;
214
+ let high = senders.length + receivers.length;
215
+ while (low < high) {
216
+ const mid = low + high >> 1;
217
+ if (fitsFlowBudget(viewAfter(mid))) high = mid;
218
+ else low = mid + 1;
183
219
  }
220
+ return viewAfter(low);
184
221
  }
185
222
  function formatFlowsUsd(value) {
186
223
  if (!(value > 0)) return "$0";
@@ -320,6 +357,12 @@ function refusalText(answer) {
320
357
  const rest = textCode === code ? text.replace(CODE_PREFIX, "") : text;
321
358
  return rest ? `${code}: ${rest}` : `${code}: the graph endpoint refused the read`;
322
359
  }
360
+ /** The endpoint says it cut the reply short (facts.query.truncated). */
361
+ function cutByEndpoint(answer) {
362
+ const facts = isRecord$1(answer.structuredContent) ? answer.structuredContent.facts : void 0;
363
+ const query = isRecord$1(facts) ? facts.query : void 0;
364
+ return isRecord$1(query) && query.truncated === true;
365
+ }
323
366
  function rowsOf(answer) {
324
367
  const facts = isRecord$1(answer.structuredContent) ? answer.structuredContent.facts : void 0;
325
368
  const query = isRecord$1(facts) ? facts.query : void 0;
@@ -349,15 +392,23 @@ function checkOffset(value) {
349
392
  }
350
393
  /**
351
394
  * 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.
395
+ * first, so an address the graph does not hold costs one read; then the
396
+ * receivers and the senders, the chunks of a side joined in order. A chunk that
397
+ * comes back short, and not cut by the endpoint, ends its side, so the next
398
+ * chunk of that side is not read or billed. The first read that does not
399
+ * answer ends the call.
355
400
  */
356
401
  async function runNode(deps, network, address, inOffset, outOffset) {
357
402
  const unavailable = deps.unavailable?.();
358
403
  if (unavailable) return errorResult$1(unavailable);
359
- const rows = {};
404
+ const rows = {
405
+ node: [],
406
+ out: [],
407
+ in: []
408
+ };
409
+ const ended = /* @__PURE__ */ new Set();
360
410
  for (const read of flowsReadsFor(address, inOffset, outOffset)) {
411
+ if (ended.has(read.id)) continue;
361
412
  let answer;
362
413
  try {
363
414
  answer = await deps.graphQuery({
@@ -370,13 +421,14 @@ async function runNode(deps, network, address, inOffset, outOffset) {
370
421
  if (answer.isError === true) return errorResult$1(refusalText(answer));
371
422
  const found = rowsOf(answer);
372
423
  if (!found) return errorResult$1("graph_query_failed: the graph endpoint answered without chain-insights.result.v1 rows");
373
- rows[read.id] = found;
424
+ rows[read.id].push(...found);
374
425
  if (read.id === "node" && found.length === 0) return errorResult$1(`${address} is not in the ${network} topology graph`);
426
+ if (found.length < read.limit && !cutByEndpoint(answer)) ended.add(read.id);
375
427
  }
376
428
  const view = buildFlowView(network, {
377
- center: rows.node?.[0] ?? {},
378
- senders: rows.in ?? [],
379
- receivers: rows.out ?? [],
429
+ center: rows.node[0] ?? {},
430
+ senders: rows.in,
431
+ receivers: rows.out,
380
432
  inOffset,
381
433
  outOffset
382
434
  });
@@ -455,7 +507,7 @@ async function handleGraphExpand(args, deps) {
455
507
  return runNode(deps, network, address, inOffset, outOffset);
456
508
  }
457
509
  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.";
510
+ 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 (up to 250 a side, from the offsets); billed as at most five 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
511
  //#endregion
460
512
  //#region src/mcp/graph-schema-cache.ts
461
513
  /** How long a schema with a failed read stays fresh. */
@@ -911,4 +963,4 @@ async function handleMetaSchema(args, deps) {
911
963
  //#endregion
912
964
  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
965
 
914
- //# sourceMappingURL=graph-schema-DhVJiVba.mjs.map
966
+ //# sourceMappingURL=graph-schema-D7wSNOT5.mjs.map