@gnsx/genesys.agent.client 0.4.2 → 1.0.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/package.json CHANGED
@@ -1,52 +1,26 @@
1
1
  {
2
2
  "name": "@gnsx/genesys.agent.client",
3
- "version": "0.4.2",
4
- "description": "TypeScript client library for Genesys Agent Server",
3
+ "version": "1.0.0",
5
4
  "type": "module",
6
- "publishConfig": {
7
- "access": "public"
8
- },
5
+ "description": "TypeScript client for the Genesys agent server — browser + Node.js",
9
6
  "main": "./dist/index.js",
10
7
  "types": "./dist/index.d.ts",
11
8
  "exports": {
12
9
  ".": {
13
10
  "types": "./dist/index.d.ts",
14
- "import": "./dist/index.js",
15
- "require": "./dist/index.cjs"
16
- }
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./package.json": "./package.json"
17
14
  },
18
- "files": [
19
- "dist",
20
- "src"
21
- ],
15
+ "files": ["dist", "src"],
22
16
  "scripts": {
23
- "build": "pnpm clean && tsc && tsup",
17
+ "build": "tsc --build",
24
18
  "clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true}); require('fs').rmSync('tsconfig.tsbuildinfo', {force: true})\"",
25
- "lint": "eslint src --fix",
26
- "type-check": "tsc --noEmit",
27
- "prepublishOnly": "pnpm build"
19
+ "lint": "eslint ."
28
20
  },
29
- "keywords": [
30
- "genesys",
31
- "agent",
32
- "client",
33
- "websocket",
34
- "typescript"
35
- ],
36
- "author": "",
37
- "license": "UNLICENSED",
38
21
  "devDependencies": {
39
- "@gnsx/genesys.agent.shared": "workspace:*",
40
- "@typescript-eslint/eslint-plugin": "^8.53.0",
41
- "@typescript-eslint/parser": "^8.53.0",
42
- "dts-bundle-generator": "^9.5.1",
43
22
  "eslint": "^9.39.2",
44
- "eslint-plugin-import": "^2.32.0",
45
- "http-server": "^14.1.1",
46
- "tsup": "^8.5.1",
47
23
  "typescript": "^5.9.3"
48
24
  },
49
- "engines": {
50
- "node": ">=18.0.0"
51
- }
25
+ "license": "UNLICENSED"
52
26
  }
