@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/package.json +34 -0
- package/src/adapters/cli.ts +172 -0
- package/src/adapters/mcp.ts +677 -0
- package/src/adapters/openclaw.ts +231 -0
- package/src/crypto.ts +102 -0
- package/src/index.ts +9 -0
- package/src/permissions.ts +160 -0
- package/src/room.ts +399 -0
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
|
+
}
|