@gopherhole/sdk 0.1.0 → 0.1.1

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/src/index.ts CHANGED
@@ -1,277 +1,680 @@
1
- import WebSocket from 'ws';
2
- import { EventEmitter } from 'events';
1
+ import { EventEmitter } from 'eventemitter3';
2
+
3
+ // Re-export types
4
+ export * from './types';
3
5
 
4
6
  export interface GopherHoleOptions {
7
+ /** API key (starts with gph_) */
8
+ apiKey: string;
9
+ /** Hub URL (defaults to production) */
5
10
  hubUrl?: string;
6
- reconnect?: boolean;
11
+ /** Agent card to register on connect */
12
+ agentCard?: AgentCardConfig;
13
+ /** Auto-reconnect on disconnect */
14
+ autoReconnect?: boolean;
15
+ /** Reconnect delay in ms */
7
16
  reconnectDelay?: number;
17
+ /** Max reconnect attempts */
18
+ maxReconnectAttempts?: number;
19
+ }
20
+
21
+ /** Agent card configuration for registration */
22
+ export interface AgentCardConfig {
23
+ name: string;
24
+ description?: string;
25
+ url?: string;
26
+ version?: string;
27
+ skills?: AgentSkillConfig[];
28
+ }
29
+
30
+ /** Skill configuration */
31
+ export interface AgentSkillConfig {
32
+ id: string;
33
+ name: string;
34
+ description?: string;
35
+ tags?: string[];
36
+ examples?: string[];
37
+ inputModes?: string[];
38
+ outputModes?: string[];
8
39
  }
9
40
 
10
41
  export interface Message {
11
42
  from: string;
12
- to: string;
43
+ taskId?: string;
13
44
  payload: MessagePayload;
14
45
  timestamp: number;
15
46
  }
16
47
 
17
48
  export interface MessagePayload {
49
+ role: 'user' | 'agent';
18
50
  parts: MessagePart[];
19
- contextId?: string;
20
51
  }
21
52
 
22
53
  export interface MessagePart {
23
- kind: 'text' | 'data' | 'file';
54
+ kind: 'text' | 'file' | 'data';
24
55
  text?: string;
25
- data?: unknown;
26
56
  mimeType?: string;
57
+ data?: string;
58
+ uri?: string;
59
+ }
60
+
61
+ export interface Task {
62
+ id: string;
63
+ contextId: string;
64
+ status: TaskStatus;
65
+ history?: MessagePayload[];
66
+ artifacts?: Artifact[];
67
+ }
68
+
69
+ export interface TaskStatus {
70
+ state: 'submitted' | 'working' | 'input-required' | 'completed' | 'failed' | 'canceled' | 'rejected';
71
+ timestamp: string;
72
+ message?: string;
73
+ }
74
+
75
+ export interface Artifact {
76
+ name: string;
77
+ mimeType: string;
78
+ data?: string;
27
79
  uri?: string;
28
80
  }
29
81
 
