@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.
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Kodama } from "../room.ts";
4
+ import type { RoomMessage } from "@kodama-run/shared";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Types for OpenClaw skill interface
8
+ // ---------------------------------------------------------------------------
9
+
10
+ interface SkillContext {
11
+ /** OpenClaw provides this — agent context, memory, etc. */
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ interface KodamaSkillOptions {
16
+ relayUrl?: string;
17
+ agentName?: string;
18
+ }
19
+
20
+ type ActionParams =
21
+ | { action: "create"; relay_url?: string; password?: string }
22
+ | { action: "join"; code: string; name?: string; relay_url?: string }
23
+ | { action: "respond"; content: string }
24
+ | { action: "leave" };
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // State machine: idle → joined → in_turn → idle
28
+ // ---------------------------------------------------------------------------
29
+
30
+ type SkillState = "idle" | "joined" | "in_turn";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Factory
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export function createKodamaSkill(options?: KodamaSkillOptions) {
37
+ const defaultRelay = options?.relayUrl ?? "http://localhost:8000";
38
+ const defaultName = options?.agentName ?? "OpenClaw Agent";
39
+
40
+ let agent: Kodama | null = null;
41
+ let state: SkillState = "idle";
42
+ let turnResolve: ((messages: RoomMessage[]) => void) | null = null;
43
+ let respondResolve: ((messages: RoomMessage[]) => void) | null = null;
44
+
45
+ return {
46
+ name: "kodama",
47
+ description:
48
+ "Join a Kodama negotiation room to converse with other agents. " +
49
+ "Actions: create, join, respond, leave.",
50
+
51
+ parameters: {
52
+ type: "object",
53
+ required: ["action"],
54
+ properties: {
55
+ action: {
56
+ type: "string",
57
+ enum: ["create", "join", "respond", "leave"],
58
+ description: "The operation to perform.",
59
+ },
60
+ code: { type: "string", description: "Room code (for join)." },
61
+ name: { type: "string", description: "Agent display name (for join)." },
62
+ relay_url: { type: "string", description: "Relay URL override." },
63
+ password: { type: "string", description: "Room password (for create/join)." },
64
+ content: { type: "string", description: "Message content (for respond)." },
65
+ },
66
+ },
67
+
68
+ async execute(params: ActionParams, _context?: SkillContext) {
69
+ switch (params.action) {
70
+ // -----------------------------------------------------------------
71
+ // CREATE — call the relay HTTP API to create a new room
72
+ // -----------------------------------------------------------------
73
+ case "create": {
74
+ const relay = params.relay_url ?? defaultRelay;
75
+ const body: Record<string, unknown> = {};
76
+ if (params.password) body.password = params.password;
77
+
78
+ const res = await fetch(`${relay}/api/rooms`, {
79
+ method: "POST",
80
+ headers: { "Content-Type": "application/json" },
81
+ body: JSON.stringify(body),
82
+ });
83
+
84
+ if (!res.ok) {
85
+ const text = await res.text();
86
+ throw new Error(`Failed to create room (HTTP ${res.status}): ${text}`);
87
+ }
88
+
89
+ const data = (await res.json()) as { code: string };
90
+ const wsBase = relay
91
+ .replace(/^http:\/\//, "ws://")
92
+ .replace(/^https:\/\//, "wss://");
93
+
94
+ return {
95
+ code: data.code,
96
+ relay_ws: `${wsBase}/ws`,
97
+ password: params.password ?? null,
98
+ };
99
+ }
100
+
101
+ // -----------------------------------------------------------------
102
+ // JOIN — connect to a room and wait for the first turn
103
+ // -----------------------------------------------------------------
104
+ case "join": {
105
+ if (state !== "idle") {
106
+ throw new Error(`Cannot join: already in state "${state}". Leave first.`);
107
+ }
108
+
109
+ const relay = params.relay_url ?? defaultRelay;
110
+ const wsUrl = relay.startsWith("ws")
111
+ ? relay
112
+ : relay.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://") + "/ws";
113
+
114
+ agent = new Kodama(params.code, {
115
+ relayUrl: wsUrl,
116
+ name: params.name ?? defaultName,
117
+ });
118
+
119
+ agent.on("error", (err) => {
120
+ // Surface errors to any pending promise
121
+ turnResolve = null;
122
+ respondResolve = null;
123
+ throw err;
124
+ });
125
+
126
+ agent.on("turn", (messages) => {
127
+ state = "in_turn";
128
+ // If we're waiting for a turn (from join or respond), resolve it
129
+ if (turnResolve) {
130
+ turnResolve(messages);
131
+ turnResolve = null;
132
+ }
133
+ if (respondResolve) {
134
+ respondResolve(messages);
135
+ respondResolve = null;
136
+ }
137
+
138
+ // Return a promise that blocks until the agent calls respond()
139
+ return new Promise<string>((resolve) => {
140
+ // Stash resolve so respond() can call it
141
+ (agent as any).__respondResolve = resolve;
142
+ });
143
+ });
144
+
145
+ await agent.join();
146
+ state = "joined";
147
+
148
+ // Wait for the first turn signal
149
+ const messages = await new Promise<RoomMessage[]>((resolve) => {
150
+ turnResolve = resolve;
151
+ });
152
+
153
+ return {
154
+ status: "joined",
155
+ room: params.code,
156
+ messages: messages.map((m) => ({
157
+ agent: m.agent.name,
158
+ content: m.content,
159
+ round: m.round,
160
+ turn: m.turn,
161
+ })),
162
+ };
163
+ }
164
+
165
+ // -----------------------------------------------------------------
166
+ // RESPOND — send a message and wait for the next turn (or completion)
167
+ // -----------------------------------------------------------------
168
+ case "respond": {
169
+ if (state !== "in_turn" || !agent) {
170
+ throw new Error(`Cannot respond: not in a turn (state="${state}").`);
171
+ }
172
+
173
+ const resolve = (agent as any).__respondResolve as
174
+ | ((v: string) => void)
175
+ | undefined;
176
+ if (!resolve) {
177
+ throw new Error("No pending turn to respond to.");
178
+ }
179
+
180
+ // Resolve the turn handler promise so the SDK sends the message
181
+ resolve(params.content);
182
+ (agent as any).__respondResolve = null;
183
+ state = "joined";
184
+
185
+ // Wait for the next turn or room completion
186
+ const room = agent.getRoom();
187
+ if (room?.status === "completed") {
188
+ return { status: "completed" };
189
+ }
190
+
191
+ const messages = await new Promise<RoomMessage[]>((resolve) => {
192
+ turnResolve = resolve;
193
+ });
194
+
195
+ return {
196
+ status: "your_turn",
197
+ messages: messages.map((m) => ({
198
+ agent: m.agent.name,
199
+ content: m.content,
200
+ round: m.round,
201
+ turn: m.turn,
202
+ })),
203
+ };
204
+ }
205
+
206
+ // -----------------------------------------------------------------
207
+ // LEAVE — disconnect from the room
208
+ // -----------------------------------------------------------------
209
+ case "leave": {
210
+ if (agent) {
211
+ agent.leave();
212
+ agent = null;
213
+ }
214
+ state = "idle";
215
+ turnResolve = null;
216
+ respondResolve = null;
217
+ return { status: "left" };
218
+ }
219
+
220
+ default:
221
+ throw new Error(`Unknown action: ${(params as any).action}`);
222
+ }
223
+ },
224
+ };
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Default skill instance
229
+ // ---------------------------------------------------------------------------
230
+
231
+ export const kodamaSkill = createKodamaSkill();
package/src/crypto.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * AES-256-GCM encryption via Web Crypto API (Excalidraw pattern).
3
+ *
4
+ * Keys are exported/imported as base64 strings for easy sharing
5
+ * (e.g. via URL fragment).
6
+ */
7
+
8
+ const ALGO = "AES-GCM";
9
+ const KEY_LENGTH = 256;
10
+ const IV_LENGTH = 12; // 96-bit IV recommended for GCM
11
+
12
+ function toBase64(buffer: ArrayBuffer): string {
13
+ const bytes = new Uint8Array(buffer);
14
+ let binary = "";
15
+ for (let i = 0; i < bytes.length; i++) {
16
+ binary += String.fromCharCode(bytes[i]);
17
+ }
18
+ return btoa(binary);
19
+ }
20
+
21
+ function fromBase64(b64: string): Uint8Array {
22
+ const binary = atob(b64);
23
+ const bytes = new Uint8Array(binary.length);
24
+ for (let i = 0; i < binary.length; i++) {
25
+ bytes[i] = binary.charCodeAt(i);
26
+ }
27
+ return bytes;
28
+ }
29
+
30
+ export class KodamaCrypto {
31
+ /**
32
+ * Generate a new AES-256-GCM key and export as base64.
33
+ */
34
+ static async generateKey(): Promise<string> {
35
+ const key = await crypto.subtle.generateKey(
36
+ { name: ALGO, length: KEY_LENGTH },
37
+ true, // extractable
38
+ ["encrypt", "decrypt"],
39
+ );
40
+ const raw = await crypto.subtle.exportKey("raw", key);
41
+ return toBase64(raw);
42
+ }
43
+
44
+ /**
45
+ * Encrypt plaintext with AES-256-GCM.
46
+ * Returns base64-encoded ciphertext and IV.
47
+ */
48
+ static async encrypt(
49
+ keyBase64: string,
50
+ plaintext: string,
51
+ ): Promise<{ ciphertext: string; iv: string }> {
52
+ const key = await KodamaCrypto.importKey(keyBase64);
53
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
54
+ const encoded = new TextEncoder().encode(plaintext);
55
+
56
+ const cipherBuffer = await crypto.subtle.encrypt(
57
+ { name: ALGO, iv },
58
+ key,
59
+ encoded,
60
+ );
61
+
62
+ return {
63
+ ciphertext: toBase64(cipherBuffer),
64
+ iv: toBase64(iv.buffer),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Decrypt AES-256-GCM ciphertext back to plaintext.
70
+ */
71
+ static async decrypt(
72
+ keyBase64: string,
73
+ ciphertextBase64: string,
74
+ ivBase64: string,
75
+ ): Promise<string> {
76
+ const key = await KodamaCrypto.importKey(keyBase64);
77
+ const iv = fromBase64(ivBase64);
78
+ const ciphertext = fromBase64(ciphertextBase64);
79
+
80
+ const plainBuffer = await crypto.subtle.decrypt(
81
+ { name: ALGO, iv: iv as BufferSource },
82
+ key,
83
+ ciphertext as BufferSource,
84
+ );
85
+
86
+ return new TextDecoder().decode(plainBuffer);
87
+ }
88
+
89
+ /**
90
+ * Import a base64 key string as a CryptoKey.
91
+ */
92
+ private static async importKey(keyBase64: string): Promise<CryptoKey> {
93
+ const raw = fromBase64(keyBase64);
94
+ return crypto.subtle.importKey(
95
+ "raw",
96
+ raw as BufferSource,
97
+ { name: ALGO, length: KEY_LENGTH },
98
+ false,
99
+ ["encrypt", "decrypt"],
100
+ );
101
+ }
102
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export { Kodama } from "./room.ts";
2
+ export type { KodamaOptions, TurnContext } from "./room.ts";
3
+ export { KodamaCrypto } from "./crypto.ts";
4
+ export { PermissionChecker } from "./permissions.ts";
5
+ export type { PermissionConfig } from "./permissions.ts";
6
+
7
+ // MCP adapter — standalone stdio server, not a library import.
8
+ // Run directly: bun packages/sdk/src/adapters/mcp.ts
9
+ // See: ./adapters/mcp.ts
@@ -0,0 +1,160 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { AUTO_DENY_PATTERNS } from "@kodama-run/shared";
3
+
4
+ export interface PermissionConfig {
5
+ allow?: string[];
6
+ deny?: string[];
7
+ }
8
+
9
+ /**
10
+ * Convert a glob pattern to a RegExp.
11
+ * ** = any path (including slashes)
12
+ * * = any non-slash segment
13
+ * . = literal dot
14
+ */
15
+ function globToRegex(pattern: string): RegExp {
16
+ let re = "";
17
+ let i = 0;
18
+ while (i < pattern.length) {
19
+ const ch = pattern[i];
20
+ if (ch === "*" && pattern[i + 1] === "*") {
21
+ // ** matches anything including path separators
22
+ re += ".*";
23
+ i += 2;
24
+ // Skip trailing slash after ** (e.g. **/)
25
+ if (pattern[i] === "/") i++;
26
+ } else if (ch === "*") {
27
+ // * matches anything except /
28
+ re += "[^/]*";
29
+ i++;
30
+ } else if (ch === "?") {
31
+ re += "[^/]";
32
+ i++;
33
+ } else if (ch === ".") {
34
+ re += "\\.";
35
+ i++;
36
+ } else {
37
+ re += ch;
38
+ i++;
39
+ }
40
+ }
41
+ return new RegExp(`^${re}$`);
42
+ }
43
+
44
+ function matchesAny(filePath: string, patterns: string[]): boolean {
45
+ return patterns.some((pattern) => globToRegex(pattern).test(filePath));
46
+ }
47
+
48
+ /**
49
+ * Enforces .kodama permission files.
50
+ *
51
+ * - If an explicit allow list exists, a file must match allow AND not match deny.
52
+ * - If no allow list, allow everything except deny.
53
+ * - AUTO_DENY_PATTERNS are always appended to the deny list.
54
+ */
55
+ export class PermissionChecker {
56
+ private readonly allowPatterns: string[] | null;
57
+ private readonly denyPatterns: string[];
58
+
59
+ constructor(config?: PermissionConfig) {
60
+ this.allowPatterns = config?.allow?.length ? config.allow : null;
61
+ this.denyPatterns = [...(config?.deny ?? []), ...AUTO_DENY_PATTERNS];
62
+ }
63
+
64
+ /**
65
+ * Check if a file path is allowed by the permission rules.
66
+ * Tests against the basename for deny patterns that don't contain slashes,
67
+ * and against the full path for patterns that do.
68
+ */
69
+ isAllowed(filePath: string): boolean {
70
+ // Check deny list — match against full path AND basename
71
+ const basename = filePath.split("/").pop() ?? filePath;
72
+ for (const pattern of this.denyPatterns) {
73
+ // If pattern has no slash, match against basename; otherwise match full path
74
+ if (!pattern.includes("/")) {
75
+ if (globToRegex(pattern).test(basename)) return false;
76
+ } else {
77
+ if (globToRegex(pattern).test(filePath)) return false;
78
+ }
79
+ }
80
+
81
+ // If allow list exists, file must match at least one allow pattern
82
+ if (this.allowPatterns !== null) {
83
+ return matchesAny(filePath, this.allowPatterns);
84
+ }
85
+
86
+ return true;
87
+ }
88
+
89
+ /**
90
+ * Content-level message filtering (pass-through for now).
91
+ */
92
+ filterMessage(content: string): string {
93
+ return content;
94
+ }
95
+
96
+ /**
97
+ * Parse a simple YAML permission file format.
98
+ *
99
+ * Expected format:
100
+ * ```
101
+ * allow:
102
+ * - src/**
103
+ * - docs/*.md
104
+ * deny:
105
+ * - "*.log"
106
+ * - tmp/**
107
+ * ```
108
+ */
109
+ static fromYaml(content: string): PermissionChecker {
110
+ const lines = content.split("\n");
111
+ const allow: string[] = [];
112
+ const deny: string[] = [];
113
+ let currentSection: "allow" | "deny" | null = null;
114
+
115
+ for (const line of lines) {
116
+ const trimmed = line.trim();
117
+ if (trimmed === "allow:") {
118
+ currentSection = "allow";
119
+ } else if (trimmed === "deny:") {
120
+ currentSection = "deny";
121
+ } else if (trimmed.startsWith("- ") && currentSection) {
122
+ // Strip the "- " prefix and any surrounding quotes
123
+ let value = trimmed.slice(2).trim();
124
+ if (
125
+ (value.startsWith('"') && value.endsWith('"')) ||
126
+ (value.startsWith("'") && value.endsWith("'"))
127
+ ) {
128
+ value = value.slice(1, -1);
129
+ }
130
+ if (currentSection === "allow") {
131
+ allow.push(value);
132
+ } else {
133
+ deny.push(value);
134
+ }
135
+ } else if (trimmed !== "" && !trimmed.startsWith("#")) {
136
+ // Non-empty, non-comment line that isn't a list item — reset section
137
+ currentSection = null;
138
+ }
139
+ }
140
+
141
+ return new PermissionChecker({
142
+ allow: allow.length > 0 ? allow : undefined,
143
+ deny: deny.length > 0 ? deny : undefined,
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Read a .kodama permission file and return a checker.
149
+ * Returns a permissive default if the file doesn't exist.
150
+ */
151
+ static async fromFile(path: string): Promise<PermissionChecker> {
152
+ try {
153
+ const content = await readFile(path, "utf-8");
154
+ return PermissionChecker.fromYaml(content);
155
+ } catch {
156
+ // File doesn't exist or can't be read — return permissive default
157
+ return new PermissionChecker();
158
+ }
159
+ }
160
+ }