pi-repl-py 0.6.14 → 0.7.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/docs/ARCHITECTURE.md +4 -2
- package/docs/helpers.md +13 -0
- package/index.ts +40 -38
- package/package.json +1 -1
- package/scripts/setup-venv.mjs +5 -22
- package/src/engine/helpers-locate.ts +0 -1
- package/src/engine/index.ts +93 -95
- package/src/engine/kernel.ts +55 -46
- package/src/engine/session.ts +7 -16
- package/src/engine/zmtp.ts +1 -13
- package/src/extension/helpers.ts +27 -16
- package/src/extension/preview/candidates.ts +0 -4
- package/src/extension/preview/descriptor.ts +0 -1
- package/src/extension/preview/types.ts +0 -2
- package/src/extension/prompt.ts +7 -11
- package/src/extension/render-core.ts +9 -36
- package/src/extension/render.ts +1 -4
- package/src/extension/session-engine.ts +42 -40
- package/src/extension/skill-hook.ts +1 -2
- package/src/extension/state-layout.ts +30 -17
- package/src/extension/tool-meta.ts +2 -9
package/src/engine/kernel.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// --- KernelClient: one ipykernel subprocess driven directly over ZMTP (no guest middleman). ---
|
|
2
|
-
|
|
3
1
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
4
2
|
import { randomUUID } from "node:crypto";
|
|
5
3
|
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
@@ -9,6 +7,7 @@ import { resolveHelperDirs } from "./helpers-locate.js";
|
|
|
9
7
|
import {
|
|
10
8
|
type ConnectionFile,
|
|
11
9
|
executeRequest,
|
|
10
|
+
isTrustedMessage,
|
|
12
11
|
JupyterSession,
|
|
13
12
|
NAMES_MIME,
|
|
14
13
|
type ParsedMessage,
|
|
@@ -36,20 +35,18 @@ export interface CellResult {
|
|
|
36
35
|
result?: string;
|
|
37
36
|
error?: { name: string; message: string; stack: string[] };
|
|
38
37
|
status: "ok" | "error" | "aborted";
|
|
39
|
-
/** Per-channel output was capped; the host adds a truncation marker. */
|
|
40
38
|
truncated?: { stdout: boolean; stderr: boolean };
|
|
41
39
|
}
|
|
42
40
|
|
|
43
41
|
export interface CellOptions {
|
|
44
42
|
signal?: AbortSignal;
|
|
45
43
|
onStream?: (chunk: string, name: "stdout" | "stderr") => void;
|
|
46
|
-
/** Cap per-channel output accumulation. Default 1 MiB (the old guest cap). */
|
|
47
44
|
maxOutputChars?: number;
|
|
48
45
|
}
|
|
49
46
|
|
|
50
47
|
export interface SnapshotEntry {
|
|
51
48
|
name: string;
|
|
52
|
-
/** "value"
|
|
49
|
+
/** "value" = zlib-compressed pickle (v3 files; v2 are plain); "def" re-executes captured source (functions and classes). */
|
|
53
50
|
kind: "value" | "def";
|
|
54
51
|
payload: string;
|
|
55
52
|
}
|
|
@@ -60,12 +57,15 @@ export interface SnapshotReply {
|
|
|
60
57
|
complete: boolean;
|
|
61
58
|
}
|
|
62
59
|
|
|
63
|
-
|
|
60
|
+
export interface HelperLoadResult {
|
|
61
|
+
name: string;
|
|
62
|
+
ok: boolean;
|
|
63
|
+
/** First error line, e.g. "NameError: name 'x' is not defined"; undefined when ok. */
|
|
64
|
+
error?: string;
|
|
65
|
+
}
|
|
64
66
|
|
|
65
67
|
/** Read the helpers dir (same skip rules as the extension's prompt loader). */
|
|
66
|
-
/**
|
|
67
|
-
* evaluator's own cwd rather than letting spawn() die with ENOENT. A deleted project dir is
|
|
68
|
-
* a real resume case (pi guards it too) — the kernel must still come up. */
|
|
68
|
+
/** Kernel cwd falls back to the host cwd when the requested dir is gone (a deleted project is a real resume case). */
|
|
69
69
|
function resolveCwd(requested?: string): string {
|
|
70
70
|
if (requested && existsSync(requested)) return requested;
|
|
71
71
|
return process.cwd();
|
|
@@ -97,14 +97,8 @@ function buildSkipList(helperNames: string[]): string {
|
|
|
97
97
|
|
|
98
98
|
function snapshotCode(helperNames: string[], maxBytes: number): string {
|
|
99
99
|
const skip = buildSkipList(helperNames);
|
|
100
|
-
// ---
|
|
101
|
-
|
|
102
|
-
// --- functions because the code object carries the cell's filename in linecache; for
|
|
103
|
-
// --- classes inspect's module-file lookup misses, so a class is captured by locating
|
|
104
|
-
// --- its header from a member method's co_firstlineno and dedent-scanning the block.
|
|
105
|
-
// --- fallback pickles the value and reports it if that also fails; per-entry and total
|
|
106
|
-
// --- byte caps mirror the pi-codex scheme: oversized bindings become skipped names.
|
|
107
|
-
return `import pickle as _pk, base64 as _b64, json as _js, inspect as _in, linecache as _lc
|
|
100
|
+
// --- defs/classes can't be pickled by reference: capture source and re-exec on restore; oversized entries are skipped by name ---
|
|
101
|
+
return `import pickle as _pk, base64 as _b64, json as _js, zlib as _zl, inspect as _in, linecache as _lc
|
|
108
102
|
def _repl_class_source(_c):
|
|
109
103
|
_m = getattr(_c, '__init__', None)
|
|
110
104
|
if _m is None or not _in.isfunction(_m):
|
|
@@ -174,7 +168,7 @@ for _k, _v in list(globals().items()):
|
|
|
174
168
|
__repl_kind = 'value'
|
|
175
169
|
try:
|
|
176
170
|
if __repl_p is None:
|
|
177
|
-
__repl_p = _b64.b64encode(_pk.dumps(_v)).decode()
|
|
171
|
+
__repl_p = _b64.b64encode(_zl.compress(_pk.dumps(_v), 1)).decode()
|
|
178
172
|
__repl_b = len(__repl_p)
|
|
179
173
|
if __repl_b > __repl_max:
|
|
180
174
|
__repl_f.append({'name': _k, 'reason': 'exceeds per-entry snapshot cap'})
|
|
@@ -188,15 +182,14 @@ for _k, _v in list(globals().items()):
|
|
|
188
182
|
get_ipython().display_pub.publish({${JSON.stringify(SNAPSHOT_MIME)}: _js.dumps({'version': 2, 'entries': __repl_e, 'failed': __repl_f})})`;
|
|
189
183
|
}
|
|
190
184
|
|
|
191
|
-
function restoreCode(entries: SnapshotEntry[]): string {
|
|
185
|
+
function restoreCode(entries: SnapshotEntry[], compressedValues: boolean): string {
|
|
192
186
|
const per = entries
|
|
193
187
|
.map(({ name, kind, payload }) => {
|
|
194
188
|
const n = JSON.stringify(name);
|
|
195
189
|
const body =
|
|
196
190
|
kind === "def"
|
|
197
191
|
? // re-execute captured source and register it in linecache under the code
|
|
198
|
-
//
|
|
199
|
-
// exec also binds the name the source defines.
|
|
192
|
+
// --- exec also registers the source in linecache so a later snapshot can capture it again ---
|
|
200
193
|
`__repl_src = _b64.b64decode(${JSON.stringify(payload)}).decode()
|
|
201
194
|
exec(__repl_src, globals())
|
|
202
195
|
__repl_obj = globals().get(${n})
|
|
@@ -207,7 +200,9 @@ function restoreCode(entries: SnapshotEntry[]): string {
|
|
|
207
200
|
__repl_fname = getattr(getattr(__repl_init, '__code__', None), 'co_filename', None)
|
|
208
201
|
if __repl_fname:
|
|
209
202
|
_lc.cache[__repl_fname] = (len(__repl_src.splitlines()), None, __repl_src.splitlines(True), __repl_fname)`
|
|
210
|
-
:
|
|
203
|
+
: compressedValues
|
|
204
|
+
? `globals()[${n}] = _pk.loads(_zl.decompress(_b64.b64decode(${JSON.stringify(payload)})))`
|
|
205
|
+
: `globals()[${n}] = _pk.loads(_b64.b64decode(${JSON.stringify(payload)}))`;
|
|
211
206
|
return `try:
|
|
212
207
|
${body}
|
|
213
208
|
__repl_r['restored'].append(${n})
|
|
@@ -215,7 +210,7 @@ except Exception as _e:
|
|
|
215
210
|
__repl_r['failed'].append({'name': ${n}, 'reason': str(_e)})`;
|
|
216
211
|
})
|
|
217
212
|
.join("\n");
|
|
218
|
-
return `import pickle as _pk, base64 as _b64, json as _js, linecache as _lc
|
|
213
|
+
return `import pickle as _pk, base64 as _b64, json as _js, zlib as _zl, linecache as _lc
|
|
219
214
|
__repl_r = {'restored': [], 'failed': []}
|
|
220
215
|
${per}
|
|
221
216
|
get_ipython().display_pub.publish({${JSON.stringify(RESTORE_MIME)}: _js.dumps(__repl_r)})`;
|
|
@@ -241,7 +236,6 @@ interface ActiveCell {
|
|
|
241
236
|
result?: string;
|
|
242
237
|
error?: { name: string; message: string; stack: string[] };
|
|
243
238
|
status: CellResult["status"];
|
|
244
|
-
/** Private-MIME payloads published by this cell (snapshot/restore/names). */
|
|
245
239
|
payloads: Record<string, string>;
|
|
246
240
|
lastActivity: number;
|
|
247
241
|
timedOut: boolean;
|
|
@@ -252,11 +246,8 @@ interface ActiveCell {
|
|
|
252
246
|
resolve(result: CellResult & { payloads: Record<string, string> }): void;
|
|
253
247
|
reject(error: Error): void;
|
|
254
248
|
settled: boolean;
|
|
255
|
-
/** The shell execute_reply arrived (carries the authoritative status). */
|
|
256
249
|
replySeen: boolean;
|
|
257
|
-
/** The matching iopub status idle arrived (published after all output). */
|
|
258
250
|
idleSeen: boolean;
|
|
259
|
-
/** The execute_reply content, held until both halves are seen. */
|
|
260
251
|
reply?: ParsedMessage;
|
|
261
252
|
}
|
|
262
253
|
|
|
@@ -267,6 +258,7 @@ export class KernelClient {
|
|
|
267
258
|
private iopub?: ZmtpSocket;
|
|
268
259
|
private readonly session: JupyterSession;
|
|
269
260
|
private readonly helperSources: { name: string; source: string }[];
|
|
261
|
+
private helperBootReport: HelperLoadResult[] = [];
|
|
270
262
|
private readonly timeoutMs: number;
|
|
271
263
|
private activeCell?: ActiveCell;
|
|
272
264
|
private connectionFilePath?: string;
|
|
@@ -274,6 +266,10 @@ export class KernelClient {
|
|
|
274
266
|
/** Serializes all kernel ops: one execute at a time, snapshots between cells. */
|
|
275
267
|
private queue: Promise<unknown> = Promise.resolve();
|
|
276
268
|
private _onUnexpectedExit?: () => void;
|
|
269
|
+
get helperReport(): readonly HelperLoadResult[] {
|
|
270
|
+
return this.helperBootReport;
|
|
271
|
+
}
|
|
272
|
+
|
|
277
273
|
/** Engine hook: an unexpected kernel death (not a deliberate kill) should drop the instance. */
|
|
278
274
|
setOnUnexpectedExit(fn: () => void): void {
|
|
279
275
|
this._onUnexpectedExit = fn;
|
|
@@ -282,7 +278,7 @@ export class KernelClient {
|
|
|
282
278
|
private silenceKillTimer?: ReturnType<typeof setTimeout>;
|
|
283
279
|
private pendingReplies = new Map<
|
|
284
280
|
string,
|
|
285
|
-
{ resolve(m: ParsedMessage): void; timer?: ReturnType<typeof setTimeout
|
|
281
|
+
{ resolve(m: ParsedMessage): void; timer?: ReturnType<typeof setTimeout>; expectedType: string }
|
|
286
282
|
>();
|
|
287
283
|
|
|
288
284
|
private constructor(conn: ConnectionFile, opts: KernelOptions) {
|
|
@@ -293,7 +289,6 @@ export class KernelClient {
|
|
|
293
289
|
this.timeoutMs = opts.timeoutMs ?? 0;
|
|
294
290
|
}
|
|
295
291
|
|
|
296
|
-
/** Spawn ipykernel, connect all channels, and wait until it answers. */
|
|
297
292
|
static async start(pythonPath: string, opts: KernelOptions = {}): Promise<KernelClient> {
|
|
298
293
|
const connPath = join(tmpdir(), `pi-repl-kernel-${randomUUID()}.json`);
|
|
299
294
|
const child = spawn(pythonPath, ["-m", "ipykernel", "-f", connPath, "--no-stdout"], {
|
|
@@ -325,7 +320,6 @@ export class KernelClient {
|
|
|
325
320
|
conn = readConnectionFile(connPath);
|
|
326
321
|
break;
|
|
327
322
|
} catch {
|
|
328
|
-
// --- not present yet, or mid-write: retry ---
|
|
329
323
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
330
324
|
}
|
|
331
325
|
}
|
|
@@ -334,8 +328,7 @@ export class KernelClient {
|
|
|
334
328
|
kc.child = child;
|
|
335
329
|
kc.connectionFilePath = connPath;
|
|
336
330
|
child.on("exit", () => {
|
|
337
|
-
// ---
|
|
338
|
-
// --- so isRunning reflects death and the engine never resumes a zombie process. ---
|
|
331
|
+
// --- clear child/ready on death so isRunning reflects it and the engine never resumes a zombie ---
|
|
339
332
|
kc.settleActive(new Error("kernel process exited"));
|
|
340
333
|
kc.child = undefined;
|
|
341
334
|
kc.ready = false;
|
|
@@ -351,7 +344,7 @@ export class KernelClient {
|
|
|
351
344
|
try {
|
|
352
345
|
await kc.connectChannels(conn);
|
|
353
346
|
await kc.probeReady();
|
|
354
|
-
await kc.preload();
|
|
347
|
+
kc.helperBootReport = await kc.preload();
|
|
355
348
|
kc.ready = true;
|
|
356
349
|
} catch (error) {
|
|
357
350
|
kc.kill();
|
|
@@ -384,16 +377,28 @@ export class KernelClient {
|
|
|
384
377
|
return this.waitForReply(msgId, KERNEL_READY_TIMEOUT_MS, "kernel_info_reply").then(() => {});
|
|
385
378
|
}
|
|
386
379
|
|
|
387
|
-
/**
|
|
388
|
-
private preload(): Promise<
|
|
389
|
-
|
|
390
|
-
for (const h of this.helperSources)
|
|
391
|
-
|
|
380
|
+
/** One cell per helper: a broken helper (syntax error or top-level raise) must cost itself alone, not abort the rest. */
|
|
381
|
+
private async preload(): Promise<HelperLoadResult[]> {
|
|
382
|
+
const report: HelperLoadResult[] = [];
|
|
383
|
+
for (const h of this.helperSources) {
|
|
384
|
+
const res = await this.executeCell(h.source, { maxOutputChars: DEFAULT_MAX_OUTPUT_CHARS });
|
|
385
|
+
if (res.status === "ok") {
|
|
386
|
+
report.push({ name: h.name, ok: true });
|
|
387
|
+
} else {
|
|
388
|
+
const err = res.error;
|
|
389
|
+
report.push({
|
|
390
|
+
name: h.name,
|
|
391
|
+
ok: false,
|
|
392
|
+
error: err ? `${err.name}: ${err.message}` : "failed to load",
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return report;
|
|
392
397
|
}
|
|
393
398
|
|
|
394
399
|
private onShellMessage(frames: Buffer[]): void {
|
|
395
400
|
const msg = this.session.parseMessage(frames);
|
|
396
|
-
if (!msg) return;
|
|
401
|
+
if (!isTrustedMessage(msg)) return;
|
|
397
402
|
const active = this.activeCell;
|
|
398
403
|
if (msg.msg_type === "execute_reply" && active && msg.parent.msg_id === active.msgId) {
|
|
399
404
|
// --- the shell reply races the iopub stream: record it, settle only after idle ---
|
|
@@ -407,14 +412,14 @@ export class KernelClient {
|
|
|
407
412
|
|
|
408
413
|
private onControlMessage(frames: Buffer[]): void {
|
|
409
414
|
const msg = this.session.parseMessage(frames);
|
|
410
|
-
if (!msg) return;
|
|
415
|
+
if (!isTrustedMessage(msg)) return;
|
|
411
416
|
// interrupt_reply / shutdown_reply — nothing awaits them; keep draining.
|
|
412
417
|
this.resolveReply(msg);
|
|
413
418
|
}
|
|
414
419
|
|
|
415
420
|
private onIopubMessage(frames: Buffer[]): void {
|
|
416
421
|
const msg = this.session.parseMessage(frames);
|
|
417
|
-
if (!msg) return;
|
|
422
|
+
if (!isTrustedMessage(msg)) return;
|
|
418
423
|
const active = this.activeCell;
|
|
419
424
|
if (!active || msg.parent.msg_id !== active.msgId) return;
|
|
420
425
|
active.lastActivity = Date.now();
|
|
@@ -490,13 +495,15 @@ export class KernelClient {
|
|
|
490
495
|
reject(new Error(`kernel did not answer ${expectedType} in time`));
|
|
491
496
|
}, timeoutMs);
|
|
492
497
|
timer.unref?.();
|
|
493
|
-
this.pendingReplies.set(msgId, { resolve, timer });
|
|
498
|
+
this.pendingReplies.set(msgId, { resolve, timer, expectedType });
|
|
494
499
|
});
|
|
495
500
|
}
|
|
496
501
|
|
|
497
502
|
private resolveReply(msg: ParsedMessage): void {
|
|
498
503
|
const pending = this.pendingReplies.get(msg.parent.msg_id as string);
|
|
499
504
|
if (!pending) return;
|
|
505
|
+
// --- any reply echoes the parent id; only the awaited type settles the wait, the timer stays armed for it ---
|
|
506
|
+
if (msg.msg_type !== pending.expectedType) return;
|
|
500
507
|
this.pendingReplies.delete(msg.parent.msg_id as string);
|
|
501
508
|
if (pending.timer) clearTimeout(pending.timer);
|
|
502
509
|
pending.resolve(msg);
|
|
@@ -614,8 +621,7 @@ export class KernelClient {
|
|
|
614
621
|
if (quiet >= this.timeoutMs && !active.settled) {
|
|
615
622
|
active.timedOut = true;
|
|
616
623
|
this.interrupt();
|
|
617
|
-
// --- the interrupt
|
|
618
|
-
// --- it never replies; escalate to a kill so the queue is freed, mirroring index.ts. ---
|
|
624
|
+
// --- a cell that swallows the interrupt never replies; escalate to a kill so the queue frees ---
|
|
619
625
|
this.silenceKillTimer ??= setTimeout(() => {
|
|
620
626
|
if (!active.settled) this.kill();
|
|
621
627
|
}, SILENCE_KILL_GRACE_MS);
|
|
@@ -670,10 +676,13 @@ export class KernelClient {
|
|
|
670
676
|
});
|
|
671
677
|
}
|
|
672
678
|
|
|
673
|
-
restore(
|
|
679
|
+
restore(
|
|
680
|
+
entries: SnapshotEntry[],
|
|
681
|
+
compressedValues = false,
|
|
682
|
+
): Promise<{ restored: string[]; failed: { name: string; reason: string }[] }> {
|
|
674
683
|
if (entries.length === 0) return Promise.resolve({ restored: [], failed: [] });
|
|
675
684
|
return this.enqueue(async () => {
|
|
676
|
-
const res = await this.executeCellNow(restoreCode(entries), { maxOutputChars: 8_000_000 });
|
|
685
|
+
const res = await this.executeCellNow(restoreCode(entries, compressedValues), { maxOutputChars: 8_000_000 });
|
|
677
686
|
const payload = res.payloads[RESTORE_MIME];
|
|
678
687
|
if (payload === undefined) return { restored: [], failed: [] };
|
|
679
688
|
try {
|
package/src/engine/session.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
// --- Jupyter
|
|
2
|
-
// ids are empty for a client's own channels (kernel ROUTER strips them);
|
|
3
|
-
// sig = hex(HMAC-SHA256(key, h||p||m||c)) over the exact bytes; key from the
|
|
4
|
-
// connection file. Checked against jupyter_client's session.py.
|
|
1
|
+
// --- Jupyter over ZMTP: [<IDS|MSG>] sig h p m c; sig = hex(HMAC-SHA256(key, h||p||m||c)); ids are empty for client channels ---
|
|
5
2
|
|
|
6
3
|
import { createHmac, randomUUID } from "node:crypto";
|
|
7
4
|
import { readFileSync } from "node:fs";
|
|
8
5
|
|
|
9
|
-
/** The kernel's connection file: ip/ports/key, written by ipykernel at boot. */
|
|
10
6
|
export interface ConnectionFile {
|
|
11
7
|
ip: string;
|
|
12
8
|
transport: "tcp" | "ipc";
|
|
@@ -37,7 +33,6 @@ export interface JupyterHeader {
|
|
|
37
33
|
version: string;
|
|
38
34
|
}
|
|
39
35
|
|
|
40
|
-
/** A parsed inbound message: JSON parts + whether the signature verified. */
|
|
41
36
|
export interface ParsedMessage {
|
|
42
37
|
msg_id: string;
|
|
43
38
|
msg_type: string;
|
|
@@ -52,7 +47,6 @@ function pack(obj: unknown): Buffer {
|
|
|
52
47
|
return Buffer.from(JSON.stringify(obj));
|
|
53
48
|
}
|
|
54
49
|
|
|
55
|
-
/** One client-side session: mints ids, signs and frames outbound messages. */
|
|
56
50
|
export class JupyterSession {
|
|
57
51
|
readonly sessionId: string;
|
|
58
52
|
readonly username: string;
|
|
@@ -77,7 +71,6 @@ export class JupyterSession {
|
|
|
77
71
|
return Buffer.from(hmac.digest("hex"), "ascii");
|
|
78
72
|
}
|
|
79
73
|
|
|
80
|
-
/** Build the wire frames for an outbound message: [DELIM, sig, h, p, m, c]. */
|
|
81
74
|
buildFrames(
|
|
82
75
|
msgType: string,
|
|
83
76
|
content: Record<string, unknown>,
|
|
@@ -100,7 +93,6 @@ export class JupyterSession {
|
|
|
100
93
|
return [DELIM, signature, h, p, m, c];
|
|
101
94
|
}
|
|
102
95
|
|
|
103
|
-
/** Parse an inbound multipart message (identities stripped by ZMTP); null if malformed. */
|
|
104
96
|
parseMessage(frames: Buffer[]): ParsedMessage | null {
|
|
105
97
|
// --- indexOf uses ===; frames are distinct Buffers, so match by value ---
|
|
106
98
|
const delimIdx = frames.findIndex((f) => f.equals(DELIM));
|
|
@@ -123,12 +115,7 @@ export class JupyterSession {
|
|
|
123
115
|
}
|
|
124
116
|
|
|
125
117
|
export function executeRequest(code: string, silent: boolean): Record<string, unknown> {
|
|
126
|
-
// --- store_history
|
|
127
|
-
// --- In/Out history, and that retention is NOT reclaimable from user cells (deleting Out
|
|
128
|
-
// --- entries and _/__/___ from user_ns leaves the objects alive). With history off, cells
|
|
129
|
-
// --- stop feeding that growth entirely. The contract does not depend on In/Out: results
|
|
130
|
-
// --- are published over iopub via the display hook (single-mode execution, unaffected by
|
|
131
|
-
// --- store_history) and returned in the cell's transcript. ---
|
|
118
|
+
// --- store_history off: IPython's In/Out pins every result and can't be reclaimed from cells (62MB → 400+MB); the display hook still publishes results ---
|
|
132
119
|
return {
|
|
133
120
|
code,
|
|
134
121
|
silent,
|
|
@@ -144,7 +131,11 @@ export const SNAPSHOT_MIME = "application/vnd.pi-repl.snapshot+json";
|
|
|
144
131
|
export const RESTORE_MIME = "application/vnd.pi-repl.restore+json";
|
|
145
132
|
export const NAMES_MIME = "application/vnd.pi-repl.names+json";
|
|
146
133
|
|
|
147
|
-
/**
|
|
134
|
+
/** Route-gate: drop malformed and unsigned traffic — anything that fails the kernel's HMAC is not the kernel. */
|
|
135
|
+
export function isTrustedMessage(msg: ParsedMessage | null): msg is ParsedMessage {
|
|
136
|
+
return msg !== null && msg.signatureOk;
|
|
137
|
+
}
|
|
138
|
+
|
|
148
139
|
export function readPayload(content: Record<string, unknown>, mime: string): string | null {
|
|
149
140
|
const data = content.data;
|
|
150
141
|
if (data && typeof data === "object") {
|
package/src/engine/zmtp.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
// --- ZMTP 3.0
|
|
2
|
-
// DEALER for shell/control, SUB for iopub; greeting 0xff..0x7f + READY each side.
|
|
1
|
+
// --- ZMTP 3.0 by hand (bun can't load libzmq's bindings); DEALER shell/control, SUB iopub; greeting 0xff..0x7f + READY each side ---
|
|
3
2
|
|
|
4
3
|
import { connect, type Socket } from "node:net";
|
|
5
4
|
|
|
6
5
|
const GREETING_SIGNATURE = Buffer.from([0xff, 0, 0, 0, 0, 0, 0, 0, 0x01, 0x7f]);
|
|
7
6
|
const NULL_MECHANISM = Buffer.concat([Buffer.from("NULL"), Buffer.alloc(16)]);
|
|
8
7
|
|
|
9
|
-
/** The client (non-server) half of the 64-byte ZMTP 3.0 greeting. */
|
|
10
8
|
function buildGreeting(): Buffer {
|
|
11
9
|
return Buffer.concat([
|
|
12
10
|
GREETING_SIGNATURE,
|
|
@@ -21,7 +19,6 @@ const FRAME_MORE = 0x01;
|
|
|
21
19
|
const FRAME_LONG = 0x02;
|
|
22
20
|
const GREETING_LENGTH = 64;
|
|
23
21
|
|
|
24
|
-
/** Serialize one ZMTP frame: flags byte, short/8-byte length, body. */
|
|
25
22
|
export function encodeFrame(body: Uint8Array, more: boolean): Buffer {
|
|
26
23
|
const flags = more ? FRAME_MORE : 0;
|
|
27
24
|
if (body.length <= 255) {
|
|
@@ -44,7 +41,6 @@ export class ZmtpFrameParser {
|
|
|
44
41
|
private total = 0;
|
|
45
42
|
private current: Buffer[] = []; // frames of the in-progress message
|
|
46
43
|
|
|
47
|
-
/** @returns one or more complete messages consumed from `chunk`. */
|
|
48
44
|
feed(chunk: Uint8Array): Buffer[][] {
|
|
49
45
|
if (chunk.length > 0) {
|
|
50
46
|
this.chunks.push(Buffer.from(chunk));
|
|
@@ -71,7 +67,6 @@ export class ZmtpFrameParser {
|
|
|
71
67
|
return messages;
|
|
72
68
|
}
|
|
73
69
|
|
|
74
|
-
/** The first `n` bytes across the chunk list, without consuming them. */
|
|
75
70
|
private peekBytes(n: number): Buffer {
|
|
76
71
|
if (this.chunks[0].length >= n) return this.chunks[0].subarray(0, n);
|
|
77
72
|
const parts: Buffer[] = [];
|
|
@@ -85,7 +80,6 @@ export class ZmtpFrameParser {
|
|
|
85
80
|
return Buffer.concat(parts);
|
|
86
81
|
}
|
|
87
82
|
|
|
88
|
-
/** Consume `n` bytes from the front of the chunk list. */
|
|
89
83
|
private take(n: number): Buffer {
|
|
90
84
|
const first = this.chunks[0];
|
|
91
85
|
if (first.length >= n) {
|
|
@@ -119,7 +113,6 @@ export class ZmtpFrameParser {
|
|
|
119
113
|
}
|
|
120
114
|
}
|
|
121
115
|
|
|
122
|
-
/** Socket-type string carried in the READY metadata (ZMTP "Socket-Type"). */
|
|
123
116
|
export type ZmtpSocketType = "DEALER" | "SUB";
|
|
124
117
|
|
|
125
118
|
interface ReadReady {
|
|
@@ -127,7 +120,6 @@ interface ReadReady {
|
|
|
127
120
|
reject(error: Error): void;
|
|
128
121
|
}
|
|
129
122
|
|
|
130
|
-
/** One ZMTP client connection: TCP socket + greeting/READY handshake → complete multipart messages. */
|
|
131
123
|
export class ZmtpSocket {
|
|
132
124
|
private socket?: Socket;
|
|
133
125
|
private parser = new ZmtpFrameParser();
|
|
@@ -140,7 +132,6 @@ export class ZmtpSocket {
|
|
|
140
132
|
this.socket = socket;
|
|
141
133
|
}
|
|
142
134
|
|
|
143
|
-
/** Connect to `host:port` and complete the ZMTP handshake for `socketType`. */
|
|
144
135
|
static connect(opts: { host: string; port: number; socketType: ZmtpSocketType }): Promise<ZmtpSocket> {
|
|
145
136
|
const socket = connect({ host: opts.host, port: opts.port });
|
|
146
137
|
const z = new ZmtpSocket(socket);
|
|
@@ -160,7 +151,6 @@ export class ZmtpSocket {
|
|
|
160
151
|
);
|
|
161
152
|
return;
|
|
162
153
|
}
|
|
163
|
-
// --- greeting done: announce our socket type, then await the peer's ---
|
|
164
154
|
z.send([buildReadyMetadata(opts.socketType)]);
|
|
165
155
|
}
|
|
166
156
|
}
|
|
@@ -214,14 +204,12 @@ export class ZmtpSocket {
|
|
|
214
204
|
this.onMessage?.(message);
|
|
215
205
|
}
|
|
216
206
|
|
|
217
|
-
/** Send a multipart message (DEALER) or a single subscription frame (SUB). */
|
|
218
207
|
send(frames: Uint8Array[]): void {
|
|
219
208
|
for (let i = 0; i < frames.length; i++) {
|
|
220
209
|
this.socket?.write(encodeFrame(frames[i], i < frames.length - 1));
|
|
221
210
|
}
|
|
222
211
|
}
|
|
223
212
|
|
|
224
|
-
/** SUB only: subscribe to a topic prefix (empty = all traffic). */
|
|
225
213
|
subscribe(topic: Uint8Array): void {
|
|
226
214
|
this.send([Buffer.concat([Buffer.from([0x01]), topic])]);
|
|
227
215
|
}
|
package/src/extension/helpers.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** helper_description surfaces verbatim — there is no signature parsing. */
|
|
2
2
|
|
|
3
3
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
@@ -12,13 +12,12 @@ interface HelperEntry {
|
|
|
12
12
|
description: string; // full helper_description body, "" if absent
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/** Extract `helper_description` verbatim; no signature parsing. */
|
|
16
15
|
function parseDescription(source: string): string {
|
|
17
16
|
const m = source.match(/helper_description\s*=\s*("""|''')([\s\S]*?)\1/);
|
|
18
17
|
return m ? m[2].trim() : "";
|
|
19
18
|
}
|
|
20
19
|
|
|
21
|
-
/**
|
|
20
|
+
/** First-seen name wins, so a project helper shadows a global one. */
|
|
22
21
|
function loadHelperEntries(dirs: string[]): HelperEntry[] {
|
|
23
22
|
const seen = new Set<string>();
|
|
24
23
|
const entries: HelperEntry[] = [];
|
|
@@ -41,20 +40,32 @@ function loadHelperEntries(dirs: string[]): HelperEntry[] {
|
|
|
41
40
|
return entries;
|
|
42
41
|
}
|
|
43
42
|
|
|
44
|
-
/**
|
|
43
|
+
/** One helper as the model sees it: verbatim description, or a pointer when the file lacks a triple-quoted helper_description. */
|
|
44
|
+
function formatHelperLine(entry: HelperEntry): string {
|
|
45
|
+
return entry.description
|
|
46
|
+
? entry.description.replace(/\n/g, "\n ")
|
|
47
|
+
: `${entry.name} (no helper_description — define one as a triple-quoted string in the file; inspect with print(${entry.name}.__doc__))`;
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
export function buildHelpersMap(dir?: string): string[] {
|
|
46
|
-
return loadHelperEntries([dir ?? DEFAULT_HELPERS_DIR]).map(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
return loadHelperEntries([dir ?? DEFAULT_HELPERS_DIR]).map(formatHelperLine);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Project .pi/helpers first, global fallback, project shadows; honors the kernel's env overrides so the two sides can't diverge. */
|
|
55
|
+
export function buildHelpersMapForCwd(cwd: string, globalDir?: string, helpersDir?: string): string[] {
|
|
56
|
+
const dirs = helpersDir ? [helpersDir] : resolveHelperDirs(cwd, globalDir);
|
|
57
|
+
return loadHelperEntries(dirs).map(formatHelperLine);
|
|
51
58
|
}
|
|
52
59
|
|
|
53
|
-
/**
|
|
54
|
-
export function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
/** System-prompt block for this session's helpers; undefined when there are none. */
|
|
61
|
+
export function buildHelpersPromptSection(cwd: string): string | undefined {
|
|
62
|
+
const map = buildHelpersMapForCwd(cwd, process.env.PI_HELPERS_GLOBAL_DIR, process.env.PI_HELPERS_DIR);
|
|
63
|
+
if (map.length === 0) return undefined;
|
|
64
|
+
const lines = [
|
|
65
|
+
"Preloaded helpers, use them as any loaded function or variable:",
|
|
66
|
+
...map.map((line) => ` - ${line.replace(/\n/g, "\n ")}`),
|
|
67
|
+
"",
|
|
68
|
+
"If output begins with <repl_helpers_failed>, those helpers failed to load at boot — do not call the listed names.",
|
|
69
|
+
];
|
|
70
|
+
return lines.join("\n");
|
|
60
71
|
}
|
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
// --- candidates: a Python-flavored file detector plus generic line scoring ---
|
|
2
|
-
|
|
3
1
|
import { descriptor } from "./descriptor.js";
|
|
4
2
|
import type { Candidate } from "./types.js";
|
|
5
3
|
|
|
6
|
-
// --- Python file effects: verb + literal path, in the idioms this evaluator runs ---
|
|
7
|
-
|
|
8
4
|
const PY_CHAINED_METHOD: ReadonlyArray<[string, string, number]> = [
|
|
9
5
|
["write_text", "write", 95],
|
|
10
6
|
["write_bytes", "write", 95],
|
package/src/extension/prompt.ts
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
|
-
// --- execute tool: the model-facing contract, shaped exactly like pi's built-in tools ---
|
|
2
|
-
// description = rich short behavior; promptSnippet = one-liner; guidelines = flat bullets.
|
|
3
|
-
|
|
4
1
|
export const executeToolDescription =
|
|
5
2
|
"Execute Python in a Jupyter notebook. Your workspace is one notebook where every cell runs in a " +
|
|
6
3
|
"shared Python environment: variables, functions, imports, classes, and data defined in one cell " +
|
|
7
|
-
"stay available
|
|
8
|
-
"
|
|
4
|
+
"stay available and reuseable for later cells for the life of the notebook. " +
|
|
5
|
+
"Output is truncated to 45K with an explicit marker ([... output truncated at 46080 chars ...]).";
|
|
9
6
|
|
|
10
|
-
export const executePromptSnippet = "Run Python
|
|
7
|
+
export const executePromptSnippet = "Run Python in a Jupyter notebook (read, write, run, search, and more)";
|
|
11
8
|
|
|
12
|
-
// --- the model-facing guidelines, flat bullets like pi's own tool contributions ---
|
|
13
9
|
export function buildPromptGuidelines(preloaded: string[]): string[] {
|
|
14
10
|
return [
|
|
15
11
|
"Write modern idiomatic Python.",
|
|
16
|
-
"Find,
|
|
17
|
-
"Make surgical, precise changes over rewrites or whole-file dumps: replace, verify, read the file back before trusting it
|
|
18
|
-
"
|
|
19
|
-
"If output begins with <repl_engine_reset>, the runtime rebuilt and the notebook was restored from
|
|
12
|
+
"Find, Filter, Fetch: narrow down the output in Python and only print the exact slice you'll ever need, be scrupulous.",
|
|
13
|
+
"Make surgical, precise, atomic changes over rewrites or whole-file dumps: read the exact lines FIRST, anchor them, replace, verify, read the file back before trusting it",
|
|
14
|
+
"AMORTIZE existing variables, functions, imports, classes, and data from prior cells/namespaces over re-deriving, recomputing or reconstructing.",
|
|
15
|
+
"If output begins with <repl_engine_reset>, the runtime rebuilt and the notebook was restored from a recent snapshot; reverify surviving states before building on them.",
|
|
20
16
|
...(preloaded.length
|
|
21
17
|
? [
|
|
22
18
|
[
|