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.
@@ -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" pickles the object; "def" re-executes captured source (functions and classes). */
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
- // --- boot preload: exec each helper; ls()/help() are gone, discovery is globals() ---
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
- /** A directory for the kernel to start in; if the requested cwd is gone, fall back to the
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
- // --- functions and classes defined in cells cannot be pickled by reference, so their
101
- // --- source is captured instead and re-executed on restore. getsource works for
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
- // object's filename so a later snapshot can capture it as source again;
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
- : `globals()[${n}] = _pk.loads(_b64.b64decode(${JSON.stringify(payload)}))`;
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
- // --- a dead kernel settles the running cell; the engine rebuilds. clear child/ready
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
- /** Exec every helper file into the kernel namespace. No custom intrinsics. */
388
- private preload(): Promise<void> {
389
- let code = "";
390
- for (const h of this.helperSources) code += `\n${h.source}\n`;
391
- return this.executeCell(code, { maxOutputChars: DEFAULT_MAX_OUTPUT_CHARS }).then(() => {});
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 is a real KeyboardInterrupt, but a cell that swallows/ignores
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(entries: SnapshotEntry[]): Promise<{ restored: string[]; failed: { name: string; reason: string }[] }> {
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 {
@@ -1,12 +1,8 @@
1
- // --- Jupyter messaging over ZMTP: [identities] <IDS|MSG> [sig, h, p, m, c] ---
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 is always false: IPython retains every last-expression result in its
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
- /** Read a private-MIME payload out of an execute_result/display_data content. */
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") {
@@ -1,12 +1,10 @@
1
- // --- ZMTP 3.0 wire protocol, by hand (bun can't load libzmq's bindings). ---
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
  }
@@ -1,4 +1,4 @@
1
- /** Loads helpers from project then global dirs; `helper_description` surfaces verbatim (no signature parsing). */
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
- /** Merge entries from ordered dirs; first-seen name wins, so a project helper shadows the global one. */
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
- /** The prompt-facing list for ONE dir: verbatim description, or an introspection pointer. */
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((t) =>
47
- t.description
48
- ? t.description.replace(/\n/g, "\n ")
49
- : `${t.name} (no description, inspect it with print(${t.name}.__doc__))`,
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
- /** The prompt-facing list at a cwd: project .pi/helpers first (up to the git root), global fallback, project shadows. */
54
- export function buildHelpersMapForCwd(cwd: string, globalDir?: string): string[] {
55
- return loadHelperEntries(resolveHelperDirs(cwd, globalDir)).map((t) =>
56
- t.description
57
- ? t.description.replace(/\n/g, "\n ")
58
- : `${t.name} (no description, inspect it with print(${t.name}.__doc__))`,
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],
@@ -1,4 +1,3 @@
1
- // --- descriptor: collapse the raw line into one readable, safe, width-capped string ---
2
1
  const DESCRIPTOR_MAX_WIDTH = 64;
3
2
 
4
3
  function collapseWhitespace(text: string): string {
@@ -1,10 +1,8 @@
1
- /** A scored preview candidate for one cell in the transcript header. */
2
1
  export interface Candidate {
3
2
  text: string;
4
3
  score: number;
5
4
  }
6
5
 
7
- /** The one-line semantic summary a collapsed cell shows. */
8
6
  export interface CellPreview {
9
7
  text: string;
10
8
  }
@@ -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 to later cells for the life of the notebook. Cells return their last expression " +
8
- "(auto-displayed) plus stdout/stderr; output is truncated to 45K with a marker.";
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 cells in a Jupyter notebook (read, write, run, search, and more)";
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, filter, fetch, sample: narrow the output in Python, then print only the window that decides the next step (a head, a shape, a slice), not the whole.",
17
- "Make surgical, precise changes over rewrites or whole-file dumps: replace, verify, read the file back before trusting it.",
18
- "Prefer to reuse existing variables, functions, imports, classes, and data from prior cells/namespaces over recomputing.",
19
- "If output begins with <repl_engine_reset>, the runtime rebuilt and the notebook was restored from the last snapshot; reverify surviving names before building on them.",
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
  [