package/src/client.ts ADDED
@@ -0,0 +1,394 @@
1
+ /**
2
+ * GenesysAgentClient — browser + Node.js compatible client for the
3
+ * Genesys Agent v2 server.
4
+ *
5
+ * Connection flow:
6
+ * 1. client.connect(serverUrl)
7
+ * → POST /sessions — allocate workspace on server
8
+ * → WebSocket to wsUrl — attach to that workspace
9
+ *
10
+ * Reconnect: re-uses the stored wsUrl (no re-POST).
11
+ * Destroy: closes WS + DELETE /sessions/:id to clean up the workspace.
12
+ */
13
+
14
+ import { randomUUID } from './uuid.js';
15
+
16
+ import type {
17
+ CreateSessionResponse,
18
+ SessionInfoResponse,
19
+ ThinkingLevel,
20
+ WsCommand,
21
+ WsServerMessage,
22
+ } from './protocol.js';
23
+ import type {
24
+ ClientEventMap,
25
+ ClientEventType,
26
+ ClientOptions,
27
+ ClientState,
28
+ ModelInfo,
29
+ WsSessionState,
30
+ } from './types.js';
31
+
32
+ const LOG_PREFIX = '[GenesysAgentClient]';
33
+
34
+ /** Pending promise waiting for a command response */
35
+ interface Pending {
36
+ resolve: (data: unknown) => void;
37
+ reject: (err: Error) => void;
38
+ }
39
+
40
+ export class GenesysAgentClient {
41
+ private ws: WebSocket | null = null;
42
+ private sessionId: string | null = null;
43
+ private wsUrl: string | null = null;
44
+ private serverUrl: string | null = null;
45
+ private state: ClientState = 'disconnected';
46
+
47
+ private reconnectAttempts = 0;
48
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
49
+ private intentionalClose = false;
50
+
51
+ private readonly pending = new Map<string, Pending>();
52
+ private readonly listeners = new Map<ClientEventType, Set<(...args: unknown[]) => void>>();
53
+
54
+ constructor(private readonly options: ClientOptions = {}) {
55
+ this.options.reconnectDelay ??= 1000;
56
+ this.options.maxReconnectAttempts ??= 5;
57
+ }
58
+
59
+ // =========================================================================
60
+ // Connection lifecycle
61
+ // =========================================================================
62
+
63
+ /**
64
+ * Create a server workspace then open the WebSocket.
65
+ * @param serverUrl Base HTTP URL, e.g. "http://localhost:3000"
66
+ */
67
+ async connect(serverUrl: string): Promise<void> {
68
+ if (this.state === 'connected' || this.state === 'connecting') {
69
+ throw new Error('Already connected or connecting');
70
+ }
71
+
72
+ this.serverUrl = serverUrl.replace(/\/+$/, '');
73
+ this.intentionalClose = false;
74
+ this.setState('connecting');
75
+
76
+ try {
77
+ const response = await fetch(`${this.serverUrl}/sessions`, { method: 'POST' });
78
+ if (!response.ok) {
79
+ const body = await response.json().catch(() => ({ error: 'Failed to create session' })) as { error?: string };
80
+ throw new Error(body.error ?? `HTTP ${response.status}`);
81
+ }
82
+
83
+ const { sessionId, wsUrl } = await response.json() as CreateSessionResponse;
84
+ this.sessionId = sessionId;
85
+ this.wsUrl = wsUrl;
86
+ } catch (err) {
87
+ this.setState('disconnected');
88
+ throw err;
89
+ }
90
+
91
+ await this.openWebSocket();
92
+ }
93
+
94
+ /**
95
+ * Close the WebSocket and DELETE the server workspace.
96
+ * After this call the client is fully cleaned up and can connect() again.
97
+ */
98
+ async destroy(): Promise<void> {
99
+ this.intentionalClose = true;
100
+ this.clearReconnectTimer();
101
+
102
+ // Reject all pending requests
103
+ for (const [, pending] of this.pending) {
104
+ pending.reject(new Error('Client destroyed'));
105
+ }
106
+ this.pending.clear();
107
+
108
+ if (this.ws) {
109
+ this.ws.close();
110
+ this.ws = null;
111
+ }
112
+
113
+ // Clean up server workspace
114
+ if (this.sessionId && this.serverUrl) {
115
+ try {
116
+ await fetch(`${this.serverUrl}/sessions/${this.sessionId}`, { method: 'DELETE' });
117
+ } catch {
118
+ // Best-effort
119
+ }
120
+ }
121
+
122
+ this.sessionId = null;
123
+ this.wsUrl = null;
124
+ this.setState('disconnected');
125
+ }
126
+
127
+ /**
128
+ * Disconnect the WebSocket but keep the workspace alive on the server
129
+ * (allows reconnect via the same wsUrl).
130
+ */
131
+ disconnect(): void {
132
+ this.intentionalClose = true;
133
+ this.clearReconnectTimer();
134
+
135
+ if (this.ws) {
136
+ this.ws.close();
137
+ this.ws = null;
138
+ }
139
+
140
+ this.setState('disconnected');
141
+ }
142
+
143
+ // =========================================================================
144
+ // Commands
145
+ // =========================================================================
146
+
147
+ /**
148
+ * Send a prompt. Returns as soon as the server ACKs — events stream back
149
+ * via the 'event' listener.
150
+ */
151
+ async prompt(message: string): Promise<void> {
152
+ await this.request({ type: 'prompt', message });
153
+ }
154
+
155
+ /** Abort the current agent turn. */
156
+ async abort(): Promise<void> {
157
+ await this.request({ type: 'abort' });
158
+ }
159
+
160
+ /** Get the current session state. */
161
+ async getState(): Promise<WsSessionState> {
162
+ return this.request<WsSessionState>({ type: 'get_state' });
163
+ }
164
+
165
+ /** Set the active model. */
166
+ async setModel(provider: string, modelId: string): Promise<void> {
167
+ await this.request({ type: 'set_model', provider, modelId });
168
+ }
169
+
170
+ /** Get all models available on the server. */
171
+ async getAvailableModels(): Promise<ModelInfo[]> {
172
+ const data = await this.request<{ models: ModelInfo[] }>({ type: 'get_available_models' });
173
+ return data.models;
174
+ }
175
+
176
+ /** Set the thinking/reasoning level. */
177
+ async setThinkingLevel(level: ThinkingLevel): Promise<void> {
178
+ await this.request({ type: 'set_thinking_level', level });
179
+ }
180
+
181
+ /** Start a new session (clears history). */
182
+ async newSession(): Promise<void> {
183
+ await this.request({ type: 'new_session' });
184
+ }
185
+
186
+ /** Compact the conversation context. */
187
+ async compact(customInstructions?: string): Promise<void> {
188
+ await this.request({ type: 'compact', customInstructions });
189
+ }
190
+
191
+ /** Get all current messages. */
192
+ async getMessages(): Promise<unknown[]> {
193
+ const data = await this.request<{ messages: unknown[] }>({ type: 'get_messages' });
194
+ return data.messages;
195
+ }
196
+
197
+ // =========================================================================
198
+ // HTTP helpers
199
+ // =========================================================================
200
+
201
+ /** Fetch workspace info from the server (no WS required). */
202
+ async getSessionInfo(): Promise<SessionInfoResponse> {
203
+ if (!this.serverUrl || !this.sessionId) {
204
+ throw new Error('Not connected');
205
+ }
206
+ const response = await fetch(`${this.serverUrl}/sessions/${this.sessionId}`);
207
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
208
+ return response.json() as Promise<SessionInfoResponse>;
209
+ }
210
+
211
+ // =========================================================================
212
+ // Event listeners
213
+ // =========================================================================
214
+
215
+ on<E extends ClientEventType>(event: E, listener: ClientEventMap[E]): ClientEventMap[E] {
216
+ if (!this.listeners.has(event)) this.listeners.set(event, new Set());
217
+ this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
218
+ return listener;
219
+ }
220
+
221
+ off<E extends ClientEventType>(event: E, listener: ClientEventMap[E]): void {
222
+ this.listeners.get(event)?.delete(listener as (...args: unknown[]) => void);
223
+ }
224
+
225
+ once<E extends ClientEventType>(event: E, listener: ClientEventMap[E]): void {
226
+ const wrapper = ((...args: unknown[]) => {
227
+ this.off(event, wrapper as ClientEventMap[E]);
228
+ (listener as (...args: unknown[]) => void)(...args);
229
+ }) as ClientEventMap[E];
230
+ this.on(event, wrapper);
231
+ }
232
+
233
+ // =========================================================================
234
+ // State
235
+ // =========================================================================
236
+
237
+ getConnectionState(): ClientState { return this.state; }
238
+ isConnected(): boolean { return this.state === 'connected'; }
239
+ getSessionId(): string | null { return this.sessionId; }
240
+
241
+ // =========================================================================
242
+ // Internal: WebSocket
243
+ // =========================================================================
244
+
245
+ private openWebSocket(): Promise<void> {
246
+ if (!this.wsUrl) throw new Error('No wsUrl available');
247
+
248
+ return new Promise((resolve, reject) => {
249
+ const ws = new WebSocket(this.wsUrl!);
250
+ this.ws = ws;
251
+
252
+ ws.onopen = () => {
253
+ this.reconnectAttempts = 0;
254
+ this.setState('connected');
255
+ resolve();
256
+ };
257
+
258
+ ws.onmessage = (event) => {
259
+ try {
260
+ this.handleMessage(JSON.parse(event.data as string) as WsServerMessage);
261
+ } catch (err) {
262
+ this.debug('Failed to parse message:', err);
263
+ }
264
+ };
265
+
266
+ ws.onclose = () => {
267
+ this.ws = null;
268
+ this.setState('disconnected');
269
+
270
+ if (!this.intentionalClose && !this.options.noAutoReconnect) {
271
+ this.scheduleReconnect();
272
+ }
273
+ };
274
+
275
+ ws.onerror = (err) => {
276
+ this.debug('WebSocket error:', err);
277
+ reject(err);
278
+ };
279
+ });
280
+ }
281
+
282
+ private handleMessage(msg: WsServerMessage): void {
283
+ // Emit raw message to 'message' listeners
284
+ this.emit('message', msg);
285
+
286
+ if (msg.type === 'event') {
287
+ // Forward pi AgentSessionEvent to 'event' listeners
288
+ this.emit('event', msg.event);
289
+ return;
290
+ }
291
+
292
+ if (msg.type === 'response') {
293
+ // Resolve/reject pending request promise
294
+ const id = msg.id;
295
+ if (id) {
296
+ const pending = this.pending.get(id);
297
+ if (pending) {
298
+ this.pending.delete(id);
299
+ if (msg.success) {
300
+ pending.resolve(msg.data);
301
+ } else {
302
+ pending.reject(new Error(msg.error));
303
+ }
304
+ }
305
+ }
306
+ }
307
+ }
308
+
309
+ // =========================================================================
310
+ // Internal: request/response correlation
311
+ // =========================================================================
312
+
313
+ private request<T = void>(cmd: WsCommand): Promise<T> {
314
+ if (!this.isConnected()) throw new Error('Not connected');
315
+
316
+ const id = randomUUID();
317
+ return new Promise<T>((resolve, reject) => {
318
+ this.pending.set(id, {
319
+ resolve: (data) => resolve(data as T),
320
+ reject,
321
+ });
322
+ this.send({ ...cmd, id });
323
+ });
324
+ }
325
+
326
+ private send(cmd: WsCommand): void {
327
+ if (this.ws?.readyState === WebSocket.OPEN) {
328
+ this.ws.send(JSON.stringify(cmd));
329
+ }
330
+ }
331
+
332
+ // =========================================================================
333
+ // Internal: reconnect
334
+ // =========================================================================
335
+
336
+ private scheduleReconnect(): void {
337
+ const max = this.options.maxReconnectAttempts!;
338
+ if (this.reconnectAttempts >= max) {
339
+ this.debug(`Giving up after ${max} reconnect attempts`);
340
+ return;
341
+ }
342
+
343
+ this.reconnectAttempts++;
344
+ const delay = Math.min(
345
+ 30_000,
346
+ this.options.reconnectDelay! * Math.pow(1.5, this.reconnectAttempts - 1),
347
+ );
348
+
349
+ this.setState('reconnecting');
350
+ this.debug(`Reconnect attempt ${this.reconnectAttempts}/${max} in ${delay}ms`);
351
+
352
+ this.reconnectTimer = setTimeout(async () => {
353
+ try {
354
+ await this.openWebSocket();
355
+ } catch {
356
+ this.scheduleReconnect();
357
+ }
358
+ }, delay);
359
+ }
360
+
361
+ private clearReconnectTimer(): void {
362
+ if (this.reconnectTimer) {
363
+ clearTimeout(this.reconnectTimer);
364
+ this.reconnectTimer = null;
365
+ }
366
+ }
367
+
368
+ // =========================================================================
369
+ // Internal: emit / state
370
+ // =========================================================================
371
+
372
+ private emit<E extends ClientEventType>(event: E, ...args: Parameters<ClientEventMap[E]>): void {
373
+ const set = this.listeners.get(event);
374
+ if (!set) return;
375
+ for (const listener of set) {
376
+ try {
377
+ (listener as (...a: unknown[]) => void)(...(args as unknown[]));
378
+ } catch (err) {
379
+ this.debug(`Error in '${event}' listener:`, err);
380
+ }
381
+ }
382
+ }
383
+
384
+ private setState(state: ClientState): void {
385
+ if (this.state !== state) {
386
+ this.state = state;
387
+ this.emit('state_change', state);
388
+ }
389
+ }
390
+
391
+ private debug(...args: unknown[]): void {
392
+ if (this.options.debug) console.log(LOG_PREFIX, ...args);
393
+ }
394
+ }
package/src/index.ts CHANGED
@@ -1,56 +1,25 @@
1
- export * from './GenesysAgentClient.js';
2
- export * from './types.js';
3
-
4
- // Re-export shared types explicitly for proper bundling with dts-bundle-generator
5
- // (inlinedLibraries doesn't properly handle subpath exports like /browser)
6
- export {
7
- AgentEventType,
8
- ServerMessageType,
9
- ClientMessageType,
10
- ServerErrorCode,
11
- TodoStatus,
12
- TodoPriority,
13
- } from '@gnsx/genesys.agent.shared/browser';
1
+ export { GenesysAgentClient } from './client.js';
14
2
 
