@nteract/pi 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024, runtimed
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @nteract/pi
2
+
3
+ Pi extensions for nteract.
4
+
5
+ ## Extensions
6
+
7
+ - `extensions/repl.ts` registers a persistent `python` tool backed by the local
8
+ nteract `runtimed` daemon through `@runtimed/node`.
9
+ - It also registers `python_add_dependencies`, `python_save_notebook`, and
10
+ `/python-reset`.
11
+
12
+ ## Install
13
+
14
+ Once published:
15
+
16
+ ```bash
17
+ pi install npm:@nteract/pi@next
18
+ ```
19
+
20
+ ## Local Use
21
+
22
+ From this checkout:
23
+
24
+ ```bash
25
+ pi --extension ./plugins/nteract/pi/extensions/repl.ts
26
+ ```
@@ -0,0 +1,669 @@
1
+ /**
2
+ * nteract Python REPL for pi — direct bindings edition.
3
+ *
4
+ * Uses the in-process @runtimed/node native binding to talk to
5
+ * the running runtimed daemon, with no MCP subprocess. State (imports,
6
+ * variables) persists across calls within the pi session.
7
+ *
8
+ * Config (env vars):
9
+ * NTERACT_RUNTIMED_NODE_PATH override the runtimed-node package path.
10
+ * NTERACT_SOCKET_PATH override the daemon socket path.
11
+ *
12
+ * After editing, run `/reload` in pi.
13
+ */
14
+
15
+ import type { ExtensionAPI, ImageContent, TextContent } from "@mariozechner/pi-coding-agent";
16
+ import { highlightCode } from "@mariozechner/pi-coding-agent";
17
+ import { Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
18
+ import { existsSync } from "node:fs";
19
+ import { createRequire } from "node:module";
20
+ import path from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { Type } from "typebox";
23
+
24
+ // --- runtimed-node loader ----------------------------------------------------
25
+
26
+ type RuntimedNode = {
27
+ defaultSocketPath(): string;
28
+ socketPathForChannel(channel: "stable" | "nightly"): string;
29
+ createNotebook(opts?: {
30
+ runtime?: string;
31
+ workingDir?: string;
32
+ socketPath?: string;
33
+ peerLabel?: string;
34
+ }): Promise<Session>;
35
+ openNotebook(
36
+ notebookId: string,
37
+ opts?: { socketPath?: string; peerLabel?: string },
38
+ ): Promise<Session>;
39
+ readParquetFile(
40
+ filePath: string,
41
+ offset: number,
42
+ limit: number,
43
+ ): {
44
+ columns: string[];
45
+ rows: string[][];
46
+ totalRows: number;
47
+ offset: number;
48
+ };
49
+ summarizeParquetFile?(filePath: string): {
50
+ numRows: number;
51
+ numBytes: number;
52
+ columns: Array<{ name: string; dataType: string; nullCount: number; statsJson: string }>;
53
+ };
54
+ };
55
+
56
+ type JsOutput = {
57
+ outputType: string;
58
+ name?: string;
59
+ text?: string;
60
+ dataJson?: string;
61
+ ename?: string;
62
+ evalue?: string;
63
+ traceback?: string[];
64
+ executionCount?: number;
65
+ blobUrlsJson?: string;
66
+ blobPathsJson?: string;
67
+ };
68
+
69
+ type CellResult = {
70
+ cellId: string;
71
+ executionCount?: number;
72
+ status: string; // "done" | "error" | "timeout" | "kernel_error"
73
+ success: boolean;
74
+ outputs: JsOutput[];
75
+ };
76
+
77
+ type Session = {
78
+ readonly notebookId: string;
79
+ runCell(source: string, opts?: { timeoutMs?: number; cellType?: string }): Promise<CellResult>;
80
+ addUvDependency(pkg: string): Promise<void>;
81
+ syncEnvironment(): Promise<void>;
82
+ saveNotebook(path?: string): Promise<void>;
83
+ close(): Promise<void>;
84
+ };
85
+
86
+ function loadRuntimedNode(): RuntimedNode | null {
87
+ const extensionDir = path.dirname(fileURLToPath(import.meta.url));
88
+ const candidates = [
89
+ process.env.NTERACT_RUNTIMED_NODE_PATH,
90
+ "@runtimed/node",
91
+ path.resolve(extensionDir, "../../../..", "packages", "runtimed-node", "src", "index.cjs"),
92
+ ].filter((candidate): candidate is string => Boolean(candidate));
93
+
94
+ const req = createRequire(import.meta.url);
95
+ const errors: string[] = [];
96
+ for (const candidate of candidates) {
97
+ try {
98
+ return req(candidate) as RuntimedNode;
99
+ } catch (e) {
100
+ errors.push(`${candidate}: ${e instanceof Error ? e.message : String(e)}`);
101
+ }
102
+ }
103
+
104
+ console.error("[nteract-repl] failed to load runtimed-node:\n" + errors.join("\n"));
105
+ return null;
106
+ }
107
+
108
+ function resolveSocketPath(rn: RuntimedNode): string {
109
+ if (process.env.NTERACT_SOCKET_PATH) return process.env.NTERACT_SOCKET_PATH;
110
+
111
+ for (const channel of ["nightly", "stable"] as const) {
112
+ const socketPath = rn.socketPathForChannel(channel);
113
+ if (existsSync(socketPath)) return socketPath;
114
+ }
115
+
116
+ return rn.defaultSocketPath();
117
+ }
118
+
119
+ // --- DataTable TUI component --------------------------------------------------
120
+
121
+ type Theme = {
122
+ fg: (color: string, text: string) => string;
123
+ bg: (color: string, text: string) => string;
124
+ bold: (text: string) => string;
125
+ };
126
+
127
+ type ColumnStats = {
128
+ kind: string;
129
+ min?: number;
130
+ max?: number;
131
+ distinct_count?: number;
132
+ top?: [string, number][];
133
+ true_count?: number;
134
+ false_count?: number;
135
+ };
136
+
137
+ function formatStat(stats: ColumnStats | null): string {
138
+ if (!stats) return "";
139
+ switch (stats.kind) {
140
+ case "numeric":
141
+ return `${stats.min?.toFixed(1)}..${stats.max?.toFixed(1)}`;
142
+ case "string":
143
+ return `${stats.distinct_count ?? "?"}d`;
144
+ case "boolean":
145
+ return `T:${stats.true_count ?? 0} F:${stats.false_count ?? 0}`;
146
+ default:
147
+ return "";
148
+ }
149
+ }
150
+
151
+ class DataTable {
152
+ private columns: string[];
153
+ private rows: string[][];
154
+ private totalRows: number;
155
+ private colTypes: string[];
156
+ private colStats: (ColumnStats | null)[];
157
+ private theme: Theme;
158
+ private cachedLines?: string[];
159
+ private cachedWidth?: number;
160
+
161
+ private indent: number;
162
+
163
+ constructor(
164
+ columns: string[],
165
+ rows: string[][],
166
+ totalRows: number,
167
+ colTypes: string[],
168
+ colStats: (ColumnStats | null)[],
169
+ theme: Theme,
170
+ indent: number = 0,
171
+ ) {
172
+ this.columns = columns;
173
+ this.rows = rows;
174
+ this.totalRows = totalRows;
175
+ this.colTypes = colTypes;
176
+ this.colStats = colStats;
177
+ this.theme = theme;
178
+ this.indent = indent;
179
+ }
180
+
181
+ invalidate(): void {
182
+ this.cachedLines = undefined;
183
+ this.cachedWidth = undefined;
184
+ }
185
+
186
+ render(width: number): string[] {
187
+ if (this.cachedLines && this.cachedWidth === width) {
188
+ return this.cachedLines;
189
+ }
190
+
191
+ const t = this.theme;
192
+ const numCols = this.columns.length;
193
+ const numRows = this.rows.length;
194
+
195
+ // Calculate column widths: max of header, type, stat, and data
196
+ const colWidths = this.columns.map((col, ci) => {
197
+ let w = col.length;
198
+ w = Math.max(w, (this.colTypes[ci] ?? "").length);
199
+ const statStr = formatStat(this.colStats[ci] ?? null);
200
+ w = Math.max(w, statStr.length);
201
+ for (const row of this.rows) {
202
+ w = Math.max(w, (row[ci] ?? "").length);
203
+ }
204
+ return w;
205
+ });
206
+
207
+ // Clamp total width — shrink columns proportionally if needed
208
+ const borderOverhead = 1 + numCols * 3; // "│ " + " │ " * (n-1) + " │"
209
+ const totalColWidth = colWidths.reduce((a, b) => a + b, 0);
210
+ const availableForCols = width - borderOverhead;
211
+ if (totalColWidth > availableForCols && availableForCols > numCols) {
212
+ const ratio = availableForCols / totalColWidth;
213
+ for (let i = 0; i < numCols; i++) {
214
+ colWidths[i] = Math.max(3, Math.floor(colWidths[i] * ratio));
215
+ }
216
+ }
217
+
218
+ const pad = (s: string, w: number) => {
219
+ const vw = visibleWidth(s);
220
+ return vw >= w ? truncateToWidth(s, w, "…") : s + " ".repeat(w - vw);
221
+ };
222
+
223
+ const rpad = (s: string, w: number) => {
224
+ const vw = visibleWidth(s);
225
+ return vw >= w ? truncateToWidth(s, w, "…") : " ".repeat(w - vw) + s;
226
+ };
227
+
228
+ // Detect numeric columns for right-alignment
229
+ const isNumeric = this.colTypes.map((dt) => /int|float|decimal|uint/.test(dt));
230
+
231
+ const align = (s: string, ci: number, w: number) => (isNumeric[ci] ? rpad(s, w) : pad(s, w));
232
+
233
+ const lines: string[] = [];
234
+
235
+ // ── Top border ──
236
+ const topBorder = t.fg("muted", "┌" + colWidths.map((w) => "─".repeat(w + 2)).join("┬") + "┐");
237
+ lines.push(topBorder);
238
+
239
+ // ── Column headers ──
240
+ const headerCells = this.columns.map((col, ci) =>
241
+ t.fg("accent", t.bold(pad(col, colWidths[ci]))),
242
+ );
243
+ lines.push(
244
+ t.fg("muted", "│") + " " + headerCells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│"),
245
+ );
246
+
247
+ // ── Type row ──
248
+ const typeCells = this.colTypes.map((dt, ci) => t.fg("dim", pad(dt, colWidths[ci])));
249
+ lines.push(
250
+ t.fg("muted", "│") + " " + typeCells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│"),
251
+ );
252
+
253
+ // ── Stats row (sparkline or range) ──
254
+ const statCells = this.colStats.map((stats, ci) => {
255
+ const statStr = formatStat(stats);
256
+ return t.fg("dim", pad(statStr, colWidths[ci]));
257
+ });
258
+ lines.push(
259
+ t.fg("muted", "│") + " " + statCells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│"),
260
+ );
261
+
262
+ // ── Header separator ──
263
+ const headerSep = t.fg("muted", "╞" + colWidths.map((w) => "═".repeat(w + 2)).join("╪") + "╡");
264
+ lines.push(headerSep);
265
+
266
+ // ── Data rows ──
267
+ for (let r = 0; r < numRows; r++) {
268
+ const row = this.rows[r];
269
+ const cells = row.map((v, ci) => align(v, ci, colWidths[ci]));
270
+ const rowStr =
271
+ t.fg("muted", "│") + " " + cells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│");
272
+ lines.push(rowStr);
273
+ }
274
+
275
+ // ── Bottom border ──
276
+ const botBorder = t.fg("muted", "└" + colWidths.map((w) => "─".repeat(w + 2)).join("┴") + "┘");
277
+ lines.push(botBorder);
278
+
279
+ // ── Footer info ──
280
+ const showing = numRows < this.totalRows ? `showing ${numRows} of ` : "";
281
+ const info = t.fg("dim", `${showing}${this.totalRows} rows × ${numCols} columns`);
282
+ lines.push(info);
283
+
284
+ // Indent all lines to align with Out[n]: prompt
285
+ const indent = " ".repeat(this.indent);
286
+ for (let i = 0; i < lines.length; i++) {
287
+ lines[i] = indent + lines[i];
288
+ }
289
+
290
+ this.cachedLines = lines;
291
+ this.cachedWidth = width;
292
+ return lines;
293
+ }
294
+ }
295
+
296
+ // --- output formatting -------------------------------------------------------
297
+
298
+ function stripAnsi(s: string): string {
299
+ const esc = String.fromCharCode(27);
300
+ return s.replace(new RegExp(`${esc}\\[[0-9;]*[A-Za-z]`, "g"), "");
301
+ }
302
+
303
+ function formatResult(result: CellResult): {
304
+ content: (TextContent | ImageContent)[];
305
+ isError: boolean;
306
+ } {
307
+ const isError =
308
+ result.status === "error" ||
309
+ result.status === "kernel_error" ||
310
+ result.outputs.some((o) => o.outputType === "error");
311
+
312
+ const parts: (TextContent | ImageContent)[] = [];
313
+ const header = `cell ${result.cellId} [${result.executionCount ?? "?"}] ${result.status}`;
314
+ const textChunks: string[] = [];
315
+
316
+ for (const o of result.outputs) {
317
+ switch (o.outputType) {
318
+ case "stream": {
319
+ const prefix = o.name === "stderr" ? "[stderr] " : "";
320
+ textChunks.push(prefix + (o.text ?? ""));
321
+ break;
322
+ }
323
+ case "execute_result":
324
+ case "display_data": {
325
+ if (!o.dataJson) break;
326
+ let data: Record<string, { type: string; value: unknown }>;
327
+ try {
328
+ data = JSON.parse(o.dataJson);
329
+ } catch {
330
+ break;
331
+ }
332
+ const hasImage = Object.keys(data).some(
333
+ (m) => m.startsWith("image/") && m !== "image/svg+xml",
334
+ );
335
+ // Text-ish rep for the agent. Skip generic Figure reprs when we
336
+ // also have an image — the image is more useful.
337
+ const textRep =
338
+ (data["text/llm+plain"]?.value as string | undefined) ??
339
+ (data["text/plain"]?.value as string | undefined);
340
+ if (textRep && !(hasImage && /^<Figure[^>]*>/.test(textRep.trim()))) {
341
+ textChunks.push(String(textRep));
342
+ }
343
+
344
+ // Attach images directly so the model can see them.
345
+ for (const [mime, entry] of Object.entries(data)) {
346
+ if (!mime.startsWith("image/")) continue;
347
+ if (mime === "image/svg+xml") continue; // text, not a raster image
348
+ if (entry?.type !== "binary" || typeof entry.value !== "string") continue;
349
+ // Dedupe images we've already emitted (Jupyter often sends the
350
+ // same image as both execute_result and display_data).
351
+ const dup = parts.some(
352
+ (p) =>
353
+ p.type === "image" &&
354
+ (p as ImageContent).data === entry.value &&
355
+ (p as ImageContent).mimeType === mime,
356
+ );
357
+ if (dup) continue;
358
+ parts.push({
359
+ type: "image",
360
+ mimeType: mime,
361
+ data: entry.value,
362
+ } as ImageContent);
363
+ }
364
+ break;
365
+ }
366
+ case "error": {
367
+ const tb = Array.isArray(o.traceback) ? o.traceback.join("\n") : "";
368
+ textChunks.push(tb || `${o.ename ?? "Error"}: ${o.evalue ?? ""}`);
369
+ break;
370
+ }
371
+ default:
372
+ textChunks.push(`[${o.outputType} output]`);
373
+ }
374
+ }
375
+
376
+ const body = stripAnsi(textChunks.join("").replace(/\n+$/, ""));
377
+ parts.unshift({
378
+ type: "text",
379
+ text: body ? `${header}\n${body}` : header,
380
+ });
381
+ return { content: parts, isError };
382
+ }
383
+
384
+ // --- extension ---------------------------------------------------------------
385
+
386
+ const PYTHON_PARAMS = Type.Object({
387
+ code: Type.String({
388
+ description:
389
+ "Python source to execute in the persistent notebook session. Use print(...) for side effects; the last expression's repr is returned as the result.",
390
+ }),
391
+ timeout_secs: Type.Optional(
392
+ Type.Number({
393
+ description: "Max seconds to wait for execution (default 120).",
394
+ default: 120,
395
+ }),
396
+ ),
397
+ });
398
+
399
+ export default function nteractReplExtension(pi: ExtensionAPI) {
400
+ const rn = loadRuntimedNode();
401
+ if (!rn) {
402
+ console.error("[nteract-repl] runtimed-node not loaded; python tool will not be registered.");
403
+ return;
404
+ }
405
+
406
+ let session: Session | null = null;
407
+ let opening: Promise<Session> | null = null;
408
+ let nextExecCount: number | null = 1;
409
+
410
+ async function ensureSession(): Promise<Session> {
411
+ if (session) return session;
412
+ if (opening) return opening;
413
+ opening = (async () => {
414
+ const socketPath = resolveSocketPath(rn);
415
+ session = await rn.createNotebook({
416
+ runtime: "python",
417
+ socketPath,
418
+ peerLabel: "pi",
419
+ });
420
+ return session;
421
+ })();
422
+ try {
423
+ return await opening;
424
+ } finally {
425
+ opening = null;
426
+ }
427
+ }
428
+
429
+ pi.registerTool({
430
+ name: "python",
431
+ label: "Python (nteract)",
432
+ description:
433
+ "Execute Python in a persistent nteract notebook session backed by the local runtimed daemon. State (variables, imports) persists across calls within this pi session. Stdout, the final expression value, and exceptions are returned as text. Images (matplotlib.show(), IPython.display.Image, etc.) are returned inline so you can see them.",
434
+ promptSnippet:
435
+ "python: run Python code against a persistent nteract kernel (state persists across calls; stdout + last-expr value + any images are returned).",
436
+ promptGuidelines: [
437
+ "Prefer `python` over `bash python -c ...` for anything stateful, multi-step, or data-heavy.",
438
+ "State (imports, variables) persists across `python` calls in this session.",
439
+ "Use print() for side-effect output; the last expression is echoed back as the result.",
440
+ "Matplotlib / PIL images are returned inline — you can iterate on plots by looking at them.",
441
+ "Install packages with the `python_add_dependencies` tool (it uses the notebook's UV env and hot-reloads without restarting the kernel).",
442
+ ],
443
+ parameters: PYTHON_PARAMS,
444
+ renderCall(args, theme, _context) {
445
+ const text =
446
+ (_context.lastComponent as InstanceType<typeof Text> | undefined) ?? new Text("", 0, 0);
447
+ const code = args?.code ?? "";
448
+ const count = nextExecCount;
449
+ const prompt = count != null ? `In [${count}]:` : "In [*]:";
450
+ const promptStr = theme.fg("accent", theme.bold(prompt));
451
+ const lines = highlightCode(code, "python");
452
+ // Indent continuation lines to align with first line after prompt
453
+ const pad = " ".repeat(prompt.length + 1);
454
+ const formatted = lines
455
+ .map((l, i) => (i === 0 ? `${promptStr} ${l}` : `${pad}${l}`))
456
+ .join("\n");
457
+ text.setText(formatted);
458
+ return text;
459
+ },
460
+ renderResult(result, _options, theme, _context) {
461
+ const details = (result as any).details ?? {};
462
+ const count = details.execution_count;
463
+ const isErr = details.is_error;
464
+
465
+ // Update the closure count for the next renderCall
466
+ if (count != null) {
467
+ nextExecCount = count + 1;
468
+ }
469
+
470
+ // If we have a parquet blob path, read it via napi and render as DataTable
471
+ const pqPath = details.parquet_blob_path;
472
+ if (pqPath && rn?.readParquetFile) {
473
+ try {
474
+ const page = rn.readParquetFile(pqPath, 0, 40);
475
+ if (page && page.rows.length > 0) {
476
+ // Get column types and stats from summary
477
+ let colTypes: string[] = page.columns.map(() => "");
478
+ let colStats: (ColumnStats | null)[] = page.columns.map(() => null);
479
+ if (rn.summarizeParquetFile) {
480
+ try {
481
+ const summary = rn.summarizeParquetFile(pqPath);
482
+ colTypes = summary.columns.map((c: any) => c.dataType);
483
+ colStats = summary.columns.map((c: any) => {
484
+ try {
485
+ return JSON.parse(c.statsJson);
486
+ } catch {
487
+ return null;
488
+ }
489
+ });
490
+ } catch {}
491
+ }
492
+
493
+ // Render table with Out[n]: prompt
494
+ const prompt = count != null ? `Out[${count}]:` : "Out:";
495
+ const promptStr = theme.fg("muted", prompt);
496
+ const indent = prompt.length + 1;
497
+ const dt = new DataTable(
498
+ page.columns,
499
+ page.rows,
500
+ page.totalRows,
501
+ colTypes,
502
+ colStats,
503
+ theme,
504
+ indent,
505
+ );
506
+ const text =
507
+ (_context.lastComponent instanceof Text ? _context.lastComponent : undefined) ??
508
+ new Text("", 0, 0);
509
+ const tableLines = dt.render(200);
510
+ text.setText(`${promptStr}\n${tableLines.join("\n")}`);
511
+ return text;
512
+ }
513
+ } catch {}
514
+ }
515
+
516
+ // Extract text output from content
517
+ const textContent = (result.content ?? [])
518
+ .filter((c: any) => c.type === "text")
519
+ .map((c: any) => c.text)
520
+ .join("\n");
521
+
522
+ // Strip the "cell cell-xxx [n] status" header we put in the text content
523
+ const body = textContent.replace(/^cell cell-[^\n]*\n?/, "").trim();
524
+
525
+ const text =
526
+ (_context.lastComponent instanceof Text ? _context.lastComponent : undefined) ??
527
+ new Text("", 0, 0);
528
+ if (!body) {
529
+ // No output — just show a check/cross
530
+ const icon = isErr ? theme.fg("error", "\u2717") : theme.fg("success", "\u2713");
531
+ text.setText(icon);
532
+ } else if (isErr) {
533
+ text.setText(theme.fg("error", body));
534
+ } else {
535
+ const prompt = count != null ? `Out[${count}]:` : "Out:";
536
+ const promptStr = theme.fg("muted", prompt);
537
+ const pad = " ".repeat(prompt.length + 1);
538
+ const bodyLines = body.split("\n");
539
+ const formatted = bodyLines
540
+ .map((l: string, i: number) => (i === 0 ? `${promptStr} ${l}` : `${pad}${l}`))
541
+ .join("\n");
542
+ text.setText(formatted);
543
+ }
544
+ return text;
545
+ },
546
+ async execute(_toolCallId, params, signal) {
547
+ if (signal?.aborted) throw new Error("aborted");
548
+ const sess = await ensureSession();
549
+ const timeoutSecs = Math.max(1, params.timeout_secs ?? 120);
550
+ const result = await sess.runCell(params.code, {
551
+ timeoutMs: Math.round(timeoutSecs * 1000),
552
+ });
553
+ // Extract parquet blob path for human-side table rendering
554
+ let parquetBlobPath: string | undefined;
555
+ for (const o of result.outputs) {
556
+ if (!o.blobPathsJson) continue;
557
+ try {
558
+ const paths = JSON.parse(o.blobPathsJson);
559
+ const pqPath = paths["application/vnd.apache.parquet"];
560
+ if (typeof pqPath === "string") {
561
+ parquetBlobPath = pqPath;
562
+ break;
563
+ }
564
+ } catch {}
565
+ }
566
+
567
+ const { content, isError } = formatResult(result);
568
+ return {
569
+ content,
570
+ details: {
571
+ notebook_id: sess.notebookId,
572
+ cell_id: result.cellId,
573
+ status: result.status,
574
+ execution_count: result.executionCount,
575
+ is_error: isError,
576
+ parquet_blob_path: parquetBlobPath,
577
+ },
578
+ };
579
+ },
580
+ });
581
+
582
+ pi.registerTool({
583
+ name: "python_add_dependencies",
584
+ label: "Add Dependencies (nteract)",
585
+ description:
586
+ "Add packages to the current nteract notebook's UV environment and hot-sync them so the running kernel can import them immediately. Accepts pip-style specs, e.g. 'matplotlib', 'numpy>=2', 'requests'.",
587
+ promptSnippet:
588
+ "python_add_dependencies: add packages to the persistent Python notebook env (hot-installs without restarting the kernel).",
589
+ parameters: Type.Object({
590
+ packages: Type.Array(Type.String(), {
591
+ description: "Package specs (e.g. ['matplotlib', 'pandas>=2']).",
592
+ }),
593
+ }),
594
+ async execute(_toolCallId, params, signal) {
595
+ if (signal?.aborted) throw new Error("aborted");
596
+ if (!params.packages.length) {
597
+ return { content: [{ type: "text", text: "No packages given." }], details: {} };
598
+ }
599
+ const sess = await ensureSession();
600
+ for (const pkg of params.packages) {
601
+ await sess.addUvDependency(pkg);
602
+ }
603
+ await sess.syncEnvironment();
604
+ return {
605
+ content: [
606
+ {
607
+ type: "text",
608
+ text: `Installed into ${sess.notebookId}: ${params.packages.join(", ")}`,
609
+ },
610
+ ],
611
+ details: { notebook_id: sess.notebookId, packages: params.packages },
612
+ };
613
+ },
614
+ });
615
+
616
+ pi.registerTool({
617
+ name: "python_save_notebook",
618
+ label: "Save Notebook (nteract)",
619
+ description:
620
+ "Save the current nteract notebook to an .ipynb file on disk. If no path is given, saves to the original location (if it was opened from a file). Provide a path to save to a new location.",
621
+ promptSnippet: "python_save_notebook: save the current notebook session to an .ipynb file.",
622
+ parameters: Type.Object({
623
+ path: Type.Optional(
624
+ Type.String({
625
+ description:
626
+ "File path to save to (e.g. './analysis.ipynb'). If omitted, saves to the original location.",
627
+ }),
628
+ ),
629
+ }),
630
+ async execute(_toolCallId, params, signal) {
631
+ if (signal?.aborted) throw new Error("aborted");
632
+ const sess = await ensureSession();
633
+ await sess.saveNotebook(params.path);
634
+ const where = params.path ?? "original location";
635
+ return {
636
+ content: [{ type: "text", text: `Notebook saved to ${where}` }],
637
+ details: { notebook_id: sess.notebookId, path: params.path },
638
+ };
639
+ },
640
+ });
641
+
642
+ pi.registerCommand("python-reset", {
643
+ description:
644
+ "Start a fresh nteract Python notebook session (next /python call opens a new kernel)",
645
+ handler: async (_args, ctx) => {
646
+ const old = session;
647
+ session = null;
648
+ nextExecCount = 1;
649
+ if (old) {
650
+ try {
651
+ await old.close();
652
+ } catch {}
653
+ }
654
+ ctx.ui.notify(
655
+ "nteract REPL: session closed; next python call will start a fresh kernel.",
656
+ "info",
657
+ );
658
+ },
659
+ });
660
+
661
+ pi.on("session_shutdown", async () => {
662
+ if (session) {
663
+ try {
664
+ await session.close();
665
+ } catch {}
666
+ session = null;
667
+ }
668
+ });
669
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@nteract/pi",
3
+ "version": "0.0.1",
4
+ "description": "nteract extensions for Pi",
5
+ "type": "module",
6
+ "license": "BSD-3-Clause",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/nteract/desktop.git",
10
+ "directory": "plugins/nteract/pi"
11
+ },
12
+ "keywords": [
13
+ "pi-package",
14
+ "nteract",
15
+ "notebook",
16
+ "python",
17
+ "repl"
18
+ ],
19
+ "pi": {
20
+ "extensions": [
21
+ "./extensions"
22
+ ]
23
+ },
24
+ "files": [
25
+ "README.md",
26
+ "extensions"
27
+ ],
28
+ "dependencies": {
29
+ "@runtimed/node": "0.0.1"
30
+ },
31
+ "peerDependencies": {
32
+ "@mariozechner/pi-coding-agent": "*",
33
+ "@mariozechner/pi-tui": "*",
34
+ "typebox": "*"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "pack:dry-run": "pnpm pack --dry-run"
41
+ }
42
+ }