@antseed/cli 0.1.94 → 0.1.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/cli/commands/buyer/start.d.ts.map +1 -1
  2. package/dist/cli/commands/buyer/start.js +22 -2
  3. package/dist/cli/commands/buyer/start.js.map +1 -1
  4. package/dist/cli/commands/network/browse.d.ts +1 -2
  5. package/dist/cli/commands/network/browse.d.ts.map +1 -1
  6. package/dist/cli/commands/network/browse.js +528 -94
  7. package/dist/cli/commands/network/browse.js.map +1 -1
  8. package/dist/cli/commands/network/chain-config-helper.d.ts +35 -0
  9. package/dist/cli/commands/network/chain-config-helper.d.ts.map +1 -0
  10. package/dist/cli/commands/network/chain-config-helper.js +45 -0
  11. package/dist/cli/commands/network/chain-config-helper.js.map +1 -0
  12. package/dist/cli/commands/network/index.d.ts.map +1 -1
  13. package/dist/cli/commands/network/index.js +2 -0
  14. package/dist/cli/commands/network/index.js.map +1 -1
  15. package/dist/cli/commands/network/peer.d.ts +6 -0
  16. package/dist/cli/commands/network/peer.d.ts.map +1 -0
  17. package/dist/cli/commands/network/peer.js +297 -0
  18. package/dist/cli/commands/network/peer.js.map +1 -0
  19. package/dist/cli/commands/network/pricing-format.d.ts +25 -0
  20. package/dist/cli/commands/network/pricing-format.d.ts.map +1 -0
  21. package/dist/cli/commands/network/pricing-format.js +38 -0
  22. package/dist/cli/commands/network/pricing-format.js.map +1 -0
  23. package/dist/cli/commands/network/tag-filter.d.ts +30 -0
  24. package/dist/cli/commands/network/tag-filter.d.ts.map +1 -0
  25. package/dist/cli/commands/network/tag-filter.js +75 -0
  26. package/dist/cli/commands/network/tag-filter.js.map +1 -0
  27. package/dist/cli/commands/seller/setup.d.ts.map +1 -1
  28. package/dist/cli/commands/seller/setup.js +2 -1
  29. package/dist/cli/commands/seller/setup.js.map +1 -1
  30. package/dist/cli/commands/seller/start.d.ts.map +1 -1
  31. package/dist/cli/commands/seller/start.js +7 -1
  32. package/dist/cli/commands/seller/start.js.map +1 -1
  33. package/dist/config/types.d.ts +10 -0
  34. package/dist/config/types.d.ts.map +1 -1
  35. package/dist/proxy/buyer-proxy.d.ts +5 -4
  36. package/dist/proxy/buyer-proxy.d.ts.map +1 -1
  37. package/dist/proxy/buyer-proxy.js +171 -210
  38. package/dist/proxy/buyer-proxy.js.map +1 -1
  39. package/dist/proxy/buyer-proxy.test.js +45 -2
  40. package/dist/proxy/buyer-proxy.test.js.map +1 -1
  41. package/dist/proxy/routing.d.ts +0 -1
  42. package/dist/proxy/routing.d.ts.map +1 -1
  43. package/dist/proxy/routing.js +0 -7
  44. package/dist/proxy/routing.js.map +1 -1
  45. package/package.json +6 -6
