@kodama-run/sdk 0.1.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/src/room.ts ADDED
@@ -0,0 +1,399 @@
1
+ import type {
2
+ Room,
3
+ RoomMessage,
4
+ ServerMessage,
5
+ AgentCard,
6
+ RoomSummary,
7
+ } from "@kodama-run/shared";
8
+ import { createAgentCard } from "@kodama-run/shared";
9
+ import { KodamaCrypto } from "./crypto.ts";
10
+ import { PermissionChecker } from "./permissions.ts";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface TurnContext {
17
+ round: number;
18
+ turn: number;
19
+ room: Room;
20
+ }
21
+
22
+ export interface KodamaOptions {
23
+ relayUrl?: string;
24
+ name?: string;
25
+ description?: string;
26
+ password?: string;
27
+ inviteToken?: string;
28
+ permissions?: PermissionChecker;
29
+ }
30
+
31
+ type TurnHandler = (
32
+ messages: RoomMessage[],
33
+ context: TurnContext,
34
+ ) => Promise<string | { content: string; done?: boolean }> | string | { content: string; done?: boolean };
35
+ type WhisperHandler = (msg: string) => void;
36
+ type ErrorHandler = (err: Error) => void;
37
+ type PauseHandler = () => Promise<string>;
38
+ type CompletedHandler = (summary: RoomSummary) => void;
39
+ type AgentLeftHandler = (agentId: string) => void;
40
+
41
+ interface EventHandlers {
42
+ turn?: TurnHandler;
43
+ whisper?: WhisperHandler;
44
+ error?: ErrorHandler;
45
+ pause?: PauseHandler;
46
+ completed?: CompletedHandler;
47
+ agent_left?: AgentLeftHandler;
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Reconnection constants
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const INITIAL_BACKOFF_MS = 1_000;
55
+ const MAX_BACKOFF_MS = 30_000;
56
+ const MAX_RECONNECT_ATTEMPTS = 10;
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Kodama — main SDK entry point
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * The primary class users interact with. 4-line onboarding:
64
+ *
65
+ * ```ts
66
+ * const room = new Kodama('KDM-7X3K')
67
+ * room.on('turn', async (messages) => 'response')
68
+ * room.join()
69
+ * ```
70
+ */
71
+ export class Kodama {
72
+ private readonly roomCode: string;
73
+ private readonly relayUrl: string;
74
+ private readonly agentName: string;
75
+ private readonly description: string;
76
+ private readonly password?: string;
77
+ private readonly inviteToken?: string;
78
+ private readonly permissions: PermissionChecker;
79
+
80
+ private handlers: EventHandlers = {};
81
+ private ownerToken: string | null = null;
82
+ private ws: WebSocket | null = null;
83
+ private encryptionKey: string | null = null;
84
+ private agentId: string | null = null;
85
+ private roomState: Room | null = null;
86
+ private messages: RoomMessage[] = [];
87
+ private reconnectAttempts = 0;
88
+ private closed = false;
89
+
90
+ // Join promise management
91
+ private joinResolve: (() => void) | null = null;
92
+ private joinReject: ((err: Error) => void) | null = null;
93
+
94
+ constructor(roomCode: string, options?: KodamaOptions) {
95
+ this.roomCode = roomCode;
96
+ this.relayUrl = options?.relayUrl ?? "ws://localhost:8000/ws";
97
+ this.agentName = options?.name ?? "Kodama Agent";
98
+ this.description = options?.description ?? "";
99
+ this.password = options?.password;
100
+ this.inviteToken = options?.inviteToken;
101
+ this.permissions = options?.permissions ?? new PermissionChecker();
102
+ }
103
+
104
+ // -----------------------------------------------------------------------
105
+ // Public API
106
+ // -----------------------------------------------------------------------
107
+
108
+ /**
109
+ * Register an event handler. Chainable.
110
+ */
111
+ on(event: "turn", handler: TurnHandler): this;
112
+ on(event: "whisper", handler: WhisperHandler): this;
113
+ on(event: "error", handler: ErrorHandler): this;
114
+ on(event: "pause", handler: PauseHandler): this;
115
+ on(event: "completed", handler: CompletedHandler): this;
116
+ on(event: "agent_left", handler: AgentLeftHandler): this;
117
+ on(event: string, handler: (...args: any[]) => any): this {
118
+ (this.handlers as any)[event] = handler;
119
+ return this;
120
+ }
121
+
122
+ /**
123
+ * Connect to the relay and join the room.
124
+ * Resolves when the server confirms the join ("joined" event).
125
+ */
126
+ async join(): Promise<void> {
127
+ this.closed = false;
128
+ this.encryptionKey = await KodamaCrypto.generateKey();
129
+
130
+ return new Promise<void>((resolve, reject) => {
131
+ this.joinResolve = resolve;
132
+ this.joinReject = reject;
133
+ this.connect();
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Leave the room and close the WebSocket.
139
+ */
140
+ leave(): void {
141
+ this.closed = true;
142
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
143
+ this.ws.send(JSON.stringify({ action: "leave" }));
144
+ this.ws.close();
145
+ }
146
+ this.ws = null;
147
+ }
148
+
149
+ /**
150
+ * Return the session encryption key (for URL fragment sharing).
151
+ */
152
+ getEncryptionKey(): string | null {
153
+ return this.encryptionKey;
154
+ }
155
+
156
+ /**
157
+ * Return the owner token received on join (for whisper endpoints).
158
+ */
159
+ getOwnerToken(): string | null {
160
+ return this.ownerToken;
161
+ }
162
+
163
+ /**
164
+ * Return current room state.
165
+ */
166
+ getRoom(): Room | null {
167
+ return this.roomState;
168
+ }
169
+
170
+ // -----------------------------------------------------------------------
171
+ // WebSocket lifecycle
172
+ // -----------------------------------------------------------------------
173
+
174
+ private connect(): void {
175
+ const ws = new WebSocket(this.relayUrl);
176
+ this.ws = ws;
177
+
178
+ ws.addEventListener("open", () => {
179
+ this.reconnectAttempts = 0;
180
+
181
+ const card: AgentCard = {
182
+ name: this.agentName,
183
+ description: this.description || `Kodama agent: ${this.agentName}`,
184
+ skills: ["conversation"],
185
+ version: "0.1.0",
186
+ };
187
+
188
+ ws.send(
189
+ JSON.stringify({
190
+ action: "join",
191
+ roomCode: this.roomCode,
192
+ agentName: this.agentName,
193
+ password: this.password,
194
+ inviteToken: this.inviteToken,
195
+ card,
196
+ }),
197
+ );
198
+ });
199
+
200
+ ws.addEventListener("message", (event) => {
201
+ try {
202
+ const data: ServerMessage = JSON.parse(
203
+ typeof event.data === "string" ? event.data : "",
204
+ );
205
+ this.handleServerMessage(data);
206
+ } catch (err) {
207
+ this.handlers.error?.(
208
+ err instanceof Error ? err : new Error(String(err)),
209
+ );
210
+ }
211
+ });
212
+
213
+ ws.addEventListener("close", () => {
214
+ if (!this.closed) {
215
+ this.attemptReconnect();
216
+ }
217
+ });
218
+
219
+ ws.addEventListener("error", (event) => {
220
+ const err = new Error("WebSocket error");
221
+ this.handlers.error?.(err);
222
+ // If we're still joining, reject
223
+ if (this.joinReject) {
224
+ this.joinReject(err);
225
+ this.joinResolve = null;
226
+ this.joinReject = null;
227
+ }
228
+ });
229
+ }
230
+
231
+ // -----------------------------------------------------------------------
232
+ // Message handling
233
+ // -----------------------------------------------------------------------
234
+
235
+ private handleServerMessage(msg: ServerMessage): void {
236
+ switch (msg.event) {
237
+ case "joined":
238
+ this.agentId = msg.agentId;
239
+ this.ownerToken = msg.ownerToken ?? null;
240
+ // Construct a minimal Room state from the join response
241
+ this.roomState = {
242
+ code: msg.room,
243
+ config: {
244
+ maxAgents: 2,
245
+ maxRounds: 10,
246
+ turnTimeout: 90,
247
+ recording: false,
248
+ },
249
+ agents: msg.agents,
250
+ currentTurn: 0,
251
+ currentRound: 1,
252
+ status: "active",
253
+ createdAt: new Date(),
254
+ } as Room;
255
+
256
+ if (this.joinResolve) {
257
+ this.joinResolve();
258
+ this.joinResolve = null;
259
+ this.joinReject = null;
260
+ }
261
+ break;
262
+
263
+ case "message":
264
+ this.messages.push(msg.message);
265
+ break;
266
+
267
+ case "your_turn":
268
+ this.handleTurn(msg.turn, msg.round);
269
+ break;
270
+
271
+ case "error":
272
+ const error = new Error(`[${msg.code}] ${msg.message}`);
273
+ this.handlers.error?.(error);
274
+ // If still joining, reject
275
+ if (this.joinReject) {
276
+ this.joinReject(error);
277
+ this.joinResolve = null;
278
+ this.joinReject = null;
279
+ }
280
+ break;
281
+
282
+ case "room_completed":
283
+ this.handleRoomCompleted(msg.summary);
284
+ this.handlers.completed?.(msg.summary);
285
+ break;
286
+
287
+ case "agent_joined":
288
+ if (this.roomState) {
289
+ this.roomState.agents.push(msg.agent);
290
+ }
291
+ break;
292
+
293
+ case "agent_left":
294
+ if (this.roomState) {
295
+ this.roomState.agents = this.roomState.agents.filter(
296
+ (a) => a.id !== msg.agentId,
297
+ );
298
+ }
299
+ this.handlers.agent_left?.(msg.agentId);
300
+ break;
301
+
302
+ case "whisper":
303
+ this.handlers.whisper?.(msg.message);
304
+ break;
305
+
306
+ case "turn_skipped":
307
+ // Could emit an event later; for now just track
308
+ break;
309
+
310
+ case "room_deleted":
311
+ if (this.roomState) {
312
+ this.roomState.status = "expired";
313
+ }
314
+ this.closed = true;
315
+ this.ws?.close();
316
+ // Fire the agent_left handler as a signal — the room is gone
317
+ this.handlers.agent_left?.("room_deleted");
318
+ break;
319
+
320
+ case "stream_chunk":
321
+ case "thinking":
322
+ // Handled by future streaming support
323
+ break;
324
+ }
325
+ }
326
+
327
+ private async handleTurn(turn: number, round: number): Promise<void> {
328
+ if (!this.handlers.turn) return;
329
+
330
+ try {
331
+ const context: TurnContext = {
332
+ round,
333
+ turn,
334
+ room: this.roomState!,
335
+ };
336
+
337
+ const response = await this.handlers.turn(this.messages, context);
338
+
339
+ // Handle both string and object return types
340
+ const content = typeof response === "string" ? response : response.content;
341
+ const done = typeof response === "string" ? undefined : response.done;
342
+
343
+ // Filter response through permissions
344
+ const filtered = this.permissions.filterMessage(content);
345
+
346
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
347
+ this.ws.send(
348
+ JSON.stringify({
349
+ action: "message",
350
+ content: filtered,
351
+ ...(done ? { done: true } : {}),
352
+ }),
353
+ );
354
+ }
355
+ } catch (err) {
356
+ this.handlers.error?.(
357
+ err instanceof Error ? err : new Error(String(err)),
358
+ );
359
+ }
360
+ }
361
+
362
+ private handleRoomCompleted(_summary: RoomSummary): void {
363
+ if (this.roomState) {
364
+ this.roomState.status = "completed";
365
+ }
366
+ this.closed = true;
367
+ this.ws?.close();
368
+ }
369
+
370
+ // -----------------------------------------------------------------------
371
+ // Reconnection with exponential backoff + jitter
372
+ // -----------------------------------------------------------------------
373
+
374
+ private attemptReconnect(): void {
375
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
376
+ const err = new Error(
377
+ `Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`,
378
+ );
379
+ this.handlers.error?.(err);
380
+ return;
381
+ }
382
+
383
+ const base = Math.min(
384
+ INITIAL_BACKOFF_MS * 2 ** this.reconnectAttempts,
385
+ MAX_BACKOFF_MS,
386
+ );
387
+ // Add jitter: +-25%
388
+ const jitter = base * 0.25 * (Math.random() * 2 - 1);
389
+ const delay = Math.round(base + jitter);
390
+
391
+ this.reconnectAttempts++;
392
+
393
+ setTimeout(() => {
394
+ if (!this.closed) {
395
+ this.connect();
396
+ }
397
+ }, delay);
398
+ }
399
+ }