@vibevibes/runtime 0.2.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,324 @@
1
+ /**
2
+ * Protocol experience support.
3
+ *
4
+ * Loads experiences defined as manifest.json + subprocess tool handler.
5
+ * The subprocess communicates via newline-delimited JSON over stdin/stdout.
6
+ */
7
+
8
+ import { spawn, type ChildProcess } from "child_process";
9
+ import * as path from "path";
10
+ import * as fs from "fs";
11
+ import type { ExperienceModule, ToolDef, ToolCtx, ToolEvent } from "@vibevibes/sdk";
12
+
13
+ // ── Manifest types ──────────────────────────────────────────────────────
14
+
15
+ export interface ProtocolManifest {
16
+ id: string;
17
+ version: string;
18
+ title: string;
19
+ description?: string;
20
+ initialState?: Record<string, any>;
21
+ tools: ProtocolToolDef[];
22
+ toolProcess: { command: string; args?: string[] };
23
+ agents?: Array<{
24
+ role: string;
25
+ systemPrompt: string;
26
+ allowedTools?: string[];
27
+ autoSpawn?: boolean;
28
+ maxInstances?: number;
29
+ }>;
30
+ observe?: { exclude?: string[]; include?: string[] };
31
+ canvas?: string;
32
+ netcode?: "default" | "tick";
33
+ tickRateMs?: number;
34
+ streams?: Array<{
35
+ name: string;
36
+ input_schema: Record<string, any>;
37
+ rateLimit?: number;
38
+ }>;
39
+ roomConfig?: {
40
+ schema: Record<string, any>;
41
+ defaults?: Record<string, any>;
42
+ presets?: Record<string, Record<string, any>>;
43
+ description?: string;
44
+ };
45
+ }
46
+
47
+ interface ProtocolToolDef {
48
+ name: string;
49
+ description: string;
50
+ input_schema: Record<string, any>;
51
+ risk?: "low" | "medium" | "high";
52
+ }
53
+
54
+ // ── JSON-RPC request/response ───────────────────────────────────────────
55
+
56
+ interface RpcRequest {
57
+ id: string;
58
+ method: "tool" | "observe" | "stream" | "init" | "ping";
59
+ params: Record<string, any>;
60
+ }
61
+
62
+ interface RpcResponse {
63
+ id: string;
64
+ result?: Record<string, any>;
65
+ error?: { message: string; code?: string };
66
+ }
67
+
68
+ // ── Subprocess executor ─────────────────────────────────────────────────
69
+
70
+ export class SubprocessExecutor {
71
+ private proc: ChildProcess | null = null;
72
+ private pending = new Map<string, {
73
+ resolve: (res: RpcResponse) => void;
74
+ reject: (err: Error) => void;
75
+ timer: ReturnType<typeof setTimeout>;
76
+ }>();
77
+ private buffer = "";
78
+ private seq = 0;
79
+ private command: string;
80
+ private args: string[];
81
+ private cwd: string;
82
+ private restarting = false;
83
+
84
+ constructor(command: string, args: string[], cwd: string) {
85
+ this.command = command;
86
+ this.args = args;
87
+ this.cwd = cwd;
88
+ }
89
+
90
+ start(): void {
91
+ if (this.proc) return;
92
+
93
+ this.proc = spawn(this.command, this.args, {
94
+ cwd: this.cwd,
95
+ stdio: ["pipe", "pipe", "pipe"],
96
+ env: { ...process.env },
97
+ });
98
+
99
+ this.proc.stdout!.on("data", (chunk: Buffer) => {
100
+ this.buffer += chunk.toString();
101
+ this.processBuffer();
102
+ });
103
+
104
+ this.proc.stderr!.on("data", (chunk: Buffer) => {
105
+ const msg = chunk.toString().trim();
106
+ if (msg) console.error(`[protocol:${this.command}] ${msg}`);
107
+ });
108
+
109
+ this.proc.on("exit", (code) => {
110
+ console.warn(`[protocol] Tool process exited with code ${code}`);
111
+ this.proc = null;
112
+ // Reject all pending requests
113
+ for (const [id, p] of this.pending) {
114
+ p.reject(new Error(`Tool process exited (code ${code})`));
115
+ clearTimeout(p.timer);
116
+ }
117
+ this.pending.clear();
118
+ this.buffer = "";
119
+
120
+ // Auto-restart after a short delay
121
+ if (!this.restarting) {
122
+ this.restarting = true;
123
+ setTimeout(() => {
124
+ this.restarting = false;
125
+ try { this.start(); } catch {}
126
+ }, 1000);
127
+ }
128
+ });
129
+
130
+ this.proc.on("error", (err) => {
131
+ console.error(`[protocol] Failed to spawn tool process: ${err.message}`);
132
+ });
133
+ }
134
+
135
+ stop(): void {
136
+ this.restarting = true; // Prevent auto-restart
137
+ if (this.proc) {
138
+ this.proc.kill();
139
+ this.proc = null;
140
+ }
141
+ for (const [, p] of this.pending) {
142
+ p.reject(new Error("Executor stopped"));
143
+ clearTimeout(p.timer);
144
+ }
145
+ this.pending.clear();
146
+ }
147
+
148
+ private processBuffer(): void {
149
+ const lines = this.buffer.split("\n");
150
+ this.buffer = lines.pop() || "";
151
+
152
+ for (const line of lines) {
153
+ const trimmed = line.trim();
154
+ if (!trimmed) continue;
155
+ try {
156
+ const response: RpcResponse = JSON.parse(trimmed);
157
+ const pending = this.pending.get(response.id);
158
+ if (pending) {
159
+ clearTimeout(pending.timer);
160
+ this.pending.delete(response.id);
161
+ pending.resolve(response);
162
+ }
163
+ } catch {
164
+ console.warn(`[protocol] Invalid JSON from tool process: ${trimmed.slice(0, 200)}`);
165
+ }
166
+ }
167
+ }
168
+
169
+ async send(method: RpcRequest["method"], params: Record<string, any>, timeoutMs = 10000): Promise<RpcResponse> {
170
+ if (!this.proc?.stdin?.writable) {
171
+ throw new Error("Tool process not running");
172
+ }
173
+
174
+ const id = `req-${++this.seq}`;
175
+ const request: RpcRequest = { id, method, params };
176
+
177
+ return new Promise<RpcResponse>((resolve, reject) => {
178
+ const timer = setTimeout(() => {
179
+ this.pending.delete(id);
180
+ reject(new Error(`Tool process timeout after ${timeoutMs}ms`));
181
+ }, timeoutMs);
182
+
183
+ this.pending.set(id, { resolve, reject, timer });
184
+
185
+ try {
186
+ this.proc!.stdin!.write(JSON.stringify(request) + "\n");
187
+ } catch (err) {
188
+ clearTimeout(timer);
189
+ this.pending.delete(id);
190
+ reject(err instanceof Error ? err : new Error(String(err)));
191
+ }
192
+ });
193
+ }
194
+
195
+ get running(): boolean {
196
+ return this.proc !== null && !this.proc.killed;
197
+ }
198
+ }
199
+
200
+ // ── Load a protocol experience ──────────────────────────────────────────
201
+
202
+ export function isProtocolExperience(entryPath: string): boolean {
203
+ return entryPath.endsWith("manifest.json");
204
+ }
205
+
206
+ export function loadProtocolManifest(manifestPath: string): ProtocolManifest {
207
+ const raw = fs.readFileSync(manifestPath, "utf-8");
208
+ const manifest: ProtocolManifest = JSON.parse(raw);
209
+
210
+ if (!manifest.id) throw new Error("manifest.json: id is required");
211
+ if (!manifest.version) throw new Error("manifest.json: version is required");
212
+ if (!manifest.title) throw new Error("manifest.json: title is required");
213
+ if (!manifest.toolProcess) throw new Error("manifest.json: toolProcess is required");
214
+ if (!Array.isArray(manifest.tools)) throw new Error("manifest.json: tools must be an array");
215
+
216
+ return manifest;
217
+ }
218
+
219
+ /**
220
+ * Create a synthetic ExperienceModule from a protocol manifest.
221
+ * Tool handlers proxy calls to the subprocess executor.
222
+ */
223
+ export function createProtocolModule(
224
+ manifest: ProtocolManifest,
225
+ executor: SubprocessExecutor,
226
+ manifestDir: string,
227
+ ): ExperienceModule & { initialState?: Record<string, any>; _executor: SubprocessExecutor; _canvasPath?: string } {
228
+
229
+ // Create tool defs with handlers that proxy to the subprocess
230
+ const tools: ToolDef[] = manifest.tools.map((t) => ({
231
+ name: t.name,
232
+ description: t.description,
233
+ // Use a plain object as input_schema with a parse method for compatibility
234
+ input_schema: createJsonSchemaValidator(t.input_schema),
235
+ risk: (t.risk || "low") as "low" | "medium" | "high",
236
+ capabilities_required: ["state.write"],
237
+ handler: async (ctx: ToolCtx, input: Record<string, any>) => {
238
+ const response = await executor.send("tool", {
239
+ tool: t.name,
240
+ input,
241
+ state: ctx.state,
242
+ actorId: ctx.actorId,
243
+ roomId: ctx.roomId,
244
+ timestamp: ctx.timestamp,
245
+ });
246
+
247
+ if (response.error) {
248
+ throw new Error(response.error.message);
249
+ }
250
+
251
+ if (response.result?.state) {
252
+ ctx.setState(response.result.state);
253
+ }
254
+
255
+ return response.result?.output ?? {};
256
+ },
257
+ }));
258
+
259
+ // Build observe function if manifest specifies observation config
260
+ let observe: ExperienceModule["observe"] | undefined;
261
+ if (manifest.observe) {
262
+ const { exclude, include } = manifest.observe;
263
+ observe = (state: Record<string, any>, _event: ToolEvent | null, _actorId: string) => {
264
+ if (include) {
265
+ const filtered: Record<string, any> = {};
266
+ for (const key of include) {
267
+ if (key in state) filtered[key] = state[key];
268
+ }
269
+ return filtered;
270
+ }
271
+ if (exclude) {
272
+ const filtered = { ...state };
273
+ for (const key of exclude) {
274
+ delete filtered[key];
275
+ }
276
+ return filtered;
277
+ }
278
+ return state;
279
+ };
280
+ }
281
+
282
+ // Resolve canvas path
283
+ const canvasFile = manifest.canvas || "index.html";
284
+ const canvasPath = path.resolve(manifestDir, canvasFile);
285
+ const hasCanvas = fs.existsSync(canvasPath);
286
+
287
+ return {
288
+ manifest: {
289
+ id: manifest.id,
290
+ version: manifest.version,
291
+ title: manifest.title,
292
+ description: manifest.description || "",
293
+ requested_capabilities: ["state.write"],
294
+ agentSlots: manifest.agents,
295
+ participantSlots: manifest.agents?.map((a) => ({ ...a, type: "ai" as const })),
296
+ netcode: manifest.netcode,
297
+ tickRateMs: manifest.tickRateMs,
298
+ },
299
+ Canvas: (() => null) as any, // Protocol experiences use HTML canvas, not React
300
+ tools,
301
+ observe,
302
+ initialState: manifest.initialState ?? {},
303
+ _executor: executor,
304
+ _canvasPath: hasCanvas ? canvasPath : undefined,
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Create a minimal Zod-compatible validator from a JSON Schema.
310
+ * Only needs .parse() for the server's input validation.
311
+ */
312
+ function createJsonSchemaValidator(schema: Record<string, any>): any {
313
+ return {
314
+ parse: (input: unknown) => {
315
+ // Basic type validation for protocol experiences
316
+ if (schema.type === "object" && typeof input !== "object") {
317
+ throw new Error("Expected object input");
318
+ }
319
+ return input;
320
+ },
321
+ // Expose the raw JSON Schema for tool listing
322
+ _jsonSchema: schema,
323
+ };
324
+ }