@@ -5,9 +5,12 @@ import { readFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { getGlobalOptions } from '../types.js';
7
7
  import { loadConfig } from '../../../config/loader.js';
8
- import { AntseedNode } from '@antseed/node';
8
+ import { AntseedNode, } from '@antseed/node';
9
9
  import { parseBootstrapList, toBootstrapConfig } from '@antseed/node/discovery';
10
10
  import { parsePersistedPeers } from '../../../proxy/buyer-proxy.js';
11
+ import { buildPaymentsConfig } from './chain-config-helper.js';
12
+ import { collectServiceTags, parseTagFilter, peerMatchesTagFilter, serviceMatchesTagFilter, } from './tag-filter.js';
13
+ import { formatUsdPerMillion } from './pricing-format.js';
11
14
  function isProcessAlive(pid) {
12
15
  try {
13
16
  process.kill(pid, 0);
@@ -18,12 +21,10 @@ function isProcessAlive(pid) {
18
21
  }
19
22
  }
20
23
  /**
21
- * Try to load discovered peers from a live buyer daemon's state file.
22
- * Returns null unless the file exists, the daemon reports `state === 'connected'`,
23
- * its PID is still alive, and the peer list is non-empty. This avoids surfacing
24
- * stale peer data from a daemon that exited without clearing the file.
24
+ * Load a snapshot from a live buyer daemon. Returns `null` unless the daemon is
25
+ * connected, its PID is alive, and the persisted peer list is non-empty.
25
26
  */
26
- async function loadPeersFromBuyerState(dataDir) {
27
+ async function loadSnapshotFromBuyerState(dataDir) {
27
28
  try {
28
29
  const raw = await readFile(join(dataDir, 'buyer.state.json'), 'utf-8');
29
30
  const parsed = JSON.parse(raw);
@@ -32,126 +33,559 @@ async function loadPeersFromBuyerState(dataDir) {
32
33
  if (typeof parsed.pid !== 'number' || !isProcessAlive(parsed.pid))
33
34
  return null;
34
35
  const peers = parsePersistedPeers(parsed);
35
- return peers.length > 0 ? peers : null;
36
+ if (peers.length === 0)
37
+ return null;
38
+ const refreshedAt = typeof parsed.onChainStatsRefreshedAt === 'number'
39
+ && Number.isFinite(parsed.onChainStatsRefreshedAt)
40
+ ? parsed.onChainStatsRefreshedAt
41
+ : null;
42
+ return {
43
+ peers,
44
+ onChainStatsRefreshedAt: refreshedAt,
45
+ sourceLabel: `buyer daemon (pid ${parsed.pid})`,
46
+ };
36
47
  }
37
48
  catch {
38
49
  return null;
39
50
  }
40
51
  }
41
- function getReputationColor(reputation) {
42
- if (reputation >= 80) {
43
- return chalk.green;
52
+ /**
53
+ * A pricing entry counts as "free" when both input and output prices are
54
+ * finite and zero. We intentionally don't treat undefined/negative values as
55
+ * free — those are "unknown" and handled separately by the formatter.
56
+ */
57
+ function isFreePricing(pricing) {
58
+ return (Number.isFinite(pricing.inputUsdPerMillion)
59
+ && Number.isFinite(pricing.outputUsdPerMillion)
60
+ && pricing.inputUsdPerMillion === 0
61
+ && pricing.outputUsdPerMillion === 0);
62
+ }
63
+ /**
64
+ * Derive the set of service names this peer offers, flattened across all its
65
+ * providers, in stable (sorted) order.
66
+ */
67
+ function collectServiceNames(peer) {
68
+ const names = new Set();
69
+ const pricing = peer.providerPricing;
70
+ if (pricing) {
71
+ for (const entry of Object.values(pricing)) {
72
+ const services = entry.services;
73
+ if (services) {
74
+ for (const name of Object.keys(services)) {
75
+ const trimmed = name.trim();
76
+ if (trimmed.length > 0)
77
+ names.add(trimmed);
78
+ }
79
+ }
80
+ }
44
81
  }
45
- if (reputation >= 50) {
46
- return chalk.yellow;
82
+ return Array.from(names).sort((a, b) => a.localeCompare(b));
83
+ }
84
+ /**
85
+ * Collect the names of free (both input and output = $0/1M) services a peer
86
+ * offers. Used to populate the optional "Free" column.
87
+ */
88
+ function collectFreeServiceNames(peer) {
89
+ const names = new Set();
90
+ const pricing = peer.providerPricing;
91
+ if (pricing) {
92
+ for (const entry of Object.values(pricing)) {
93
+ if (entry.services) {
94
+ for (const [serviceName, servicePricing] of Object.entries(entry.services)) {
95
+ if (isFreePricing(servicePricing))
96
+ names.add(serviceName);
97
+ }
98
+ }
99
+ // If the provider's defaults are free and no per-service override lifts
100
+ // them, consider "(default)" a free offering worth surfacing.
101
+ if (entry.defaults && isFreePricing(entry.defaults) && (!entry.services || Object.keys(entry.services).length === 0)) {
102
+ names.add('(default)');
103
+ }
104
+ }
47
105
  }
48
- return chalk.red;
106
+ return Array.from(names).sort((a, b) => a.localeCompare(b));
107
+ }
108
+ /**
109
+ * Check whether the peer matches a `--service` filter. Matches on provider
110
+ * name OR any announced service name (case-insensitive).
111
+ */
112
+ function peerMatchesServiceFilter(peer, filter) {
113
+ const needle = filter.trim().toLowerCase();
114
+ if (needle.length === 0)
115
+ return true;
116
+ if (peer.providers.some((p) => p.toLowerCase() === needle))
117
+ return true;
118
+ return collectServiceNames(peer).some((name) => name.toLowerCase() === needle);
49
119
  }
50
- function renderPeersTable(peers) {
51
- const table = new Table({
52
- head: [
53
- chalk.bold('Name'),
54
- chalk.bold('Peer ID'),
55
- chalk.bold('Providers'),
56
- chalk.bold('Input $/1M'),
57
- chalk.bold('Output $/1M'),
58
- chalk.bold('Reputation'),
59
- chalk.bold('Load'),
60
- ],
61
- colWidths: [18, 16, 18, 14, 14, 12, 10],
120
+ /**
121
+ * Determine the cheapest *paid* input/output price pair across all services.
122
+ * Free services (input=output=0) are deliberately excluded from these
123
+ * columns — they are surfaced in a dedicated "Free" column when present —
124
+ * so the In/Out $/1M columns always reflect the cheapest paid option a peer
125
+ * offers. If every candidate is free or unknown, the result is `null`.
126
+ *
127
+ * CAVEAT: the returned input and output are picked independently across
128
+ * services. A peer with serviceA=$1/$2 and serviceB=$2/$1 is rendered as
129
+ * $1/$1 — a lower-bound approximation, not a real single-service offer.
130
+ * Use `antseed network browse --services` or `antseed network peer <id>`
131
+ * for per-service pricing when exact comparison matters.
132
+ */
133
+ function resolveBestPaidPricing(peer) {
134
+ let bestInput = null;
135
+ let bestOutput = null;
136
+ const pricing = peer.providerPricing;
137
+ if (pricing) {
138
+ for (const entry of Object.values(pricing)) {
139
+ const candidates = [];
140
+ if (entry.defaults)
141
+ candidates.push(entry.defaults);
142
+ if (entry.services)
143
+ candidates.push(...Object.values(entry.services));
144
+ for (const c of candidates) {
145
+ if (isFreePricing(c))
146
+ continue;
147
+ if (Number.isFinite(c.inputUsdPerMillion) && c.inputUsdPerMillion > 0) {
148
+ if (bestInput === null || c.inputUsdPerMillion < bestInput)
149
+ bestInput = c.inputUsdPerMillion;
150
+ }
151
+ if (Number.isFinite(c.outputUsdPerMillion) && c.outputUsdPerMillion > 0) {
152
+ if (bestOutput === null || c.outputUsdPerMillion < bestOutput)
153
+ bestOutput = c.outputUsdPerMillion;
154
+ }
155
+ }
156
+ }
157
+ }
158
+ // Top-level defaults are only used when we have nothing else — and only if
159
+ // they are positive (non-free).
160
+ if (bestInput === null && Number.isFinite(peer.defaultInputUsdPerMillion) && (peer.defaultInputUsdPerMillion ?? 0) > 0) {
161
+ bestInput = peer.defaultInputUsdPerMillion ?? null;
162
+ }
163
+ if (bestOutput === null && Number.isFinite(peer.defaultOutputUsdPerMillion) && (peer.defaultOutputUsdPerMillion ?? 0) > 0) {
164
+ bestOutput = peer.defaultOutputUsdPerMillion ?? null;
165
+ }
166
+ return { input: bestInput, output: bestOutput };
167
+ }
168
+ function formatUsdcVolume(micros) {
169
+ if (typeof micros !== 'number' || !Number.isFinite(micros) || micros < 0) {
170
+ return chalk.dim('—');
171
+ }
172
+ const usd = micros / 1_000_000;
173
+ if (usd >= 1000)
174
+ return chalk.green(`$${usd.toFixed(0)}`);
175
+ if (usd >= 1)
176
+ return chalk.green(`$${usd.toFixed(2)}`);
177
+ if (usd > 0)
178
+ return `$${usd.toFixed(4)}`;
179
+ return chalk.dim('$0');
180
+ }
181
+ function formatAge(sec) {
182
+ if (typeof sec !== 'number' || !Number.isFinite(sec) || sec <= 0) {
183
+ return chalk.dim('never');
184
+ }
185
+ const nowMs = Date.now();
186
+ const ageMs = nowMs - sec * 1000;
187
+ if (ageMs < 0)
188
+ return chalk.dim('just now');
189
+ const mins = Math.floor(ageMs / 60_000);
190
+ if (mins < 1)
191
+ return 'just now';
192
+ if (mins < 60)
193
+ return `${mins}m ago`;
194
+ const hours = Math.floor(mins / 60);
195
+ if (hours < 24)
196
+ return `${hours}h ago`;
197
+ const days = Math.floor(hours / 24);
198
+ if (days < 30)
199
+ return `${days}d ago`;
200
+ const months = Math.floor(days / 30);
201
+ return `${months}mo ago`;
202
+ }
203
+ function formatHumanAgeMs(ms) {
204
+ if (!Number.isFinite(ms) || ms < 0)
205
+ return 'just now';
206
+ if (ms < 60_000)
207
+ return `${Math.floor(ms / 1000)}s ago`;
208
+ const mins = Math.floor(ms / 60_000);
209
+ if (mins < 60)
210
+ return `${mins}m ago`;
211
+ const hours = Math.floor(mins / 60);
212
+ if (hours < 24)
213
+ return `${hours}h ago`;
214
+ const days = Math.floor(hours / 24);
215
+ return `${days}d ago`;
216
+ }
217
+ /**
218
+ * Mark a peer as "vouched" when it has positive on-chain channel count and no
219
+ * ghosts. Rendered as a ✓ next to its peer id in the table.
220
+ */
221
+ function isPeerVouched(peer) {
222
+ const channels = peer.onChainChannelCount ?? 0;
223
+ const ghosts = peer.onChainGhostCount ?? 0;
224
+ return channels > 0 && ghosts === 0;
225
+ }
226
+ function sortPeers(peers, sortKey) {
227
+ const copy = [...peers];
228
+ copy.sort((a, b) => {
229
+ switch (sortKey) {
230
+ case 'volume': {
231
+ const va = a.onChainTotalVolumeUsdcMicros ?? -1;
232
+ const vb = b.onChainTotalVolumeUsdcMicros ?? -1;
233
+ if (va !== vb)
234
+ return vb - va;
235
+ return (b.onChainChannelCount ?? 0) - (a.onChainChannelCount ?? 0);
236
+ }
237
+ case 'sessions': {
238
+ const ca = a.onChainChannelCount ?? -1;
239
+ const cb = b.onChainChannelCount ?? -1;
240
+ if (ca !== cb)
241
+ return cb - ca;
242
+ return (b.onChainTotalVolumeUsdcMicros ?? 0) - (a.onChainTotalVolumeUsdcMicros ?? 0);
243
+ }
244
+ case 'price': {
245
+ const pa = resolveBestPaidPricing(a).input ?? Number.POSITIVE_INFINITY;
246
+ const pb = resolveBestPaidPricing(b).input ?? Number.POSITIVE_INFINITY;
247
+ if (pa !== pb)
248
+ return pa - pb;
249
+ return (b.onChainTotalVolumeUsdcMicros ?? 0) - (a.onChainTotalVolumeUsdcMicros ?? 0);
250
+ }
251
+ case 'recent': {
252
+ const ra = a.onChainLastSettledAtSec ?? 0;
253
+ const rb = b.onChainLastSettledAtSec ?? 0;
254
+ if (ra !== rb)
255
+ return rb - ra;
256
+ return (b.onChainTotalVolumeUsdcMicros ?? 0) - (a.onChainTotalVolumeUsdcMicros ?? 0);
257
+ }
258
+ }
62
259
  });
260
+ return copy;
261
+ }
262
+ function parseSortKey(raw) {
263
+ const normalized = (raw ?? 'volume').trim().toLowerCase();
264
+ if (normalized === 'sessions' || normalized === 'price' || normalized === 'recent' || normalized === 'volume') {
265
+ return normalized;
266
+ }
267
+ return 'volume';
268
+ }
269
+ function parseTopLimit(raw) {
270
+ const parsed = raw === undefined ? 20 : parseInt(raw, 10);
271
+ if (!Number.isFinite(parsed) || parsed <= 0)
272
+ return 20;
273
+ return Math.min(parsed, 500);
274
+ }
275
+ /**
276
+ * Render the default compact table: one row per peer with aggregate metrics.
277
+ * Full peer ids are printed so the output is directly copy-pasteable into
278
+ * `antseed buyer connection set --peer <id>` without any truncation.
279
+ * The "Free" column is only emitted when at least one displayed peer has
280
+ * free services; otherwise it's omitted so the common-case table stays
281
+ * compact.
282
+ */
283
+ function renderCompactTable(peers, hasChainData) {
284
+ const freeNamesByPeer = new Map(peers.map((peer) => [peer.peerId, collectFreeServiceNames(peer)]));
285
+ const anyFreeService = Array.from(freeNamesByPeer.values()).some((names) => names.length > 0);
286
+ const head = [
287
+ chalk.bold('Peer'),
288
+ chalk.bold('Name'),
289
+ chalk.bold('Providers'),
290
+ chalk.bold('Services'),
291
+ chalk.bold('Min In $/1M'),
292
+ chalk.bold('Min Out $/1M'),
293
+ ];
294
+ if (anyFreeService)
295
+ head.push(chalk.bold('Free'));
296
+ head.push(chalk.bold('Sessions'), chalk.bold('Ghosts'), chalk.bold('Volume'), chalk.bold('Last settled'), chalk.bold('Load'));
297
+ const table = new Table({ head, wordWrap: true });
63
298
  for (const peer of peers) {
64
- const reputation = peer.reputationScore ?? 0;
65
- const repLabel = `${reputation}%`;
66
- const repColor = getReputationColor(reputation);
299
+ const services = collectServiceNames(peer);
300
+ const servicesCell = services.length === 0
301
+ ? chalk.dim('—')
302
+ : services.length <= 2
303
+ ? services.join(', ')
304
+ : `${services.slice(0, 2).join(', ')} ${chalk.dim(`+${services.length - 2}`)}`;
305
+ const pricing = resolveBestPaidPricing(peer);
306
+ const ghostCount = peer.onChainGhostCount;
307
+ const ghostCell = typeof ghostCount === 'number'
308
+ ? (ghostCount === 0 ? chalk.dim('0') : chalk.red(String(ghostCount)))
309
+ : chalk.dim('—');
310
+ const sessionsCell = typeof peer.onChainChannelCount === 'number'
311
+ ? (peer.onChainChannelCount > 0 ? chalk.cyan(String(peer.onChainChannelCount)) : chalk.dim('0'))
312
+ : chalk.dim('—');
67
313
  const load = peer.currentLoad !== undefined && peer.maxConcurrency !== undefined
68
314
  ? `${peer.currentLoad}/${peer.maxConcurrency}`
69
- : chalk.dim('n/a');
70
- table.push([
71
- peer.displayName ?? chalk.dim('n/a'),
72
- chalk.dim(peer.peerId.slice(0, 12) + '...'),
73
- peer.providers.join(', '),
74
- peer.defaultInputUsdPerMillion !== undefined
75
- ? `$${peer.defaultInputUsdPerMillion.toFixed(2)}`
76
- : chalk.dim('n/a'),
77
- peer.defaultOutputUsdPerMillion !== undefined
78
- ? `$${peer.defaultOutputUsdPerMillion.toFixed(2)}`
79
- : chalk.dim('n/a'),
80
- repColor(repLabel),
81
- load,
82
- ]);
315
+ : chalk.dim('');
316
+ const badge = isPeerVouched(peer) ? chalk.green(' ✓') : '';
317
+ const peerCell = peer.peerId + badge;
318
+ const freeNames = freeNamesByPeer.get(peer.peerId) ?? [];
319
+ const freeCell = freeNames.length === 0
320
+ ? chalk.dim('—')
321
+ : freeNames.length <= 2
322
+ ? chalk.green(freeNames.join(', '))
323
+ : chalk.green(`${freeNames.slice(0, 2).join(', ')}`) + chalk.dim(` +${freeNames.length - 2}`);
324
+ const row = [
325
+ peerCell,
326
+ peer.displayName ?? chalk.dim('—'),
327
+ peer.providers.join(', ') || chalk.dim('—'),
328
+ servicesCell,
329
+ formatUsdPerMillion(pricing.input),
330
+ formatUsdPerMillion(pricing.output),
331
+ ];
332
+ if (anyFreeService)
333
+ row.push(freeCell);
334
+ row.push(sessionsCell, ghostCell, formatUsdcVolume(peer.onChainTotalVolumeUsdcMicros ?? null), formatAge(peer.onChainLastSettledAtSec ?? null), load);
335
+ table.push(row);
83
336
  }
84
337
  console.log('');
85
338
  console.log(table.toString());
339
+ if (!hasChainData) {
340
+ console.log(chalk.dim(' Sessions / Ghosts / Volume / Last settled are dim — configure chain RPC to enable on-chain verification.'));
341
+ }
342
+ if (anyFreeService) {
343
+ console.log(chalk.dim(' Free column lists services a peer offers at $0 in/out. "Min In/Out $/1M" always reflects the cheapest PAID option.'));
344
+ }
86
345
  console.log('');
87
346
  }
88
347
  /**
89
- * Register the `antseed network browse` command on the Commander program.
90
- * Discovers peers on the network and displays available services, prices, and reputation.
348
+ * Render the expanded "one row per (peer, provider, service)" table. Adds a
349
+ * `Tags` column when at least one displayed (peer, service) pair has tags —
350
+ * handy both for exploring categories and for verifying a `--tag` filter
351
+ * actually matched the services you expected.
352
+ *
353
+ * When `requestedTags` is non-empty:
354
+ * - Services that don't match any requested tag are skipped.
355
+ * - Tagless fallback rows (peers with no `providerPricing` entries; or a
356
+ * provider with no services, rendered as a synthetic `(default)` row)
357
+ * are skipped entirely — they can't match an opt-in tag constraint,
358
+ * and surfacing them would contradict the `peer` detail command and
359
+ * mislead callers auditing a filtered view.
360
+ * - Matching tags inside the `Tags` cell are highlighted green.
361
+ */
362
+ function renderExpandedTable(peers, requestedTags) {
363
+ const rows = [];
364
+ const hasTagFilter = requestedTags.size > 0;
365
+ for (const peer of peers) {
366
+ const pricing = peer.providerPricing;
367
+ if (!pricing || Object.keys(pricing).length === 0) {
368
+ // Peer with no pricing info — render one row per provider as a fallback
369
+ // shape so the table is never empty for a known peer. These rows have
370
+ // no tags and can never match `--tag`, so suppress them under a filter.
371
+ if (hasTagFilter)
372
+ continue;
373
+ for (const provider of peer.providers) {
374
+ rows.push({
375
+ peerId: peer.peerId,
376
+ provider,
377
+ service: '—',
378
+ input: formatUsdPerMillion(peer.defaultInputUsdPerMillion ?? null),
379
+ output: formatUsdPerMillion(peer.defaultOutputUsdPerMillion ?? null),
380
+ sessions: typeof peer.onChainChannelCount === 'number' ? String(peer.onChainChannelCount) : '—',
381
+ volume: formatUsdcVolume(peer.onChainTotalVolumeUsdcMicros ?? null),
382
+ tags: [],
383
+ });
384
+ }
385
+ continue;
386
+ }
387
+ for (const [providerName, providerEntry] of Object.entries(pricing)) {
388
+ const services = providerEntry.services ?? {};
389
+ const serviceEntries = Object.entries(services);
390
+ if (serviceEntries.length === 0) {
391
+ // Synthetic `(default)` row — defaults aren't tagged so they can't
392
+ // match a tag filter; keep the row only when no filter is active.
393
+ if (hasTagFilter)
394
+ continue;
395
+ rows.push({
396
+ peerId: peer.peerId,
397
+ provider: providerName,
398
+ service: '(default)',
399
+ input: formatUsdPerMillion(providerEntry.defaults?.inputUsdPerMillion ?? null),
400
+ output: formatUsdPerMillion(providerEntry.defaults?.outputUsdPerMillion ?? null),
401
+ sessions: typeof peer.onChainChannelCount === 'number' ? String(peer.onChainChannelCount) : '—',
402
+ volume: formatUsdcVolume(peer.onChainTotalVolumeUsdcMicros ?? null),
403
+ tags: [],
404
+ });
405
+ continue;
406
+ }
407
+ for (const [serviceName, servicePricing] of serviceEntries.sort(([a], [b]) => a.localeCompare(b))) {
408
+ if (hasTagFilter && !serviceMatchesTagFilter(peer, providerName, serviceName, requestedTags)) {
409
+ continue;
410
+ }
411
+ rows.push({
412
+ peerId: peer.peerId,
413
+ provider: providerName,
414
+ service: serviceName,
415
+ input: formatUsdPerMillion(servicePricing.inputUsdPerMillion),
416
+ output: formatUsdPerMillion(servicePricing.outputUsdPerMillion),
417
+ sessions: typeof peer.onChainChannelCount === 'number' ? String(peer.onChainChannelCount) : '—',
418
+ volume: formatUsdcVolume(peer.onChainTotalVolumeUsdcMicros ?? null),
419
+ tags: collectServiceTags(peer, providerName, serviceName),
420
+ });
421
+ }
422
+ }
423
+ }
424
+ const anyTags = rows.some((r) => r.tags.length > 0);
425
+ const head = [
426
+ chalk.bold('Peer'),
427
+ chalk.bold('Provider'),
428
+ chalk.bold('Service'),
429
+ chalk.bold('In $/1M'),
430
+ chalk.bold('Out $/1M'),
431
+ chalk.bold('Sessions'),
432
+ chalk.bold('Volume'),
433
+ ];
434
+ if (anyTags)
435
+ head.push(chalk.bold('Tags'));
436
+ const table = new Table({ head, wordWrap: true });
437
+ for (const r of rows) {
438
+ const row = [
439
+ r.peerId,
440
+ r.provider,
441
+ r.service === '—' || r.service === '(default)' ? chalk.dim(r.service) : r.service,
442
+ r.input,
443
+ r.output,
444
+ r.sessions === '—' ? chalk.dim('—') : r.sessions,
445
+ r.volume,
446
+ ];
447
+ if (anyTags) {
448
+ row.push(r.tags.length === 0
449
+ ? chalk.dim('—')
450
+ : r.tags
451
+ .map((t) => (requestedTags.has(t) ? chalk.green(t) : t))
452
+ .join(', '));
453
+ }
454
+ table.push(row);
455
+ }
456
+ console.log('');
457
+ console.log(table.toString());
458
+ if (anyTags && requestedTags.size > 0) {
459
+ console.log(chalk.dim(` Matching tags highlighted green: ${Array.from(requestedTags).sort().join(', ')}`));
460
+ }
461
+ console.log('');
462
+ }
463
+ /**
464
+ * Register the `antseed network browse` command.
91
465
  */
92
466
  export function registerNetworkBrowseCommand(networkCmd) {
93
467
  networkCmd
94
468
  .command('browse')
95
- .description('Browse available services, prices, and reputation on the P2P network')
96
- .option('-s, --service <service>', 'filter by service name')
469
+ .description('Browse peers on the network — prices, services, and on-chain settlements')
470
+ .option('-s, --service <service>', 'filter by service or provider name')
471
+ .option('-t, --tag <tags>', 'filter by service category tag(s); comma-separated for OR match '
472
+ + '(e.g. --tag tee,privacy). Well-known tags: privacy, legal, uncensored, coding, finance, tee')
473
+ .option('--services', 'expand to one row per (peer, provider, service) with per-service pricing', false)
474
+ .option('--sort <key>', 'sort by volume | sessions | price | recent (default: volume)')
475
+ .option('--top <n>', 'show only the top N peers (default: 20)')
97
476
  .option('--json', 'output as JSON', false)
98
- .action(async (options) => {
477
+ .action(async (rawOptions) => {
99
478
  const globalOpts = getGlobalOptions(networkCmd);
100
479
  const config = await loadConfig(globalOpts.config);
101
- const serviceFilter = options.service;
102
- // Fast path: if a buyer daemon is running, it has already populated
103
- // buyer.state.json with discovered peers. Read from there to skip a
104
- // 30-second DHT round-trip.
105
- const cachedPeers = await loadPeersFromBuyerState(globalOpts.dataDir);
106
- if (cachedPeers) {
107
- const filtered = serviceFilter
108
- ? cachedPeers.filter((peer) => peer.providers.includes(serviceFilter))
109
- : cachedPeers;
110
- if (filtered.length > 0) {
111
- if (options.json) {
112
- console.log(JSON.stringify(filtered, null, 2));
113
- return;
114
- }
115
- console.log(chalk.dim(`Loaded ${filtered.length} peer(s) from running buyer daemon`));
116
- renderPeersTable(filtered);
117
- return;
480
+ const serviceFilter = rawOptions.service?.trim();
481
+ const tagFilter = parseTagFilter(rawOptions.tag);
482
+ const sortKey = parseSortKey(rawOptions.sort);
483
+ const topLimit = parseTopLimit(rawOptions.top);
484
+ let snapshot = await loadSnapshotFromBuyerState(globalOpts.dataDir);
485
+ if (!snapshot) {
486
+ const bootstrapNodes = config.network.bootstrapNodes.length > 0
487
+ ? toBootstrapConfig(parseBootstrapList(config.network.bootstrapNodes))
488
+ : undefined;
489
+ const spinner = ora('Discovering peers on the network...').start();
490
+ const paymentsConfig = buildPaymentsConfig(config.payments?.crypto);
491
+ const node = new AntseedNode({
492
+ role: 'buyer',
493
+ ...(bootstrapNodes ? { bootstrapNodes } : {}),
494
+ dhtOperationTimeoutMs: 30_000,
495
+ ...(paymentsConfig ? { payments: paymentsConfig } : {}),
496
+ });
497
+ try {
498
+ await node.start();
118
499
  }
119
- }
120
- const bootstrapNodes = config.network.bootstrapNodes.length > 0
121
- ? toBootstrapConfig(parseBootstrapList(config.network.bootstrapNodes))
122
- : undefined;
123
- const spinner = ora('Discovering peers on the network...').start();
124
- const node = new AntseedNode({
125
- role: 'buyer',
126
- bootstrapNodes,
127
- dhtOperationTimeoutMs: 30_000,
128
- });
129
- try {
130
- await node.start();
131
- }
132
- catch (err) {
133
- spinner.fail(chalk.red(`Failed to connect to network: ${err.message}`));
134
- process.exit(1);
135
- }
136
- try {
137
- const peers = await node.discoverPeers(serviceFilter);
138
- spinner.succeed(chalk.green(`Found ${peers.length} peer(s)`));
139
- if (peers.length === 0) {
140
- console.log(chalk.dim('No peers found. Try again later or check your bootstrap nodes.'));
141
- await node.stop();
142
- return;
500
+ catch (err) {
501
+ spinner.fail(chalk.red(`Failed to connect to network: ${err.message}`));
502
+ process.exit(1);
503
+ }
504
+ try {
505
+ const peers = await node.discoverPeers(serviceFilter);
506
+ spinner.succeed(chalk.green(`Found ${peers.length} peer(s)`));
507
+ // Only stamp `onChainStatsRefreshedAt` when the enrichment loop
508
+ // could actually have run — otherwise we'd display a misleading
509
+ // "On-chain stats as of just now" header on top of a table that
510
+ // has "—" in every on-chain column. Derive it from the per-peer
511
+ // timestamps so we reflect only real RPC reads.
512
+ const latestChainStamp = peers
513
+ .map((p) => p.onChainStatsFetchedAt ?? 0)
514
+ .reduce((max, v) => (v > max ? v : max), 0);
515
+ snapshot = {
516
+ peers,
517
+ onChainStatsRefreshedAt: latestChainStamp > 0 ? latestChainStamp : null,
518
+ sourceLabel: 'live DHT discovery',
519
+ };
143
520
  }
144
- if (options.json) {
145
- console.log(JSON.stringify(peers, null, 2));
521
+ catch (err) {
522
+ spinner.fail(chalk.red(`Discovery failed: ${err.message}`));
146
523
  await node.stop();
147
524
  return;
148
525
  }
149
- renderPeersTable(peers);
526
+ await node.stop();
527
+ }
528
+ let peers = snapshot.peers;
529
+ if (serviceFilter) {
530
+ peers = peers.filter((peer) => peerMatchesServiceFilter(peer, serviceFilter));
531
+ }
532
+ if (tagFilter.size > 0) {
533
+ peers = peers.filter((peer) => peerMatchesTagFilter(peer, tagFilter));
534
+ }
535
+ if (peers.length === 0) {
536
+ const filters = [
537
+ serviceFilter ? '--service' : null,
538
+ tagFilter.size > 0 ? '--tag' : null,
539
+ ].filter((x) => x !== null);
540
+ const hint = filters.length > 0
541
+ ? `Try adjusting ${filters.join(' / ')} or widening your search.`
542
+ : 'Try again later once more peers announce.';
543
+ console.log(chalk.dim(`No peers found. ${hint}`));
544
+ return;
545
+ }
546
+ peers = sortPeers(peers, sortKey);
547
+ const truncated = peers.length > topLimit;
548
+ const displayed = truncated ? peers.slice(0, topLimit) : peers;
549
+ if (rawOptions.json) {
550
+ console.log(JSON.stringify({
551
+ source: snapshot.sourceLabel,
552
+ onChainStatsRefreshedAt: snapshot.onChainStatsRefreshedAt,
553
+ sort: sortKey,
554
+ filters: {
555
+ service: serviceFilter ?? null,
556
+ tags: tagFilter.size > 0 ? Array.from(tagFilter).sort() : null,
557
+ },
558
+ total: peers.length,
559
+ peers: displayed,
560
+ }, null, 2));
561
+ return;
562
+ }
563
+ const filterBits = [];
564
+ if (serviceFilter)
565
+ filterBits.push(`service="${serviceFilter}"`);
566
+ if (tagFilter.size > 0)
567
+ filterBits.push(`tags=[${Array.from(tagFilter).sort().join(',')}]`);
568
+ const filterSuffix = filterBits.length > 0 ? ` • filter: ${filterBits.join(' ')}` : '';
569
+ console.log(chalk.dim(`Source: ${snapshot.sourceLabel} • ${peers.length} peer(s)${truncated ? ` (showing top ${topLimit})` : ''} • sort: ${sortKey}${filterSuffix}`));
570
+ if (snapshot.onChainStatsRefreshedAt) {
571
+ const ageMs = Date.now() - snapshot.onChainStatsRefreshedAt;
572
+ console.log(chalk.dim(`On-chain stats as of ${formatHumanAgeMs(ageMs)}`));
573
+ }
574
+ const hasChainData = displayed.some((peer) => typeof peer.onChainChannelCount === 'number');
575
+ if (rawOptions.services) {
576
+ renderExpandedTable(displayed, tagFilter);
577
+ }
578
+ else {
579
+ renderCompactTable(displayed, hasChainData);
150
580
  }
151
- catch (err) {
152
- spinner.fail(chalk.red(`Discovery failed: ${err.message}`));
581
+ const topPeer = displayed[0];
582
+ if (topPeer) {
583
+ console.log(chalk.bold('Pin a peer:'));
584
+ console.log(` antseed buyer connection set --peer ${topPeer.peerId}`);
585
+ console.log(chalk.dim(` or per-request: curl -H "x-antseed-pin-peer: ${topPeer.peerId}" ...`));
586
+ console.log(chalk.dim(` full details: antseed network peer ${topPeer.peerId}`));
587
+ console.log('');
153
588
  }
154
- await node.stop();
155
589
  });
156
590
  }
157
591
  //# sourceMappingURL=browse.js.map