15
3
  export type {
16
- AgentEvent,
17
- ServerMessage,
18
- ClientMessage,
19
- SessionInfo,
20
- AgentEventMessage,
21
- ConnectionEstablishedMessage,
22
- RequestInputMessage,
23
- ErrorMessage,
24
- PingMessage,
25
- SessionInfoMessage,
26
- SessionRestoredMessage,
27
- InputMessage,
28
- // Event types
29
- UserMessageEvent,
30
- AgentMessageEvent,
31
- StreamChunkEvent,
32
- ToolCallEvent,
33
- ToolResultEvent,
34
- ToolErrorEvent,
35
- ReasoningEvent,
36
- TokenUsageEvent,
37
- TodoItem,
38
- TodoListEvent,
39
- SendDataEvent,
40
- ErrorEvent,
41
- MessagesClearedEvent,
42
- ModelUpdatedEvent,
43
- RoundFinishedEvent,
44
- } from '@gnsx/genesys.agent.shared/browser';
4
+ ClientOptions,
5
+ ClientState,
6
+ ClientEventType,
7
+ ClientEventMap,
8
+ AgentEventListener,
9
+ MessageListener,
10
+ StateChangeListener,
11
+ ModelInfo,
12
+ } from './types.js';
45
13
 
46
- // Re-export constants
47
- export {
48
- DEFAULT_PORT,
49
- DEFAULT_SESSION_DIR,
50
- DEFAULT_SESSION_TIMEOUT,
51
- DEFAULT_MAX_SESSIONS,
52
- MAX_FILE_SIZE,
53
- WS_HEARTBEAT_INTERVAL,
54
- SYSTEM_CONTEXT_PLACEHOLDER,
55
- PROJECT_LAYOUT_PLACEHOLDER,
56
- } from '@gnsx/genesys.agent.shared/browser';
14
+ export type {
15
+ WsCommand,
16
+ WsServerMessage,
17
+ WsEvent,
18
+ WsResponse,
19
+ WsError,
20
+ WsSessionState,
21
+ CreateSessionResponse,
22
+ SessionInfoResponse,
23
+ ThinkingLevel,
24
+ ImageContent,
25
+ } from './protocol.js';
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Wire types shared between the Genesys Agent v2 server and this client.
3
+ *
4
+ * Kept as a standalone file (no server imports) so the client package stays
5
+ * browser-compatible with zero Node.js dependencies.
6
+ *
7
+ * Keep in sync with packages/server/src/protocol.ts.
8
+ */
9
+
10
+ // ============================================================================
11
+ // Thinking levels (mirrors @mariozechner/pi-agent-core ThinkingLevel)
12
+ // ============================================================================
13
+
14
+ export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
15
+
16
+ // ============================================================================
17
+ // Image content (mirrors @mariozechner/pi-ai ImageContent)
18
+ // ============================================================================
19
+
20
+ export interface ImageContent {
21
+ type: 'image';
22
+ mimeType: string;
23
+ data: string; // base64
24
+ }
25
+
26
+ // ============================================================================
27
+ // Client → Server
28
+ // ============================================================================
29
+
30
+ export type WsCommand =
31
+ | { id?: string; type: 'prompt'; message: string; images?: ImageContent[]; streamingBehavior?: 'steer' | 'followUp' }
32
+ | { id?: string; type: 'abort' }
33
+ | { id?: string; type: 'new_session' }
34
+ | { id?: string; type: 'get_state' }
35
+ | { id?: string; type: 'set_model'; provider: string; modelId: string }
36
+ | { id?: string; type: 'get_available_models' }
37
+ | { id?: string; type: 'set_thinking_level'; level: ThinkingLevel }
38
+ | { id?: string; type: 'compact'; customInstructions?: string }
39
+ | { id?: string; type: 'get_messages' };
40
+
41
+ // ============================================================================
42
+ // Server → Client
43
+ // ============================================================================
44
+
45
+ /** A pi AgentSessionEvent forwarded verbatim */
46
+ export interface WsEvent {
47
+ type: 'event';
48
+ event: Record<string, unknown>;
49
+ }
50
+
51
+ /** Successful command response */
52
+ export interface WsResponse {
53
+ type: 'response';
54
+ id?: string;
55
+ command: string;
56
+ success: true;
57
+ data?: unknown;
58
+ }
59
+
60
+ /** Error command response */
61
+ export interface WsError {
62
+ type: 'response';
63
+ id?: string;
64
+ command: string;
65
+ success: false;
66
+ error: string;
67
+ }
68
+
69
+ export type WsServerMessage = WsEvent | WsResponse | WsError;
70
+
71
+ // ============================================================================
72
+ // Session state snapshot (returned by get_state)
73
+ // ============================================================================
74
+
75
+ export interface WsSessionState {
76
+ sessionId: string;
77
+ sessionFile?: string;
78
+ model?: { provider: string; id: string };
79
+ thinkingLevel: ThinkingLevel;
80
+ isStreaming: boolean;
81
+ isCompacting: boolean;
82
+ messageCount: number;
83
+ }
84
+
85
+ // ============================================================================
86
+ // HTTP types
87
+ // ============================================================================
88
+
89
+ /** Response from POST /sessions */
90
+ export interface CreateSessionResponse {
91
+ sessionId: string;
92
+ wsUrl: string;
93
+ }
94
+
95
+ /** Response from GET /sessions/:id */
96
+ export interface SessionInfoResponse {
97
+ sessionId: string;
98
+ createdAt: number;
99
+ isConnected: boolean;
100
+ }