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/CHANGELOG.md +111 -0
- package/README.md +202 -0
- package/SPEC.md +469 -0
- package/package.json +52 -0
- package/src/bin.js +220 -0
- package/src/bridge.js +622 -0
- package/src/bz2.js +46 -0
- package/src/commands.js +288 -0
- package/src/config.js +393 -0
- package/src/identity.js +93 -0
- package/src/lxmf.js +380 -0
- package/src/quota.js +512 -0
- package/src/rpc.js +627 -0
- package/src/text.js +97 -0
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
|
+
}
|