30
- interface HubMessage {
31
- type: string;
32
- id?: string;
33
- from?: string;
34
- to?: string;
35
- payload?: MessagePayload;
36
- agentId?: string;
37
- error?: string;
38
- timestamp?: number;
82
+ export interface SendOptions {
83
+ /** Existing context/conversation ID */
84
+ contextId?: string;
85
+ /** Push notification URL */
86
+ pushNotificationUrl?: string;
87
+ /** History length to include */
88
+ historyLength?: number;
39
89
  }
40
90
 
41
- export class GopherHole extends EventEmitter {
91
+ type EventMap = {
92
+ connect: () => void;
93
+ disconnect: (reason: string) => void;
94
+ error: (error: Error) => void;
95
+ message: (message: Message) => void;
96
+ taskUpdate: (task: Task) => void;
97
+ };
98
+
99
+ const DEFAULT_HUB_URL = 'wss://gopherhole.helixdata.workers.dev/ws';
100
+ const DEFAULT_API_URL = 'https://gopherhole.helixdata.workers.dev';
101
+
102
+ export class GopherHole extends EventEmitter<EventMap> {
42
103
  private apiKey: string;
43
104
  private hubUrl: string;
105
+ private apiUrl: string;
44
106
  private ws: WebSocket | null = null;
45
- private reconnect: boolean;
107
+ private autoReconnect: boolean;
46
108
  private reconnectDelay: number;
47
- private authenticated = false;
48
- private pendingMessages: Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }> = new Map();
49
- private messageCounter = 0;
50
- public agentId: string | null = null;
51
-
52
- constructor(apiKey: string, options: GopherHoleOptions = {}) {
109
+ private maxReconnectAttempts: number;
110
+ private reconnectAttempts = 0;
111
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
112
+ private pingInterval: ReturnType<typeof setInterval> | null = null;
113
+ private agentId: string | null = null;
114
+ private agentCard: AgentCardConfig | null = null;
115
+
116
+ constructor(apiKeyOrOptions: string | GopherHoleOptions) {
53
117
  super();
54
- this.apiKey = apiKey;
55
- this.hubUrl = options.hubUrl || 'wss://gopherhole.ai/ws';
56
- this.reconnect = options.reconnect ?? true;
57
- this.reconnectDelay = options.reconnectDelay ?? 5000;
58
- }
59
118
 
60
- async connect(): Promise<void> {
61
- return new Promise<void>((resolve, reject) => {
62
- try {
63
- this.ws = new WebSocket(this.hubUrl);
64
-
65
- this.ws.on('open', () => {
66
- console.log('[GopherHole] Connected, authenticating...');
67
- this.ws!.send(JSON.stringify({ type: 'auth', token: this.apiKey }));
68
- });
69
-
70
- this.ws.on('message', (data) => {
71
- try {
72
- const msg: HubMessage = JSON.parse(data.toString());
73
- this.handleMessage(msg, resolve, reject);
74
- } catch (err) {
75
- console.error('[GopherHole] Failed to parse message:', err);
76
- }
77
- });
78
-
79
- this.ws.on('close', () => {
80
- console.log('[GopherHole] Disconnected');
81
- this.authenticated = false;
82
- this.emit('disconnect');
83
-
84
- if (this.reconnect) {
85
- console.log(`[GopherHole] Reconnecting in ${this.reconnectDelay}ms...`);
86
- setTimeout(() => this.connect(), this.reconnectDelay);
87
- }
88
- });
89
-
90
- this.ws.on('error', (err) => {
91
- console.error('[GopherHole] WebSocket error:', err);
92
- this.emit('error', err);
93
- if (!this.authenticated) {
94
- reject(err);
95
- }
96
- });
97
- } catch (err) {
98
- reject(err);
99
- }
100
- });
119
+ const options = typeof apiKeyOrOptions === 'string'
120
+ ? { apiKey: apiKeyOrOptions }
121
+ : apiKeyOrOptions;
122
+
123
+ this.apiKey = options.apiKey;
124
+ this.hubUrl = options.hubUrl || DEFAULT_HUB_URL;
125
+ this.apiUrl = this.hubUrl.replace('/ws', '').replace('wss://', 'https://').replace('ws://', 'http://');
126
+ this.agentCard = options.agentCard || null;
127
+ this.autoReconnect = options.autoReconnect ?? true;
128
+ this.reconnectDelay = options.reconnectDelay ?? 1000;
129
+ this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
130
+ }
131
+
132
+ /**
133
+ * Update agent card (sends to hub if connected)
134
+ */
135
+ async updateCard(card: AgentCardConfig): Promise<void> {
136
+ this.agentCard = card;
137
+ if (this.ws?.readyState === 1) {
138
+ this.ws.send(JSON.stringify({ type: 'update_card', agentCard: card }));
139
+ }
101
140
  }
102
141
 
103
- private handleMessage(msg: HubMessage, resolve?: () => void, reject?: (e: Error) => void) {
104
- switch (msg.type) {
105
- case 'auth_ok':
106
- console.log(`[GopherHole] Authenticated as ${msg.agentId}`);
107
- this.authenticated = true;
108
- this.agentId = msg.agentId || null;
109
- this.emit('connected', { agentId: this.agentId });
110
- resolve?.();
111
- break;
112
-
113
- case 'auth_error':
114
- console.error('[GopherHole] Auth failed:', msg.error);
115
- reject?.(new Error(msg.error || 'Authentication failed'));
116
- break;
117
-
118
- case 'message':
119
- const incomingMsg: Message = {
120
- from: msg.from!,
121
- to: msg.to!,
122
- payload: msg.payload!,
123
- timestamp: msg.timestamp || Date.now(),
124
- };
125
- console.log(`[GopherHole] Message from ${msg.from}`);
126
- this.emit('message', incomingMsg);
127
- break;
128
-
129
- case 'ack':
130
- const pending = this.pendingMessages.get(msg.id!);
131
- if (pending) {
132
- pending.resolve({ id: msg.id, timestamp: msg.timestamp });
133
- this.pendingMessages.delete(msg.id!);
142
+ /**
143
+ * Connect to the GopherHole hub via WebSocket
144
+ */
145
+ async connect(): Promise<void> {
146
+ return new Promise((resolve, reject) => {
147
+ // Browser or Node WebSocket
148
+ const WS = typeof WebSocket !== 'undefined' ? WebSocket : require('ws');
149
+
150
+ const ws = new WS(this.hubUrl, {
151
+ headers: {
152
+ 'Authorization': `Bearer ${this.apiKey}`,
153
+ },
154
+ }) as WebSocket;
155
+
156
+ this.ws = ws;
157
+
158
+ ws.onopen = () => {
159
+ this.reconnectAttempts = 0;
160
+ this.startPing();
161
+ this.emit('connect');
162
+ resolve();
163
+ };
164
+
165
+ ws.onclose = (event: CloseEvent) => {
166
+ this.stopPing();
167
+ const reason = event.reason || 'Connection closed';
168
+ this.emit('disconnect', reason);
169
+
170
+ if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
171
+ this.scheduleReconnect();
134
172
  }
135
- break;
136
-
137
- case 'error':
138
- console.error('[GopherHole] Error:', msg.error);
139
- const pendingErr = msg.id ? this.pendingMessages.get(msg.id) : null;
140
- if (pendingErr) {
141
- pendingErr.reject(new Error(msg.error));
142
- this.pendingMessages.delete(msg.id!);
173
+ };
174
+
175
+ ws.onerror = () => {
176
+ const error = new Error('WebSocket error');
177
+ this.emit('error', error);
178
+ reject(error);
179
+ };
180
+
181
+ ws.onmessage = (event: MessageEvent) => {
182
+ try {
183
+ const data = JSON.parse(event.data.toString());
184
+ this.handleMessage(data);
185
+ } catch {
186
+ this.emit('error', new Error('Failed to parse message'));
143
187
  }
144
- this.emit('error', new Error(msg.error));
145
- break;
188
+ };
189
+ });
190
+ }
146
191
 
147
- default:
148
- console.log('[GopherHole] Unknown message type:', msg.type);
192
+ /**
193
+ * Disconnect from the hub
194
+ */
195
+ disconnect(): void {
196
+ this.autoReconnect = false;
197
+ this.stopPing();
198
+ if (this.reconnectTimer) {
199
+ clearTimeout(this.reconnectTimer);
200
+ this.reconnectTimer = null;
201
+ }
202
+ if (this.ws) {
203
+ this.ws.close();
204
+ this.ws = null;
149
205
  }
150
206
  }
151
207
 
152
- async send(to: string, payload: MessagePayload): Promise<{ id: string; timestamp: number }> {
153
- if (!this.authenticated || !this.ws) {
154
- throw new Error('Not connected');
155
- }
208
+ /**
209
+ * Send a message to another agent
210
+ */
211
+ async send(toAgentId: string, payload: MessagePayload, options?: SendOptions): Promise<Task> {
212
+ const response = await this.rpc('message/send', {
213
+ message: payload,
214
+ configuration: {
215
+ agentId: toAgentId,
216
+ ...options,
217
+ },
218
+ });
156
219
 
157
- const id = `msg-${++this.messageCounter}-${Date.now()}`;
220
+ return response as Task;
221
+ }
158
222
 
159
- return new Promise((resolve, reject) => {
160
- this.pendingMessages.set(id, { resolve: resolve as (v: unknown) => void, reject });
161
-
162
- this.ws!.send(JSON.stringify({
163
- type: 'message',
164
- id,
165
- to,
166
- payload,
167
- }));
168
-
169
- // Timeout after 30s
170
- setTimeout(() => {
171
- if (this.pendingMessages.has(id)) {
172
- this.pendingMessages.delete(id);
173
- reject(new Error('Message send timeout'));
174
- }
175
- }, 30000);
223
+ /**
224
+ * Send a text message to another agent
225
+ */
226
+ async sendText(toAgentId: string, text: string, options?: SendOptions): Promise<Task> {
227
+ return this.send(toAgentId, {
228
+ role: 'agent',
229
+ parts: [{ kind: 'text', text }],
230
+ }, options);
231
+ }
232
+
233
+ /**
234
+ * Get a task by ID
235
+ */
236
+ async getTask(taskId: string, historyLength?: number): Promise<Task> {
237
+ const response = await this.rpc('tasks/get', {
238
+ id: taskId,
239
+ historyLength,
240
+ });
241
+ return response as Task;
242
+ }
243
+
244
+ /**
245
+ * List tasks
246
+ */
247
+ async listTasks(options?: {
248
+ contextId?: string;
249
+ pageSize?: number;
250
+ pageToken?: string;
251
+ }): Promise<{ tasks: Task[]; nextPageToken?: string; totalSize: number }> {
252
+ const response = await this.rpc('tasks/list', options || {});
253
+ return response as { tasks: Task[]; nextPageToken?: string; totalSize: number };
254
+ }
255
+
256
+ /**
257
+ * Cancel a task
258
+ */
259
+ async cancelTask(taskId: string): Promise<Task> {
260
+ const response = await this.rpc('tasks/cancel', { id: taskId });
261
+ return response as Task;
262
+ }
263
+
264
+ /**
265
+ * Reply to a message/task (sends back to the original caller)
266
+ */
267
+ async reply(taskId: string, payload: MessagePayload, toAgentId?: string): Promise<Task> {
268
+ // If toAgentId not provided, we need to figure out who to reply to
269
+ // For now, require the caller to provide it or pass through the task context
270
+ if (!toAgentId) {
271
+ // Get task to use same context
272
+ const task = await this.getTask(taskId);
273
+ // Note: The task doesn't expose client_agent_id via API, so we send to context
274
+ // The server should route based on contextId
275
+ const response = await this.rpc('message/send', {
276
+ message: payload,
277
+ configuration: {
278
+ contextId: task.contextId,
279
+ // Server needs to handle replies via context routing
280
+ },
281
+ });
282
+ return response as Task;
283
+ }
284
+
285
+ const response = await this.rpc('message/send', {
286
+ message: payload,
287
+ configuration: {
288
+ agentId: toAgentId,
289
+ },
176
290
  });
291
+
292
+ return response as Task;
177
293
  }
178
294
 
179
- sendText(to: string, text: string, contextId?: string): Promise<{ id: string; timestamp: number }> {
180
- return this.send(to, {
295
+ /**
296
+ * Reply with text
297
+ */
298
+ async replyText(taskId: string, text: string): Promise<Task> {
299
+ return this.reply(taskId, {
300
+ role: 'agent',
181
301
  parts: [{ kind: 'text', text }],
182
- contextId,
183
302
  });
184
303
  }
185
304
 
186
- disconnect() {
187
- this.reconnect = false;
188
- if (this.ws) {
189
- this.ws.close();
190
- this.ws = null;
305
+ /**
306
+ * Make a JSON-RPC call to the A2A endpoint
307
+ */
308
+ private async rpc(method: string, params: Record<string, unknown>): Promise<unknown> {
309
+ const response = await fetch(`${this.apiUrl}/a2a`, {
310
+ method: 'POST',
311
+ headers: {
312
+ 'Content-Type': 'application/json',
313
+ 'Authorization': `Bearer ${this.apiKey}`,
314
+ },
315
+ body: JSON.stringify({
316
+ jsonrpc: '2.0',
317
+ method,
318
+ params,
319
+ id: Date.now(),
320
+ }),
321
+ });
322
+
323
+ const data = await response.json();
324
+
325
+ if (data.error) {
326
+ throw new Error(data.error.message || 'RPC error');
191
327
  }
192
- }
193
328
 
194
- isConnected(): boolean {
195
- return this.authenticated && this.ws?.readyState === WebSocket.OPEN;
329
+ return data.result;
196
330
  }
197
331
 
198
332
  /**
199
- * Create a GopherHole instance from environment variables
200
- * Reads GOPHERHOLE_API_KEY and optionally GOPHERHOLE_HUB_URL
333
+ * Handle incoming WebSocket messages
201
334
  */
202
- static fromEnv(options: Omit<GopherHoleOptions, 'hubUrl'> = {}): GopherHole {
203
- const apiKey = process.env.GOPHERHOLE_API_KEY;
204
- if (!apiKey) {
205
- throw new Error('GOPHERHOLE_API_KEY environment variable is required');
335
+ private handleMessage(data: any): void {
336
+ if (data.type === 'message') {
337
+ this.emit('message', {
338
+ from: data.from,
339
+ taskId: data.taskId,
340
+ payload: data.payload,
341
+ timestamp: data.timestamp || Date.now(),
342
+ });
343
+ } else if (data.type === 'task_update') {
344
+ this.emit('taskUpdate', data.task);
345
+ } else if (data.type === 'pong') {
346
+ // Heartbeat response
347
+ } else if (data.type === 'welcome') {
348
+ this.agentId = data.agentId;
349
+ // Send agent card if configured
350
+ if (this.agentCard && this.ws?.readyState === 1) {
351
+ this.ws.send(JSON.stringify({ type: 'update_card', agentCard: this.agentCard }));
352
+ }
353
+ } else if (data.type === 'card_updated') {
354
+ // Agent card was successfully updated
355
+ } else if (data.type === 'warning') {
356
+ console.warn('GopherHole warning:', data.message);
206
357
  }
207
- return new GopherHole(apiKey, {
208
- ...options,
209
- hubUrl: process.env.GOPHERHOLE_HUB_URL || 'wss://gopherhole.ai/ws',
210
- });
211
358
  }
212
359
 
213
360
  /**
214
- * Create a simple agent with a message handler
361
+ * Start ping interval
362
+ */
363
+ private startPing(): void {
364
+ this.pingInterval = setInterval(() => {
365
+ if (this.ws?.readyState === 1) { // OPEN
366
+ this.ws.send(JSON.stringify({ type: 'ping' }));
367
+ }
368
+ }, 30000);
369
+ }
370
+
371
+ /**
372
+ * Stop ping interval
215
373
  */
216
- static agent(config: {
217
- apiKey?: string;
218
- hubUrl?: string;
219
- onMessage: (msg: Message, hub: GopherHole) => Promise<string | MessagePayload | void> | string | MessagePayload | void;
220
- onConnect?: (hub: GopherHole) => void;
221
- onError?: (err: Error, hub: GopherHole) => void;
222
- }): { start: () => Promise<void>; stop: () => void; hub: GopherHole } {
223
- const apiKey = config.apiKey || process.env.GOPHERHOLE_API_KEY;
224
- if (!apiKey) {
225
- throw new Error('API key required: pass apiKey or set GOPHERHOLE_API_KEY');
374
+ private stopPing(): void {
375
+ if (this.pingInterval) {
376
+ clearInterval(this.pingInterval);
377
+ this.pingInterval = null;
226
378
  }
379
+ }
227
380
 
228
- const hub = new GopherHole(apiKey, {
229
- hubUrl: config.hubUrl || process.env.GOPHERHOLE_HUB_URL,
230
- });
381
+ /**
382
+ * Schedule reconnection
383
+ */
384
+ private scheduleReconnect(): void {
385
+ if (this.reconnectTimer) return;
386
+
387
+ this.reconnectAttempts++;
388
+ const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
231
389
 
232
- hub.on('message', async (msg: Message) => {
390
+ this.reconnectTimer = setTimeout(async () => {
391
+ this.reconnectTimer = null;
233
392
  try {
234
- const response = await config.onMessage(msg, hub);
235
-
236
- // If handler returns a response, send it back
237
- if (response) {
238
- if (typeof response === 'string') {
239
- await hub.sendText(msg.from, response);
240
- } else {
241
- await hub.send(msg.from, response);
242
- }
243
- }
244
- } catch (err) {
245
- console.error('[GopherHole Agent] Message handler error:', err);
246
- if (config.onError) {
247
- config.onError(err as Error, hub);
248
- }
393
+ await this.connect();
394
+ } catch {
395
+ // Will retry via onclose handler
249
396
  }
397
+ }, delay);
398
+ }
399
+
400
+ /**
401
+ * Get connection state
402
+ */
403
+ get connected(): boolean {
404
+ return this.ws?.readyState === 1;
405
+ }
406
+
407
+ /**
408
+ * Get the agent ID (available after connect)
409
+ */
410
+ get id(): string | null {
411
+ return this.agentId;
412
+ }
413
+
414
+ // ============================================================
415
+ // DISCOVERY METHODS
416
+ // ============================================================
417
+
418
+ /**
419
+ * Discover public agents with comprehensive search
420
+ */
421
+ async discover(options?: DiscoverOptions): Promise<DiscoverResult> {
422
+ const params = new URLSearchParams();
423
+
424
+ if (options?.query) params.set('q', options.query);
425
+ if (options?.category) params.set('category', options.category);
426
+ if (options?.tag) params.set('tag', options.tag);
427
+ if (options?.skillTag) params.set('skillTag', options.skillTag);
428
+ if (options?.contentMode) params.set('contentMode', options.contentMode);
429
+ if (options?.sort) params.set('sort', options.sort);
430
+ if (options?.limit) params.set('limit', String(options.limit));
431
+ if (options?.offset) params.set('offset', String(options.offset));
432
+
433
+ // Include API key to see same-tenant agents (not just public)
434
+ const response = await fetch(`${this.apiUrl}/api/discover/agents?${params}`, {
435
+ headers: {
436
+ 'Authorization': `Bearer ${this.apiKey}`,
437
+ },
250
438
  });
439
+ return response.json();
440
+ }
251
441
 
252
- hub.on('connected', () => {
253
- console.log(`[GopherHole Agent] Connected as ${hub.agentId}`);
254
- if (config.onConnect) {
255
- config.onConnect(hub);
256
- }
442
+ /**
443
+ * Search agents with fuzzy matching on description
444
+ */
445
+ async searchAgents(query: string, options?: Omit<DiscoverOptions, 'query'>): Promise<DiscoverResult> {
446
+ return this.discover({ ...options, query });
447
+ }
448
+
449
+ /**
450
+ * Find agents by category
451
+ */
452
+ async findByCategory(category: string, options?: Omit<DiscoverOptions, 'category'>): Promise<DiscoverResult> {
453
+ return this.discover({ ...options, category });
454
+ }
455
+
456
+ /**
457
+ * Find agents by tag
458
+ */
459
+ async findByTag(tag: string, options?: Omit<DiscoverOptions, 'tag'>): Promise<DiscoverResult> {
460
+ return this.discover({ ...options, tag });
461
+ }
462
+
463
+ /**
464
+ * Find agents by skill tag (searches within agent skills)
465
+ */
466
+ async findBySkillTag(skillTag: string, options?: Omit<DiscoverOptions, 'skillTag'>): Promise<DiscoverResult> {
467
+ return this.discover({ ...options, skillTag });
468
+ }
469
+
470
+ /**
471
+ * Find agents that support a specific input/output mode
472
+ */
473
+ async findByContentMode(mode: string, options?: Omit<DiscoverOptions, 'contentMode'>): Promise<DiscoverResult> {
474
+ return this.discover({ ...options, contentMode: mode });
475
+ }
476
+
477
+ /**
478
+ * Get top-rated agents
479
+ */
480
+ async getTopRated(limit = 10): Promise<DiscoverResult> {
481
+ return this.discover({ sort: 'rating', limit });
482
+ }
483
+
484
+ /**
485
+ * Get most popular agents (by usage)
486
+ */
487
+ async getPopular(limit = 10): Promise<DiscoverResult> {
488
+ return this.discover({ sort: 'popular', limit });
489
+ }
490
+
491
+ /**
492
+ * Get featured/curated agents
493
+ */
494
+ async getFeatured(): Promise<{ featured: PublicAgent[] }> {
495
+ const response = await fetch(`${this.apiUrl}/api/discover/featured`);
496
+ return response.json();
497
+ }
498
+
499
+ /**
500
+ * Get available categories
501
+ */
502
+ async getCategories(): Promise<{ categories: AgentCategory[] }> {
503
+ const response = await fetch(`${this.apiUrl}/api/discover/categories`);
504
+ return response.json();
505
+ }
506
+
507
+ /**
508
+ * Get detailed info about a public agent
509
+ */
510
+ async getAgentInfo(agentId: string): Promise<AgentInfoResult> {
511
+ const response = await fetch(`${this.apiUrl}/api/discover/agents/${agentId}`);
512
+ if (!response.ok) {
513
+ throw new Error('Agent not found');
514
+ }
515
+ return response.json();
516
+ }
517
+
518
+ /**
519
+ * Rate an agent (requires authentication)
520
+ */
521
+ async rateAgent(agentId: string, rating: number, review?: string): Promise<RatingResult> {
522
+ const response = await fetch(`${this.apiUrl}/api/discover/agents/${agentId}/rate`, {
523
+ method: 'POST',
524
+ headers: {
525
+ 'Content-Type': 'application/json',
526
+ 'Authorization': `Bearer ${this.apiKey}`,
527
+ },
528
+ body: JSON.stringify({ rating, review }),
257
529
  });
530
+ if (!response.ok) {
531
+ const error = await response.json();
532
+ throw new Error(error.error || 'Failed to rate agent');
533
+ }
534
+ return response.json();
535
+ }
258
536
 
259
- hub.on('error', (err: Error) => {
260
- if (config.onError) {
261
- config.onError(err, hub);
262
- }
537
+ /**
538
+ * Get best agent for a task using smart matching
539
+ * Searches by query and returns the top-rated match
540
+ */
541
+ async findBestAgent(query: string, options?: {
542
+ category?: string;
543
+ minRating?: number;
544
+ pricing?: 'free' | 'paid' | 'any';
545
+ }): Promise<PublicAgent | null> {
546
+ const result = await this.discover({
547
+ query,
548
+ category: options?.category,
549
+ sort: 'rating',
550
+ limit: 10,
263
551
  });
552
+
553
+ const agents = result.agents.filter(agent => {
554
+ if (options?.minRating && agent.avgRating < options.minRating) return false;
555
+ if (options?.pricing === 'free' && agent.pricing !== 'free') return false;
556
+ if (options?.pricing === 'paid' && agent.pricing === 'free') return false;
557
+ return true;
558
+ });
559
+
560
+ return agents[0] || null;
561
+ }
264
562
 
265
- return {
266
- hub,
267
- start: async () => {
268
- await hub.connect();
269
- },
270
- stop: () => {
271
- hub.disconnect();
272
- },
273
- };
563
+ /**
564
+ * Find agents similar to a given agent
565
+ */
566
+ async findSimilar(agentId: string, limit = 5): Promise<DiscoverResult> {
567
+ // Get the agent's info first
568
+ const info = await this.getAgentInfo(agentId);
569
+ const agent = info.agent;
570
+
571
+ // Search by category and tags
572
+ if (agent.category) {
573
+ const result = await this.discover({
574
+ category: agent.category,
575
+ sort: 'rating',
576
+ limit: limit + 1, // +1 to exclude self
577
+ });
578
+
579
+ // Filter out the original agent
580
+ result.agents = result.agents.filter(a => a.id !== agentId).slice(0, limit);
581
+ return result;
582
+ }
583
+
584
+ // Fallback to top rated
585
+ return this.getTopRated(limit);
274
586
  }
275
587
  }
276
588
 
589
+ // ============================================================
590
+ // DISCOVERY TYPES
591
+ // ============================================================
592
+
593
+ export interface DiscoverOptions {
594
+ /** Search query (fuzzy matches name, description, tags) */
595
+ query?: string;
596
+ /** Filter by category */
597
+ category?: string;
598
+ /** Filter by tag */
599
+ tag?: string;
600
+ /** Filter by skill tag (searches within agent skills) */
601
+ skillTag?: string;
602
+ /** Filter by content mode (MIME type, e.g., 'text/markdown', 'image/png') */
603
+ contentMode?: string;
604
+ /** Sort order */
605
+ sort?: 'rating' | 'popular' | 'recent';
606
+ /** Max results (default 20, max 100) */
607
+ limit?: number;
608
+ /** Pagination offset */
609
+ offset?: number;
610
+ }
611
+
612
+ export interface DiscoverResult {
613
+ agents: PublicAgent[];
614
+ count: number;
615
+ offset: number;
616
+ }
617
+
618
+ export interface PublicAgent {
619
+ id: string;
620
+ name: string;
621
+ description: string | null;
622
+ category: string | null;
623
+ tags: string[];
624
+ pricing: 'free' | 'paid' | 'contact';
625
+ avgRating: number;
626
+ ratingCount: number;
627
+ tenantName: string;
628
+ websiteUrl: string | null;
629
+ docsUrl: string | null;
630
+ }
631
+
632
+ export interface AgentCategory {
633
+ name: string;
634
+ count: number;
635
+ }
636
+
637
+ export interface AgentInfoResult {
638
+ agent: PublicAgent & {
639
+ agentCard: {
640
+ name: string;
641
+ description?: string;
642
+ skills?: AgentSkill[];
643
+ } | null;
644
+ stats: {
645
+ avgRating: number;
646
+ ratingCount: number;
647
+ totalMessages: number;
648
+ successRate: number;
649
+ avgResponseTime: number;
650
+ };
651
+ };
652
+ reviews: AgentReview[];
653
+ }
654
+
655
+ /** Full A2A skill schema */
656
+ export interface AgentSkill {
657
+ id: string;
658
+ name: string;
659
+ description?: string;
660
+ tags?: string[];
661
+ examples?: string[];
662
+ inputModes?: string[];
663
+ outputModes?: string[];
664
+ }
665
+
666
+ export interface AgentReview {
667
+ rating: number;
668
+ review: string;
669
+ created_at: number;
670
+ reviewer_name: string;
671
+ }
672
+
673
+ export interface RatingResult {
674
+ success: boolean;
675
+ avgRating: number;
676
+ ratingCount: number;
677
+ }
678
+
679
+ // Default export
277
680
  export default GopherHole;