pi-lxmf 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/rpc.js ADDED
@@ -0,0 +1,627 @@
1
+ /**
2
+ * @file rpc.js
3
+ *
4
+ * `PiRpcClient` spawns and supervises `pi --mode rpc`, speaking the JSONL
5
+ * RPC protocol documented in pi's `docs/rpc.md`:
6
+ *
7
+ * - Commands are JSON objects written to the child's stdin, one per line.
8
+ * - Responses (`type: "response"`) are correlated by `id`.
9
+ * - All other output records are agent/runtime events, forwarded to the
10
+ * bridge via the `"event"` EventTarget event.
11
+ *
12
+ * Framing follows the protocol's strict JSONL semantics: records are
13
+ * delimited by LF (`\n`) only, a trailing `\r` is tolerated, and Node's
14
+ * `readline` is deliberately NOT used (it also splits on U+2028/U+2029,
15
+ * which are valid inside JSON strings).
16
+ *
17
+ * The client supervises the child: an unexpected exit triggers a respawn
18
+ * (re-applying `--session` from the last known pointer) with backoff, and a
19
+ * crash loop (3 restarts within a minute) gives up with a `"dead"` event.
20
+ */
21
+
22
+ import { spawn as nodeSpawn } from "node:child_process";
23
+ import { existsSync } from "node:fs";
24
+ import { StringDecoder } from "node:string_decoder";
25
+
26
+ /** Thrown when a Pi RPC command fails (`success: false`) or times out. */
27
+ export class RpcError extends Error {
28
+ /**
29
+ * @param {string} message
30
+ * @param {string} [command]
31
+ */
32
+ constructor(message, command) {
33
+ super(command ? `${message} (${command})` : message);
34
+ this.name = "RpcError";
35
+ this.command = command;
36
+ }
37
+ }
38
+
39
+ /** Upper bound for a single stdout/stderr line (assistant payloads are large). */
40
+ const MAX_LINE_BYTES = 8 * 1024 * 1024;
41
+
42
+ /** Guard bound before a line is even parsed. */
43
+ const MAX_BUFFER_BYTES = 16 * 1024 * 1024;
44
+
45
+ /**
46
+ * Creates a strict-JSONL line reader: `push()` accepts string or Buffer
47
+ * chunks (multi-byte UTF-8 sequences may straddle chunks) and invokes
48
+ * `onLine` per complete LF-terminated record. A trailing `\r` is stripped;
49
+ * U+2028/U+2029 never split records. `flush()` emits a final unterminated
50
+ * line, if any.
51
+ *
52
+ * @param {(line: string) => void} onLine
53
+ * @returns {{push: (chunk: string|Buffer) => void, flush: () => void}}
54
+ */
55
+ export function createJsonlReader(onLine) {
56
+ const decoder = new StringDecoder("utf8");
57
+ let buffer = "";
58
+ return {
59
+ push(chunk) {
60
+ buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
61
+ if (buffer.length > MAX_BUFFER_BYTES) {
62
+ throw new Error(
63
+ `RPC line exceeded ${MAX_BUFFER_BYTES} bytes without a newline`,
64
+ );
65
+ }
66
+ let newlineAt = buffer.indexOf("\n");
67
+ while (newlineAt !== -1) {
68
+ emit(buffer.slice(0, newlineAt));
69
+ buffer = buffer.slice(newlineAt + 1);
70
+ newlineAt = buffer.indexOf("\n");
71
+ }
72
+ },
73
+ flush() {
74
+ buffer += decoder.end();
75
+ if (buffer.length > 0) emit(buffer);
76
+ buffer = "";
77
+ },
78
+ };
79
+
80
+ /** @param {string} raw */
81
+ function emit(raw) {
82
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
83
+ if (line.length > 0) onLine(line);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Extracts the concatenated text blocks of an assistant (or any) message.
89
+ *
90
+ * @param {{role?: string, content?: string|Array<{type?: string, text?: string, [key: string]: any}>}} message
91
+ * @returns {string}
92
+ */
93
+ export function assistantText(message) {
94
+ if (!message || typeof message !== "object") return "";
95
+ const content = message.content;
96
+ if (typeof content === "string") return content;
97
+ if (!Array.isArray(content)) return "";
98
+ return content
99
+ .filter(
100
+ (block) =>
101
+ block && block.type === "text" && typeof block.text === "string",
102
+ )
103
+ .map((block) => block.text)
104
+ .join("\n\n")
105
+ .trim();
106
+ }
107
+
108
+ /**
109
+ * A supervised client for `pi --mode rpc`.
110
+ *
111
+ * Emits EventTarget events:
112
+ * - `"event"` — `{ detail: event }` for every non-response Pi event.
113
+ * - `"ready"` — the child is accepting commands (initially and after restarts).
114
+ * - `"restarting"` — `{ detail: { code, signal, attempt } }` unexpected exit; respawn scheduled.
115
+ * - `"dead"` — `{ detail: { reason } }` no more respawns will be attempted.
116
+ *
117
+ * @fires PiRpcClient#event
118
+ */
119
+ export class PiRpcClient extends EventTarget {
120
+ /**
121
+ * @param {object} options
122
+ * @param {string} [options.piBin="pi"] - Pi binary to spawn.
123
+ * @param {string|null} [options.model] - `--model` pattern passed to Pi.
124
+ * @param {string} [options.cwd] - Working directory for Pi (the project).
125
+ * @param {string|null} [options.sessionPath] - Session file resumed via `--session`.
126
+ * @param {Function} [options.spawnFn] - Injectable spawn (tests). Defaults to `node:child_process` spawn.
127
+ * @param {number} [options.restartBaseDelayMs=1000] - Base backoff between restarts (tests shrink it).
128
+ * @param {(msg: string) => void} [options.log] - Diagnostic sink. Defaults to console.log.
129
+ */
130
+ constructor(options = {}) {
131
+ super();
132
+ this.piBin = options.piBin || "pi";
133
+ this.model = options.model || null;
134
+ this.cwd = options.cwd || process.cwd();
135
+ this.sessionPath = options.sessionPath || null;
136
+ this.spawnFn = options.spawnFn || nodeSpawn;
137
+ this.restartBaseDelayMs = options.restartBaseDelayMs ?? 1000;
138
+ this.log = options.log || ((msg) => console.log(msg));
139
+
140
+ /** @type {import("node:child_process").ChildProcess|null} */
141
+ this.child = null;
142
+ /** @type {import("node:stream").Writable|null} */
143
+ this.stdin = null;
144
+ /** @type {Map<string, {resolve: (e: any) => void, reject: (e: Error) => void, timer: NodeJS.Timeout}>} */
145
+ this.pending = new Map();
146
+ this.nextId = 0;
147
+ this.stopped = false;
148
+ /** @type {number[]} */
149
+ this.restartTimestamps = [];
150
+ this.ready = false;
151
+ /** @type {Promise<void>|null} */
152
+ this.firstReady = null;
153
+ }
154
+
155
+ /**
156
+ * Builds the child argv.
157
+ *
158
+ * @returns {string[]}
159
+ */
160
+ argv() {
161
+ const args = ["--mode", "rpc"];
162
+ if (this.model) args.push("--model", this.model);
163
+ if (this.sessionPath && existsSync(this.sessionPath)) {
164
+ args.push("--session", this.sessionPath);
165
+ }
166
+ return args;
167
+ }
168
+
169
+ /**
170
+ * Spawns Pi and resolves once it answers commands. Rejects if the child
171
+ * cannot be spawned or never becomes ready within `readyTimeoutMs`.
172
+ *
173
+ * @param {object} [options]
174
+ * @param {number} [options.readyTimeoutMs=30000]
175
+ * @returns {Promise<void>}
176
+ */
177
+ async start(options = {}) {
178
+ if (this.child) throw new Error("PiRpcClient already started");
179
+ const readyTimeoutMs = options.readyTimeoutMs ?? 30000;
180
+ this.firstReady = new Promise((resolve, reject) => {
181
+ this.spawnChild();
182
+ const startedAt = Date.now();
183
+ const probe = () => {
184
+ if (this.stopped) {
185
+ reject(new Error("pi-lxmf stopped before pi became ready"));
186
+ return;
187
+ }
188
+ if (!this.child) {
189
+ reject(new Error("pi exited before becoming ready"));
190
+ return;
191
+ }
192
+ this.request({ type: "get_state" }, 2000)
193
+ .then(() => this.markReady())
194
+ .then(resolve)
195
+ .catch(() => {
196
+ if (Date.now() - startedAt > readyTimeoutMs) {
197
+ this.giveUp(
198
+ `pi did not answer RPC commands within ${readyTimeoutMs} ms`,
199
+ );
200
+ reject(new RpcError(`pi not ready within ${readyTimeoutMs} ms`));
201
+ } else {
202
+ setTimeout(probe, 500);
203
+ }
204
+ });
205
+ };
206
+ setTimeout(probe, 300);
207
+ });
208
+ return this.firstReady;
209
+ }
210
+
211
+ /**
212
+ * Spawns the child process and attaches readers. Used by `start()` and by
213
+ * the restart path.
214
+ *
215
+ * @private
216
+ */
217
+ spawnChild() {
218
+ const child = this.spawnFn(this.piBin, this.argv(), {
219
+ cwd: this.cwd,
220
+ env: process.env,
221
+ stdio: ["pipe", "pipe", "pipe"],
222
+ });
223
+ this.child = child;
224
+ this.stdin = child.stdin ?? null;
225
+ this.ready = false;
226
+
227
+ const stdout = createJsonlReader((line) => this.routeLine(line, "stdout"));
228
+ child.stdout?.on("data", (/** @type {Buffer} */ chunk) => {
229
+ try {
230
+ stdout.push(chunk);
231
+ } catch (e) {
232
+ this.log(`pi-lxmf: rpc stdout framing error: ${e}`);
233
+ }
234
+ });
235
+ child.stdout?.on("end", () => stdout.flush());
236
+
237
+ const stderr = createJsonlReader((line) =>
238
+ this.log(`pi stderr: ${line.slice(0, 400)}`),
239
+ );
240
+ child.stderr?.on("data", (/** @type {Buffer} */ chunk) => {
241
+ try {
242
+ stderr.push(chunk);
243
+ } catch {
244
+ /* oversized stderr line: dropped */
245
+ }
246
+ });
247
+
248
+ child.on("error", (/** @type {Error} */ e) => {
249
+ this.log(`pi-lxmf: could not run ${this.piBin}: ${e.message}`);
250
+ this.handleExit(-1, null);
251
+ });
252
+ child.on(
253
+ "exit",
254
+ (
255
+ /** @type {number|null} */ code,
256
+ /** @type {NodeJS.Signals|null} */ signal,
257
+ ) => this.handleExit(code, signal),
258
+ );
259
+ this.log(
260
+ `pi-lxmf: spawned ${this.piBin} ${this.argv().join(" ")} (cwd ${this.cwd})`,
261
+ );
262
+ }
263
+
264
+ /**
265
+ * Routes one stdout line: responses resolve pending requests, everything
266
+ * else is forwarded as an `"event"`.
267
+ *
268
+ * @param {string} line
269
+ * @param {"stdout"|"stderr"} stream
270
+ * @private
271
+ */
272
+ routeLine(line, stream) {
273
+ if (stream !== "stdout" || line.length > MAX_LINE_BYTES) return;
274
+ /** @type {any} */
275
+ let event;
276
+ try {
277
+ event = JSON.parse(line);
278
+ } catch {
279
+ this.log(`pi-lxmf: unparseable stdout line: ${line.slice(0, 200)}`);
280
+ return;
281
+ }
282
+ if (event && event.type === "response" && typeof event.id === "string") {
283
+ const waiter = this.pending.get(event.id);
284
+ if (waiter) {
285
+ this.pending.delete(event.id);
286
+ clearTimeout(waiter.timer);
287
+ waiter.resolve(event);
288
+ }
289
+ return;
290
+ }
291
+ this.dispatchEvent(new CustomEvent("event", { detail: event }));
292
+ }
293
+
294
+ /**
295
+ * Handles child exit: fails pending requests and either stops (intentional)
296
+ * or schedules a supervised restart.
297
+ *
298
+ * @param {number|null} code
299
+ * @param {NodeJS.Signals|null} signal
300
+ * @private
301
+ */
302
+ handleExit(code, signal) {
303
+ if (!this.child) return;
304
+ this.child = null;
305
+ this.stdin = null;
306
+ const wasReady = this.ready;
307
+ this.ready = false;
308
+ const error = new RpcError(
309
+ `pi exited (code ${code ?? "?"} signal ${signal ?? "?"})`,
310
+ );
311
+ for (const [, waiter] of this.pending) {
312
+ clearTimeout(waiter.timer);
313
+ waiter.reject(error);
314
+ }
315
+ this.pending.clear();
316
+
317
+ if (this.stopped) return;
318
+
319
+ const now = Date.now();
320
+ this.restartTimestamps = this.restartTimestamps.filter(
321
+ (t) => now - t < 60000,
322
+ );
323
+ if (this.restartTimestamps.length >= 3) {
324
+ this.giveUp(
325
+ `pi crashed ${this.restartTimestamps.length} times within a minute`,
326
+ );
327
+ return;
328
+ }
329
+ const attempt = this.restartTimestamps.length + 1;
330
+ this.restartTimestamps.push(now);
331
+ const backoffMs = Math.min(
332
+ this.restartBaseDelayMs * 2 ** (attempt - 1),
333
+ this.restartBaseDelayMs * 8,
334
+ );
335
+ if (wasReady || this.firstReady === null) {
336
+ this.dispatchEvent(
337
+ new CustomEvent("restarting", { detail: { code, signal, attempt } }),
338
+ );
339
+ }
340
+ this.log(
341
+ `pi-lxmf: pi exited (code ${code} signal ${signal}); restart #${attempt} in ${backoffMs} ms`,
342
+ );
343
+ setTimeout(() => {
344
+ if (this.stopped || this.child) return;
345
+ this.spawnChild();
346
+ this.probeUntilReady();
347
+ }, backoffMs);
348
+ }
349
+
350
+ /**
351
+ * Probes until the respawned child answers, then marks it ready.
352
+ *
353
+ * @private
354
+ */
355
+ probeUntilReady() {
356
+ const step = () => {
357
+ if (this.stopped || !this.child) return;
358
+ this.request({ type: "get_state" }, 2000)
359
+ .then(() => {
360
+ this.markReady();
361
+ })
362
+ .catch(() => setTimeout(step, 500));
363
+ };
364
+ setTimeout(step, 300);
365
+ }
366
+
367
+ /**
368
+ * @private
369
+ */
370
+ markReady() {
371
+ if (this.ready) return;
372
+ this.ready = true;
373
+ this.dispatchEvent(new CustomEvent("ready"));
374
+ }
375
+
376
+ /**
377
+ * @param {string} reason
378
+ * @private
379
+ */
380
+ giveUp(reason) {
381
+ this.stopped = true;
382
+ this.log(`pi-lxmf: giving up on pi: ${reason}`);
383
+ this.dispatchEvent(new CustomEvent("dead", { detail: { reason } }));
384
+ if (this.child) {
385
+ this.child.kill("SIGKILL");
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Updates the session path used by the next spawn (`--session`).
391
+ *
392
+ * @param {string|null} path
393
+ */
394
+ setSessionPath(path) {
395
+ this.sessionPath = path;
396
+ }
397
+
398
+ /**
399
+ * Writes one JSON command as a line to pi's stdin.
400
+ *
401
+ * @param {Record<string, any>} cmd
402
+ * @private
403
+ */
404
+ write(cmd) {
405
+ if (!this.stdin) throw new RpcError("pi is not running");
406
+ this.stdin.write(`${JSON.stringify(cmd)}\n`);
407
+ }
408
+
409
+ /**
410
+ * Sends a command with a generated id and awaits the matching response.
411
+ *
412
+ * @param {Record<string, any>} cmd - Command without `id`.
413
+ * @param {number} [timeoutMs=30000]
414
+ * @returns {Promise<any>} The response event.
415
+ */
416
+ request(cmd, timeoutMs = 30000) {
417
+ const id = `r${++this.nextId}`;
418
+ return new Promise((resolve, reject) => {
419
+ const timer = setTimeout(() => {
420
+ this.pending.delete(id);
421
+ reject(new RpcError(`timed out after ${timeoutMs} ms`, cmd.type));
422
+ }, timeoutMs);
423
+ this.pending.set(id, { resolve, reject, timer });
424
+ try {
425
+ this.write({ ...cmd, id });
426
+ } catch (e) {
427
+ this.pending.delete(id);
428
+ clearTimeout(timer);
429
+ reject(e);
430
+ }
431
+ });
432
+ }
433
+
434
+ /**
435
+ * Asserts a response is successful and returns its `data`.
436
+ *
437
+ * @param {any} response
438
+ * @param {string} command
439
+ * @returns {any}
440
+ */
441
+ static dataOf(response, command) {
442
+ if (response?.success !== true) {
443
+ throw new RpcError(response?.error || "command failed", command);
444
+ }
445
+ return response.data;
446
+ }
447
+
448
+ // --- Typed command helpers ---------------------------------------------
449
+
450
+ /**
451
+ * Sends a user prompt. `streamingBehavior` ("steer"|"followUp") is
452
+ * required by pi when a run is already active. Returns the raw response
453
+ * so the caller can inspect acceptance and retry on races.
454
+ *
455
+ * @param {string} message
456
+ * @param {"steer"|"followUp"} [streamingBehavior]
457
+ * @returns {Promise<any>}
458
+ */
459
+ prompt(message, streamingBehavior) {
460
+ const cmd = /** @type {Record<string, any>} */ ({
461
+ type: "prompt",
462
+ message,
463
+ });
464
+ if (streamingBehavior) cmd.streamingBehavior = streamingBehavior;
465
+ return this.request(cmd, 30000);
466
+ }
467
+
468
+ /**
469
+ * @returns {Promise<any>} `get_state` data (model, isStreaming, sessionFile, …).
470
+ */
471
+ async getState() {
472
+ return PiRpcClient.dataOf(
473
+ await this.request({ type: "get_state" }),
474
+ "get_state",
475
+ );
476
+ }
477
+
478
+ /**
479
+ * @returns {Promise<any>} `new_session` data (`{ cancelled }`).
480
+ */
481
+ async newSession() {
482
+ return PiRpcClient.dataOf(
483
+ await this.request({ type: "new_session" }, 30000),
484
+ "new_session",
485
+ );
486
+ }
487
+
488
+ /**
489
+ * @param {string} [customInstructions]
490
+ * @returns {Promise<any>} `compact` data.
491
+ */
492
+ async compact(customInstructions) {
493
+ const cmd = /** @type {Record<string, any>} */ ({ type: "compact" });
494
+ if (customInstructions) cmd.customInstructions = customInstructions;
495
+ return PiRpcClient.dataOf(await this.request(cmd, 180000), "compact");
496
+ }
497
+
498
+ /**
499
+ * @param {string} provider
500
+ * @param {string} modelId
501
+ * @returns {Promise<any>} The new model object.
502
+ */
503
+ async setModel(provider, modelId) {
504
+ return PiRpcClient.dataOf(
505
+ await this.request({ type: "set_model", provider, modelId }, 30000),
506
+ "set_model",
507
+ );
508
+ }
509
+
510
+ /**
511
+ * @returns {Promise<any[]>} Available model objects.
512
+ */
513
+ async getAvailableModels() {
514
+ const data = PiRpcClient.dataOf(
515
+ await this.request({ type: "get_available_models" }, 30000),
516
+ "get_available_models",
517
+ );
518
+ return data?.models ?? [];
519
+ }
520
+
521
+ /**
522
+ * @returns {Promise<string[]>} Thinking levels supported by the current model.
523
+ */
524
+ async getAvailableThinkingLevels() {
525
+ const data = PiRpcClient.dataOf(
526
+ await this.request({ type: "get_available_thinking_levels" }, 30000),
527
+ "get_available_thinking_levels",
528
+ );
529
+ return data?.levels ?? ["off"];
530
+ }
531
+
532
+ /**
533
+ * @param {string} level
534
+ */
535
+ async setThinkingLevel(level) {
536
+ return PiRpcClient.dataOf(
537
+ await this.request({ type: "set_thinking_level", level }, 30000),
538
+ "set_thinking_level",
539
+ );
540
+ }
541
+
542
+ /**
543
+ * @returns {Promise<any>} `get_session_stats` data.
544
+ */
545
+ async getSessionStats() {
546
+ return PiRpcClient.dataOf(
547
+ await this.request({ type: "get_session_stats" }, 30000),
548
+ "get_session_stats",
549
+ );
550
+ }
551
+
552
+ /**
553
+ * @param {string} name
554
+ */
555
+ async setSessionName(name) {
556
+ return PiRpcClient.dataOf(
557
+ await this.request({ type: "set_session_name", name }, 30000),
558
+ "set_session_name",
559
+ );
560
+ }
561
+
562
+ /**
563
+ * @returns {Promise<any[]>} Commands invokable via `prompt` (extensions, templates, skills).
564
+ */
565
+ async getCommands() {
566
+ const data = PiRpcClient.dataOf(
567
+ await this.request({ type: "get_commands" }, 30000),
568
+ "get_commands",
569
+ );
570
+ return data?.commands ?? [];
571
+ }
572
+
573
+ /**
574
+ * Removes queued steering/follow-up messages. Returns the raw response so
575
+ * callers can tolerate older pi builds without `clear_queue`.
576
+ *
577
+ * @returns {Promise<any>}
578
+ */
579
+ clearQueue() {
580
+ return this.request({ type: "clear_queue" }, 5000);
581
+ }
582
+
583
+ /**
584
+ * Fire-and-forget abort of the current run.
585
+ */
586
+ abort() {
587
+ try {
588
+ this.write({ type: "abort" });
589
+ } catch {
590
+ /* pi not running: nothing to abort */
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Answers an `extension_ui_request`. With `cancelled: true` the dialog is
596
+ * declined (nobody is at a TUI on a headless bridge).
597
+ *
598
+ * @param {string} id
599
+ * @param {Record<string, any>} payload
600
+ */
601
+ respondUi(id, payload) {
602
+ try {
603
+ this.write({ type: "extension_ui_response", id, ...payload });
604
+ } catch {
605
+ /* pi not running */
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Intentional shutdown: no restart will be scheduled.
611
+ */
612
+ stop() {
613
+ if (this.stopped) return;
614
+ this.stopped = true;
615
+ if (this.child) {
616
+ this.child.kill("SIGINT");
617
+ const child = this.child;
618
+ setTimeout(() => {
619
+ try {
620
+ child.kill("SIGKILL");
621
+ } catch {
622
+ /* already gone */
623
+ }
624
+ }, 3000).unref();
625
+ }
626
+ }
627
+ }