@gopherhole/sdk 0.1.0 → 0.1.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 +140 -73
- package/dist/index.d.mts +505 -0
- package/dist/index.d.ts +478 -40
- package/dist/index.js +449 -197
- package/dist/index.mjs +440 -0
- package/package.json +39 -7
- package/src/index.ts +617 -204
- package/src/types.ts +232 -0
- package/tsconfig.json +9 -6
- package/listen-marketclaw.ts +0 -26
- package/send-from-nova.ts +0 -28
- package/test.ts +0 -86
package/src/index.ts
CHANGED
|
@@ -1,277 +1,690 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' | '
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
107
|
+
private autoReconnect: boolean;
|
|
46
108
|
private reconnectDelay: number;
|
|
47
|
-
private
|
|
48
|
-
private
|
|
49
|
-
private
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
}
|
|
146
191
|
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
220
|
+
return response as Task;
|
|
221
|
+
}
|
|
158
222
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
195
|
-
return this.authenticated && this.ws?.readyState === WebSocket.OPEN;
|
|
329
|
+
return data.result;
|
|
196
330
|
}
|
|
197
331
|
|
|
198
332
|
/**
|
|
199
|
-
*
|
|
200
|
-
* Reads GOPHERHOLE_API_KEY and optionally GOPHERHOLE_HUB_URL
|
|
333
|
+
* Handle incoming WebSocket messages
|
|
201
334
|
*/
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
*
|
|
361
|
+
* Start ping interval
|
|
215
362
|
*/
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
373
|
+
*/
|
|
374
|
+
private stopPing(): void {
|
|
375
|
+
if (this.pingInterval) {
|
|
376
|
+
clearInterval(this.pingInterval);
|
|
377
|
+
this.pingInterval = null;
|
|
226
378
|
}
|
|
379
|
+
}
|
|
227
380
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
381
|
+
/**
|
|
382
|
+
* Schedule reconnection
|
|
383
|
+
*/
|
|
384
|
+
private scheduleReconnect(): void {
|
|
385
|
+
if (this.reconnectTimer) return;
|
|
231
386
|
|
|
232
|
-
|
|
387
|
+
this.reconnectAttempts++;
|
|
388
|
+
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
389
|
+
|
|
390
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
391
|
+
this.reconnectTimer = null;
|
|
233
392
|
try {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
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
|
+
if (options?.scope) params.set('scope', options.scope);
|
|
433
|
+
|
|
434
|
+
// Include API key to see same-tenant agents (not just public)
|
|
435
|
+
const response = await fetch(`${this.apiUrl}/api/discover/agents?${params}`, {
|
|
436
|
+
headers: {
|
|
437
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
438
|
+
},
|
|
250
439
|
});
|
|
440
|
+
return response.json();
|
|
441
|
+
}
|
|
251
442
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
443
|
+
/**
|
|
444
|
+
* Discover all agents in your tenant (no limit)
|
|
445
|
+
*/
|
|
446
|
+
async discoverTenantAgents(): Promise<DiscoverResult> {
|
|
447
|
+
return this.discover({ scope: 'tenant' });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Search agents with fuzzy matching on description
|
|
452
|
+
*/
|
|
453
|
+
async searchAgents(query: string, options?: Omit<DiscoverOptions, 'query'>): Promise<DiscoverResult> {
|
|
454
|
+
return this.discover({ ...options, query });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Find agents by category
|
|
459
|
+
*/
|
|
460
|
+
async findByCategory(category: string, options?: Omit<DiscoverOptions, 'category'>): Promise<DiscoverResult> {
|
|
461
|
+
return this.discover({ ...options, category });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Find agents by tag
|
|
466
|
+
*/
|
|
467
|
+
async findByTag(tag: string, options?: Omit<DiscoverOptions, 'tag'>): Promise<DiscoverResult> {
|
|
468
|
+
return this.discover({ ...options, tag });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Find agents by skill tag (searches within agent skills)
|
|
473
|
+
*/
|
|
474
|
+
async findBySkillTag(skillTag: string, options?: Omit<DiscoverOptions, 'skillTag'>): Promise<DiscoverResult> {
|
|
475
|
+
return this.discover({ ...options, skillTag });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Find agents that support a specific input/output mode
|
|
480
|
+
*/
|
|
481
|
+
async findByContentMode(mode: string, options?: Omit<DiscoverOptions, 'contentMode'>): Promise<DiscoverResult> {
|
|
482
|
+
return this.discover({ ...options, contentMode: mode });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Get top-rated agents
|
|
487
|
+
*/
|
|
488
|
+
async getTopRated(limit = 10): Promise<DiscoverResult> {
|
|
489
|
+
return this.discover({ sort: 'rating', limit });
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Get most popular agents (by usage)
|
|
494
|
+
*/
|
|
495
|
+
async getPopular(limit = 10): Promise<DiscoverResult> {
|
|
496
|
+
return this.discover({ sort: 'popular', limit });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Get featured/curated agents
|
|
501
|
+
*/
|
|
502
|
+
async getFeatured(): Promise<{ featured: PublicAgent[] }> {
|
|
503
|
+
const response = await fetch(`${this.apiUrl}/api/discover/featured`);
|
|
504
|
+
return response.json();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Get available categories
|
|
509
|
+
*/
|
|
510
|
+
async getCategories(): Promise<{ categories: AgentCategory[] }> {
|
|
511
|
+
const response = await fetch(`${this.apiUrl}/api/discover/categories`);
|
|
512
|
+
return response.json();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Get detailed info about a public agent
|
|
517
|
+
*/
|
|
518
|
+
async getAgentInfo(agentId: string): Promise<AgentInfoResult> {
|
|
519
|
+
const response = await fetch(`${this.apiUrl}/api/discover/agents/${agentId}`);
|
|
520
|
+
if (!response.ok) {
|
|
521
|
+
throw new Error('Agent not found');
|
|
522
|
+
}
|
|
523
|
+
return response.json();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Rate an agent (requires authentication)
|
|
528
|
+
*/
|
|
529
|
+
async rateAgent(agentId: string, rating: number, review?: string): Promise<RatingResult> {
|
|
530
|
+
const response = await fetch(`${this.apiUrl}/api/discover/agents/${agentId}/rate`, {
|
|
531
|
+
method: 'POST',
|
|
532
|
+
headers: {
|
|
533
|
+
'Content-Type': 'application/json',
|
|
534
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
535
|
+
},
|
|
536
|
+
body: JSON.stringify({ rating, review }),
|
|
257
537
|
});
|
|
538
|
+
if (!response.ok) {
|
|
539
|
+
const error = await response.json();
|
|
540
|
+
throw new Error(error.error || 'Failed to rate agent');
|
|
541
|
+
}
|
|
542
|
+
return response.json();
|
|
543
|
+
}
|
|
258
544
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
545
|
+
/**
|
|
546
|
+
* Get best agent for a task using smart matching
|
|
547
|
+
* Searches by query and returns the top-rated match
|
|
548
|
+
*/
|
|
549
|
+
async findBestAgent(query: string, options?: {
|
|
550
|
+
category?: string;
|
|
551
|
+
minRating?: number;
|
|
552
|
+
pricing?: 'free' | 'paid' | 'any';
|
|
553
|
+
}): Promise<PublicAgent | null> {
|
|
554
|
+
const result = await this.discover({
|
|
555
|
+
query,
|
|
556
|
+
category: options?.category,
|
|
557
|
+
sort: 'rating',
|
|
558
|
+
limit: 10,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const agents = result.agents.filter(agent => {
|
|
562
|
+
if (options?.minRating && agent.avgRating < options.minRating) return false;
|
|
563
|
+
if (options?.pricing === 'free' && agent.pricing !== 'free') return false;
|
|
564
|
+
if (options?.pricing === 'paid' && agent.pricing === 'free') return false;
|
|
565
|
+
return true;
|
|
263
566
|
});
|
|
567
|
+
|
|
568
|
+
return agents[0] || null;
|
|
569
|
+
}
|
|
264
570
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
571
|
+
/**
|
|
572
|
+
* Find agents similar to a given agent
|
|
573
|
+
*/
|
|
574
|
+
async findSimilar(agentId: string, limit = 5): Promise<DiscoverResult> {
|
|
575
|
+
// Get the agent's info first
|
|
576
|
+
const info = await this.getAgentInfo(agentId);
|
|
577
|
+
const agent = info.agent;
|
|
578
|
+
|
|
579
|
+
// Search by category and tags
|
|
580
|
+
if (agent.category) {
|
|
581
|
+
const result = await this.discover({
|
|
582
|
+
category: agent.category,
|
|
583
|
+
sort: 'rating',
|
|
584
|
+
limit: limit + 1, // +1 to exclude self
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Filter out the original agent
|
|
588
|
+
result.agents = result.agents.filter(a => a.id !== agentId).slice(0, limit);
|
|
589
|
+
return result;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Fallback to top rated
|
|
593
|
+
return this.getTopRated(limit);
|
|
274
594
|
}
|
|
275
595
|
}
|
|
276
596
|
|
|
597
|
+
// ============================================================
|
|
598
|
+
// DISCOVERY TYPES
|
|
599
|
+
// ============================================================
|
|
600
|
+
|
|
601
|
+
export interface DiscoverOptions {
|
|
602
|
+
/** Search query (fuzzy matches name, description, tags) */
|
|
603
|
+
query?: string;
|
|
604
|
+
/** Filter by category */
|
|
605
|
+
category?: string;
|
|
606
|
+
/** Filter by tag */
|
|
607
|
+
tag?: string;
|
|
608
|
+
/** Filter by skill tag (searches within agent skills) */
|
|
609
|
+
skillTag?: string;
|
|
610
|
+
/** Filter by content mode (MIME type, e.g., 'text/markdown', 'image/png') */
|
|
611
|
+
contentMode?: string;
|
|
612
|
+
/** Sort order */
|
|
613
|
+
sort?: 'rating' | 'popular' | 'recent';
|
|
614
|
+
/** Max results (default 20, max 100; ignored when scope=tenant) */
|
|
615
|
+
limit?: number;
|
|
616
|
+
/** Pagination offset */
|
|
617
|
+
offset?: number;
|
|
618
|
+
/** Scope: 'tenant' returns only same-tenant agents with no limit */
|
|
619
|
+
scope?: 'tenant';
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export interface DiscoverResult {
|
|
623
|
+
agents: PublicAgent[];
|
|
624
|
+
count: number;
|
|
625
|
+
offset: number;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export interface PublicAgent {
|
|
629
|
+
id: string;
|
|
630
|
+
name: string;
|
|
631
|
+
description: string | null;
|
|
632
|
+
category: string | null;
|
|
633
|
+
tags: string[];
|
|
634
|
+
pricing: 'free' | 'paid' | 'contact';
|
|
635
|
+
avgRating: number;
|
|
636
|
+
ratingCount: number;
|
|
637
|
+
tenantName: string;
|
|
638
|
+
websiteUrl: string | null;
|
|
639
|
+
docsUrl: string | null;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export interface AgentCategory {
|
|
643
|
+
name: string;
|
|
644
|
+
count: number;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export interface AgentInfoResult {
|
|
648
|
+
agent: PublicAgent & {
|
|
649
|
+
agentCard: {
|
|
650
|
+
name: string;
|
|
651
|
+
description?: string;
|
|
652
|
+
skills?: AgentSkill[];
|
|
653
|
+
} | null;
|
|
654
|
+
stats: {
|
|
655
|
+
avgRating: number;
|
|
656
|
+
ratingCount: number;
|
|
657
|
+
totalMessages: number;
|
|
658
|
+
successRate: number;
|
|
659
|
+
avgResponseTime: number;
|
|
660
|
+
};
|
|
661
|
+
};
|
|
662
|
+
reviews: AgentReview[];
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Full A2A skill schema */
|
|
666
|
+
export interface AgentSkill {
|
|
667
|
+
id: string;
|
|
668
|
+
name: string;
|
|
669
|
+
description?: string;
|
|
670
|
+
tags?: string[];
|
|
671
|
+
examples?: string[];
|
|
672
|
+
inputModes?: string[];
|
|
673
|
+
outputModes?: string[];
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export interface AgentReview {
|
|
677
|
+
rating: number;
|
|
678
|
+
review: string;
|
|
679
|
+
created_at: number;
|
|
680
|
+
reviewer_name: string;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export interface RatingResult {
|
|
684
|
+
success: boolean;
|
|
685
|
+
avgRating: number;
|
|
686
|
+
ratingCount: number;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Default export
|
|
277
690
|
export default GopherHole;
|