@nteract/pi 0.1.2 → 0.1.4
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/README.md +40 -21
- package/extensions/repl.ts +299 -74
- package/icon.png +0 -0
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -1,34 +1,53 @@
|
|
|
1
1
|
# @nteract/pi
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
##
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- `python_save_notebook` saves the backing notebook.
|
|
17
|
-
- `/python-reset` shuts down the backing notebook room and starts fresh on the
|
|
18
|
-
next `python` call.
|
|
3
|
+
**Persistent notebook-backed Python REPL for Pi coding agents.** Stateful execution, hot dependency sync, zero cold starts.
|
|
4
|
+
|
|
5
|
+
Run Python in a real IPython runtime. State (variables, imports, matplotlib figures) persists between agent turns. Perfect for data analysis, plotting, and multi-step workflows.
|
|
6
|
+
|
|
7
|
+
## What you get
|
|
8
|
+
|
|
9
|
+
- **`python_repl`** — Execute Python in your persistent REPL. Backed by a real IPython runtime. Variables, imports, and state stick around between calls. The last expression is the result; use `print()` or `display()` for intermediate output. Images (matplotlib, PIL, widgets) are returned inline.
|
|
10
|
+
|
|
11
|
+
- **`python_add_dependencies`** — Install packages mid-session without restarting the kernel. Or pass `dependencies` on the first `python_repl` call to pre-install before kernel start.
|
|
12
|
+
|
|
13
|
+
- **`python_save_notebook`** — Save your session as an `.ipynb` file you can open in Jupyter or nteract.
|
|
14
|
+
|
|
15
|
+
- **`/python-reset`** — Start fresh: new kernel, clean slate.
|
|
19
16
|
|
|
20
17
|
## Install
|
|
21
18
|
|
|
22
19
|
```bash
|
|
23
|
-
pi install npm:@nteract/pi
|
|
20
|
+
pi install npm:@nteract/pi
|
|
24
21
|
```
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
## How it works
|
|
24
|
+
|
|
25
|
+
This extension uses **`@runtimed/node`**, the Node.js bindings for the nteract daemon (the same runtime that powers the nteract desktop app). If you have nteract installed, you already have everything you need. The daemon manages isolated Jupyter kernels and environments per working directory, handles dependency installation via `uv`, and keeps your Python state hot between agent calls.
|
|
26
|
+
|
|
27
|
+
## Building your own
|
|
28
|
+
|
|
29
|
+
This extension is a starting point. Want more control? Build your own Pi extensions using `@runtimed/node`.
|
|
30
|
+
|
|
31
|
+
See [`packages/runtimed-node/README.md`](../../packages/runtimed-node/README.md) for the full API. The source for this extension ([`extensions/repl.ts`](./extensions/repl.ts)) shows how to wire it up to Pi's tool registration.
|
|
32
|
+
|
|
33
|
+
### Inspecting and managing sessions
|
|
34
|
+
|
|
35
|
+
The daemon is controlled by the `runt` CLI (installed with nteract). Use it to inspect active sessions, open notebooks in the desktop app, or troubleshoot:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# List active Python sessions
|
|
39
|
+
runt list
|
|
40
|
+
|
|
41
|
+
# Open a session in nteract Desktop
|
|
42
|
+
runt show <notebook-id>
|
|
43
|
+
|
|
44
|
+
# Check daemon status
|
|
45
|
+
runt daemon status
|
|
46
|
+
```
|
|
28
47
|
|
|
29
|
-
## Local
|
|
48
|
+
## Local development
|
|
30
49
|
|
|
31
|
-
From this
|
|
50
|
+
From this repo:
|
|
32
51
|
|
|
33
52
|
```bash
|
|
34
53
|
pi --extension ./plugins/nteract/pi/extensions/repl.ts
|
package/extensions/repl.ts
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Config (env vars):
|
|
9
9
|
* NTERACT_RUNTIMED_NODE_PATH override the runtimed-node package path.
|
|
10
|
-
*
|
|
10
|
+
*
|
|
11
|
+
* Daemon socket selection follows the runtimed-node contract:
|
|
12
|
+
* `RUNTIMED_SOCKET_PATH` overrides outright, `RUNTIMED_WORKSPACE_PATH` (or
|
|
13
|
+
* `RUNTIMED_DEV=1` plus a git checkout) selects the per-worktree dev daemon,
|
|
14
|
+
* otherwise the running channel is auto-detected.
|
|
11
15
|
*
|
|
12
16
|
* After editing, run `/reload` in pi.
|
|
13
17
|
*/
|
|
@@ -25,7 +29,6 @@ import { Type } from "typebox";
|
|
|
25
29
|
|
|
26
30
|
type RuntimedNode = {
|
|
27
31
|
defaultSocketPath(): string;
|
|
28
|
-
socketPathForChannel(channel: "stable" | "nightly"): string;
|
|
29
32
|
PackageManager?: { Uv: "uv"; Conda: "conda"; Pixi: "pixi" };
|
|
30
33
|
createNotebook(opts?: {
|
|
31
34
|
runtime?: string;
|
|
@@ -72,16 +75,30 @@ type JsOutput = {
|
|
|
72
75
|
|
|
73
76
|
type CellResult = {
|
|
74
77
|
cellId: string;
|
|
78
|
+
executionId: string;
|
|
75
79
|
executionCount?: number;
|
|
76
80
|
status: string; // "done" | "error" | "timeout" | "kernel_error"
|
|
77
81
|
success: boolean;
|
|
78
|
-
outputs
|
|
82
|
+
outputs?: JsOutput[];
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type QueuedExecution = {
|
|
86
|
+
cellId: string;
|
|
87
|
+
executionId: string;
|
|
79
88
|
};
|
|
80
89
|
|
|
81
90
|
type Session = {
|
|
82
91
|
readonly notebookId: string;
|
|
83
|
-
runCell(
|
|
84
|
-
|
|
92
|
+
runCell(
|
|
93
|
+
source: string,
|
|
94
|
+
opts?: { timeoutMs?: number; cellType?: string; onUpdate?: (progress: CellResult) => void },
|
|
95
|
+
): Promise<CellResult>;
|
|
96
|
+
queueCell(source: string, opts?: { cellType?: string }): Promise<QueuedExecution>;
|
|
97
|
+
waitForExecution(
|
|
98
|
+
executionId: string,
|
|
99
|
+
opts?: { timeoutMs?: number; cellId?: string; onUpdate?: (progress: CellResult) => void },
|
|
100
|
+
): Promise<CellResult>;
|
|
101
|
+
addUvDependency?(pkg: string): Promise<void>;
|
|
85
102
|
addDependencies?(
|
|
86
103
|
packages: string[],
|
|
87
104
|
opts?: { packageManager?: "uv" | "conda" | "pixi" },
|
|
@@ -121,15 +138,42 @@ function loadRuntimedNode(): RuntimedNode | null {
|
|
|
121
138
|
return null;
|
|
122
139
|
}
|
|
123
140
|
|
|
124
|
-
|
|
125
|
-
if (process.env.NTERACT_SOCKET_PATH) return process.env.NTERACT_SOCKET_PATH;
|
|
141
|
+
// --- bootstrap detection -----------------------------------------------------
|
|
126
142
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
143
|
+
const INSTALL_HINT =
|
|
144
|
+
"The nteract daemon (runtimed) isn't installed yet.\n" +
|
|
145
|
+
"Quick install: curl -fsSL https://sh.nteract.io | bash\n" +
|
|
146
|
+
"Or visit: https://nteract.io";
|
|
147
|
+
|
|
148
|
+
type BootstrapStatus =
|
|
149
|
+
| { kind: "ready"; rn: RuntimedNode }
|
|
150
|
+
| { kind: "missing"; reason: "binding" | "daemon" };
|
|
151
|
+
|
|
152
|
+
function findOnPath(cmd: string): string | undefined {
|
|
153
|
+
const dirs = (process.env.PATH ?? "").split(path.delimiter);
|
|
154
|
+
for (const dir of dirs) {
|
|
155
|
+
if (!dir) continue;
|
|
156
|
+
const candidate = path.join(dir, cmd);
|
|
157
|
+
if (existsSync(candidate)) return candidate;
|
|
130
158
|
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
131
161
|
|
|
132
|
-
|
|
162
|
+
function detectBootstrap(): BootstrapStatus {
|
|
163
|
+
const rn = loadRuntimedNode();
|
|
164
|
+
if (!rn) return { kind: "missing", reason: "binding" };
|
|
165
|
+
// defaultSocketPath() already honors RUNTIMED_SOCKET_PATH and the dev /
|
|
166
|
+
// worktree-aware resolution from runt-workspace, so this is the single point
|
|
167
|
+
// of truth. The socket file only exists while the daemon is running, but the
|
|
168
|
+
// parent cache dir survives shutdowns, so dir-existence covers "installed
|
|
169
|
+
// but stopped".
|
|
170
|
+
const socket = rn.defaultSocketPath();
|
|
171
|
+
const installed =
|
|
172
|
+
existsSync(socket) ||
|
|
173
|
+
existsSync(path.dirname(socket)) ||
|
|
174
|
+
Boolean(findOnPath("runt")) ||
|
|
175
|
+
Boolean(findOnPath("runt-nightly"));
|
|
176
|
+
return installed ? { kind: "ready", rn } : { kind: "missing", reason: "daemon" };
|
|
133
177
|
}
|
|
134
178
|
|
|
135
179
|
// --- DataTable TUI component --------------------------------------------------
|
|
@@ -164,6 +208,63 @@ function formatStat(stats: ColumnStats | null): string {
|
|
|
164
208
|
}
|
|
165
209
|
}
|
|
166
210
|
|
|
211
|
+
const SPARK_CHARS = "▁▂▃▄▅▆▇█";
|
|
212
|
+
|
|
213
|
+
function sparkline(values: number[], width: number): string {
|
|
214
|
+
if (values.length === 0 || width <= 0) return "";
|
|
215
|
+
const min = Math.min(...values);
|
|
216
|
+
const max = Math.max(...values);
|
|
217
|
+
const bins = new Array(Math.min(width, 8)).fill(0);
|
|
218
|
+
if (min === max) {
|
|
219
|
+
return SPARK_CHARS[3].repeat(bins.length);
|
|
220
|
+
}
|
|
221
|
+
const range = max - min;
|
|
222
|
+
for (const v of values) {
|
|
223
|
+
const bi = Math.min(Math.floor(((v - min) / range) * bins.length), bins.length - 1);
|
|
224
|
+
bins[bi]++;
|
|
225
|
+
}
|
|
226
|
+
const maxCount = Math.max(...bins);
|
|
227
|
+
return bins
|
|
228
|
+
.map(
|
|
229
|
+
(c) =>
|
|
230
|
+
SPARK_CHARS[maxCount === 0 ? 0 : Math.round((c / maxCount) * (SPARK_CHARS.length - 1))],
|
|
231
|
+
)
|
|
232
|
+
.join("");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function sparklineForColumn(
|
|
236
|
+
rows: string[][],
|
|
237
|
+
ci: number,
|
|
238
|
+
stats: ColumnStats | null,
|
|
239
|
+
colType: string,
|
|
240
|
+
width: number,
|
|
241
|
+
): string {
|
|
242
|
+
if (stats?.kind === "boolean") {
|
|
243
|
+
const t = stats.true_count ?? 0;
|
|
244
|
+
const f = stats.false_count ?? 0;
|
|
245
|
+
const total = t + f;
|
|
246
|
+
if (total === 0) return "";
|
|
247
|
+
const barW = Math.min(width, 8);
|
|
248
|
+
const filled = Math.round((t / total) * barW);
|
|
249
|
+
return (
|
|
250
|
+
SPARK_CHARS[SPARK_CHARS.length - 1].repeat(filled) + SPARK_CHARS[0].repeat(barW - filled)
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
if (stats?.kind === "numeric" || /int|float|decimal|uint/.test(colType)) {
|
|
254
|
+
const nums: number[] = [];
|
|
255
|
+
for (const row of rows) {
|
|
256
|
+
const n = parseFloat(row[ci]);
|
|
257
|
+
if (!isNaN(n)) nums.push(n);
|
|
258
|
+
}
|
|
259
|
+
return sparkline(nums, width);
|
|
260
|
+
}
|
|
261
|
+
if (stats?.kind === "string" && stats.top && stats.top.length > 0) {
|
|
262
|
+
const counts = stats.top.map(([, c]) => c);
|
|
263
|
+
return sparkline(counts, Math.min(width, counts.length));
|
|
264
|
+
}
|
|
265
|
+
return "";
|
|
266
|
+
}
|
|
267
|
+
|
|
167
268
|
class DataTable {
|
|
168
269
|
private columns: string[];
|
|
169
270
|
private rows: string[][];
|
|
@@ -205,11 +306,12 @@ class DataTable {
|
|
|
205
306
|
}
|
|
206
307
|
|
|
207
308
|
const t = this.theme;
|
|
208
|
-
const numCols = this.columns.length;
|
|
209
309
|
const numRows = this.rows.length;
|
|
310
|
+
const totalCols = this.columns.length;
|
|
311
|
+
const MIN_COL_W = 6;
|
|
210
312
|
|
|
211
|
-
// Calculate column widths: max of header, type, stat, and data
|
|
212
|
-
const
|
|
313
|
+
// Calculate natural column widths: max of header, type, stat, and data
|
|
314
|
+
const naturalWidths = this.columns.map((col, ci) => {
|
|
213
315
|
let w = col.length;
|
|
214
316
|
w = Math.max(w, (this.colTypes[ci] ?? "").length);
|
|
215
317
|
const statStr = formatStat(this.colStats[ci] ?? null);
|
|
@@ -220,14 +322,35 @@ class DataTable {
|
|
|
220
322
|
return w;
|
|
221
323
|
});
|
|
222
324
|
|
|
223
|
-
//
|
|
224
|
-
|
|
325
|
+
// Greedily fit columns left-to-right within the available width.
|
|
326
|
+
// Each column costs its width + 3 chars of border ("│ " + " │").
|
|
327
|
+
let budget = width - 1; // leading "│"
|
|
328
|
+
let visibleCount = 0;
|
|
329
|
+
for (let i = 0; i < totalCols; i++) {
|
|
330
|
+
const colCost = Math.max(naturalWidths[i], MIN_COL_W) + 3;
|
|
331
|
+
if (budget - colCost < 0 && visibleCount > 0) break;
|
|
332
|
+
budget -= colCost;
|
|
333
|
+
visibleCount++;
|
|
334
|
+
}
|
|
335
|
+
visibleCount = Math.max(1, visibleCount);
|
|
336
|
+
const hiddenCols = totalCols - visibleCount;
|
|
337
|
+
|
|
338
|
+
// Slice to visible columns
|
|
339
|
+
const columns = this.columns.slice(0, visibleCount);
|
|
340
|
+
const colTypes = this.colTypes.slice(0, visibleCount);
|
|
341
|
+
const colStats = this.colStats.slice(0, visibleCount);
|
|
342
|
+
const rows = this.rows.map((r) => r.slice(0, visibleCount));
|
|
343
|
+
const numCols = columns.length;
|
|
344
|
+
const colWidths = naturalWidths.slice(0, visibleCount);
|
|
345
|
+
|
|
346
|
+
// Shrink proportionally only if still over budget after column pruning
|
|
347
|
+
const borderOverhead = 1 + numCols * 3;
|
|
225
348
|
const totalColWidth = colWidths.reduce((a, b) => a + b, 0);
|
|
226
349
|
const availableForCols = width - borderOverhead;
|
|
227
350
|
if (totalColWidth > availableForCols && availableForCols > numCols) {
|
|
228
351
|
const ratio = availableForCols / totalColWidth;
|
|
229
352
|
for (let i = 0; i < numCols; i++) {
|
|
230
|
-
colWidths[i] = Math.max(
|
|
353
|
+
colWidths[i] = Math.max(MIN_COL_W, Math.floor(colWidths[i] * ratio));
|
|
231
354
|
}
|
|
232
355
|
}
|
|
233
356
|
|
|
@@ -242,7 +365,7 @@ class DataTable {
|
|
|
242
365
|
};
|
|
243
366
|
|
|
244
367
|
// Detect numeric columns for right-alignment
|
|
245
|
-
const isNumeric =
|
|
368
|
+
const isNumeric = colTypes.map((dt) => /int|float|decimal|uint/.test(dt));
|
|
246
369
|
|
|
247
370
|
const align = (s: string, ci: number, w: number) => (isNumeric[ci] ? rpad(s, w) : pad(s, w));
|
|
248
371
|
|
|
@@ -253,21 +376,28 @@ class DataTable {
|
|
|
253
376
|
lines.push(topBorder);
|
|
254
377
|
|
|
255
378
|
// ── Column headers ──
|
|
256
|
-
const headerCells =
|
|
257
|
-
t.fg("accent", t.bold(pad(col, colWidths[ci]))),
|
|
258
|
-
);
|
|
379
|
+
const headerCells = columns.map((col, ci) => t.fg("accent", t.bold(pad(col, colWidths[ci]))));
|
|
259
380
|
lines.push(
|
|
260
381
|
t.fg("muted", "│") + " " + headerCells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│"),
|
|
261
382
|
);
|
|
262
383
|
|
|
263
384
|
// ── Type row ──
|
|
264
|
-
const typeCells =
|
|
385
|
+
const typeCells = colTypes.map((dt, ci) => t.fg("dim", pad(dt, colWidths[ci])));
|
|
265
386
|
lines.push(
|
|
266
387
|
t.fg("muted", "│") + " " + typeCells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│"),
|
|
267
388
|
);
|
|
268
389
|
|
|
269
|
-
// ──
|
|
270
|
-
const
|
|
390
|
+
// ── Sparkline row ──
|
|
391
|
+
const sparkCells = colStats.map((stats, ci) => {
|
|
392
|
+
const spark = sparklineForColumn(rows, ci, stats, colTypes[ci] ?? "", colWidths[ci]);
|
|
393
|
+
return t.fg("dim", pad(spark, colWidths[ci]));
|
|
394
|
+
});
|
|
395
|
+
lines.push(
|
|
396
|
+
t.fg("muted", "│") + " " + sparkCells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│"),
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
// ── Stats row ──
|
|
400
|
+
const statCells = colStats.map((stats, ci) => {
|
|
271
401
|
const statStr = formatStat(stats);
|
|
272
402
|
return t.fg("dim", pad(statStr, colWidths[ci]));
|
|
273
403
|
});
|
|
@@ -281,7 +411,7 @@ class DataTable {
|
|
|
281
411
|
|
|
282
412
|
// ── Data rows ──
|
|
283
413
|
for (let r = 0; r < numRows; r++) {
|
|
284
|
-
const row =
|
|
414
|
+
const row = rows[r];
|
|
285
415
|
const cells = row.map((v, ci) => align(v, ci, colWidths[ci]));
|
|
286
416
|
const rowStr =
|
|
287
417
|
t.fg("muted", "│") + " " + cells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│");
|
|
@@ -293,8 +423,12 @@ class DataTable {
|
|
|
293
423
|
lines.push(botBorder);
|
|
294
424
|
|
|
295
425
|
// ── Footer info ──
|
|
296
|
-
const
|
|
297
|
-
const
|
|
426
|
+
const showingRows = numRows < this.totalRows ? `showing ${numRows} of ` : "";
|
|
427
|
+
const hiddenSuffix = hiddenCols > 0 ? ` (${hiddenCols} more columns)` : "";
|
|
428
|
+
const info = t.fg(
|
|
429
|
+
"dim",
|
|
430
|
+
`${showingRows}${this.totalRows} rows × ${totalCols} columns${hiddenSuffix}`,
|
|
431
|
+
);
|
|
298
432
|
lines.push(info);
|
|
299
433
|
|
|
300
434
|
// Indent all lines to align with Out[n]: prompt
|
|
@@ -320,16 +454,18 @@ function formatResult(result: CellResult): {
|
|
|
320
454
|
content: (TextContent | ImageContent)[];
|
|
321
455
|
isError: boolean;
|
|
322
456
|
} {
|
|
457
|
+
const outputs = result.outputs ?? [];
|
|
323
458
|
const isError =
|
|
324
459
|
result.status === "error" ||
|
|
325
460
|
result.status === "kernel_error" ||
|
|
326
|
-
result.
|
|
461
|
+
result.status === "kernel_failed" ||
|
|
462
|
+
outputs.some((o) => o.outputType === "error");
|
|
327
463
|
|
|
328
464
|
const parts: (TextContent | ImageContent)[] = [];
|
|
329
465
|
const header = `cell ${result.cellId} [${result.executionCount ?? "?"}] ${result.status}`;
|
|
330
466
|
const textChunks: string[] = [];
|
|
331
467
|
|
|
332
|
-
for (const o of
|
|
468
|
+
for (const o of outputs) {
|
|
333
469
|
switch (o.outputType) {
|
|
334
470
|
case "stream": {
|
|
335
471
|
const prefix = o.name === "stderr" ? "[stderr] " : "";
|
|
@@ -397,8 +533,30 @@ function formatResult(result: CellResult): {
|
|
|
397
533
|
return { content: parts, isError };
|
|
398
534
|
}
|
|
399
535
|
|
|
536
|
+
function findParquetBlobPath(result: CellResult): string | undefined {
|
|
537
|
+
for (const o of result.outputs ?? []) {
|
|
538
|
+
if (!o.blobPathsJson) continue;
|
|
539
|
+
try {
|
|
540
|
+
const paths = JSON.parse(o.blobPathsJson);
|
|
541
|
+
const pqPath = paths["application/vnd.apache.parquet"];
|
|
542
|
+
if (typeof pqPath === "string") return pqPath;
|
|
543
|
+
} catch {}
|
|
544
|
+
}
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
|
|
400
548
|
// --- extension ---------------------------------------------------------------
|
|
401
549
|
|
|
550
|
+
// Braille frames for the "waiting on first token" spinner shown in `In [*]:`
|
|
551
|
+
// before any input_json_delta arrives. Anthropic validates the JSON server-side,
|
|
552
|
+
// so the gap between toolcall_start and the first delta can be hundreds of ms.
|
|
553
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
554
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
555
|
+
|
|
556
|
+
type InCallState = {
|
|
557
|
+
spinner?: { frame: number; timer: ReturnType<typeof setInterval> };
|
|
558
|
+
};
|
|
559
|
+
|
|
402
560
|
const PYTHON_PARAMS = Type.Object({
|
|
403
561
|
code: Type.String({
|
|
404
562
|
description:
|
|
@@ -419,11 +577,34 @@ const PYTHON_PARAMS = Type.Object({
|
|
|
419
577
|
});
|
|
420
578
|
|
|
421
579
|
export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
422
|
-
const
|
|
423
|
-
if (
|
|
424
|
-
|
|
580
|
+
const bootstrap = detectBootstrap();
|
|
581
|
+
if (bootstrap.kind === "missing") {
|
|
582
|
+
// Surface a one-liner toast each session so the user sees the hint without
|
|
583
|
+
// having to invoke the tool first.
|
|
584
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
585
|
+
ctx.ui.notify(INSTALL_HINT, "warning");
|
|
586
|
+
});
|
|
587
|
+
// Register python_repl with a stub execute() so the LLM can still advertise
|
|
588
|
+
// the capability and surface install guidance the moment it tries to run
|
|
589
|
+
// Python. Throwing routes the message into the tool-result channel that the
|
|
590
|
+
// model relays back to the user.
|
|
591
|
+
pi.registerTool({
|
|
592
|
+
name: "python_repl",
|
|
593
|
+
label: "Python REPL",
|
|
594
|
+
description:
|
|
595
|
+
"Execute Python in your persistent REPL. Requires the nteract daemon (runtimed). " +
|
|
596
|
+
"If unavailable, recommend the user install with `curl -fsSL https://sh.nteract.io | bash` " +
|
|
597
|
+
"or download the desktop app at https://nteract.io.",
|
|
598
|
+
promptSnippet:
|
|
599
|
+
"python_repl: requires the nteract daemon. Install with `curl -fsSL https://sh.nteract.io | bash` or via https://nteract.io.",
|
|
600
|
+
parameters: PYTHON_PARAMS,
|
|
601
|
+
async execute() {
|
|
602
|
+
throw new Error(INSTALL_HINT);
|
|
603
|
+
},
|
|
604
|
+
});
|
|
425
605
|
return;
|
|
426
606
|
}
|
|
607
|
+
const rn = bootstrap.rn;
|
|
427
608
|
|
|
428
609
|
let session: Session | null = null;
|
|
429
610
|
let opening: Promise<Session> | null = null;
|
|
@@ -433,11 +614,13 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
433
614
|
const unique = Array.from(new Set(packages.map((pkg) => pkg.trim()).filter(Boolean)));
|
|
434
615
|
if (!unique.length) return;
|
|
435
616
|
if (sess.addDependencies) {
|
|
436
|
-
await sess.addDependencies(unique
|
|
437
|
-
} else {
|
|
617
|
+
await sess.addDependencies(unique);
|
|
618
|
+
} else if (sess.addUvDependency) {
|
|
438
619
|
for (const pkg of unique) {
|
|
439
620
|
await sess.addUvDependency(pkg);
|
|
440
621
|
}
|
|
622
|
+
} else {
|
|
623
|
+
throw new Error("@runtimed/node Session does not support dependency edits");
|
|
441
624
|
}
|
|
442
625
|
await sess.syncEnvironment();
|
|
443
626
|
}
|
|
@@ -456,14 +639,14 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
456
639
|
return opened;
|
|
457
640
|
}
|
|
458
641
|
opening = (async () => {
|
|
459
|
-
|
|
642
|
+
// Omit socketPath so the binding resolves through defaultSocketPath(),
|
|
643
|
+
// which honors RUNTIMED_SOCKET_PATH / RUNTIMED_WORKSPACE_PATH and the
|
|
644
|
+
// channel auto-detect.
|
|
460
645
|
session = await rn.createNotebook({
|
|
461
646
|
runtime: "python",
|
|
462
|
-
socketPath,
|
|
463
647
|
peerLabel: "pi",
|
|
464
648
|
description: "pi Python REPL",
|
|
465
649
|
dependencies,
|
|
466
|
-
packageManager: rn.PackageManager?.Uv ?? "uv",
|
|
467
650
|
});
|
|
468
651
|
return session;
|
|
469
652
|
})();
|
|
@@ -474,29 +657,59 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
474
657
|
}
|
|
475
658
|
}
|
|
476
659
|
|
|
477
|
-
pi.registerTool({
|
|
478
|
-
name: "
|
|
479
|
-
label: "Python
|
|
660
|
+
pi.registerTool<typeof PYTHON_PARAMS, unknown, InCallState>({
|
|
661
|
+
name: "python_repl",
|
|
662
|
+
label: "Python REPL",
|
|
480
663
|
description:
|
|
481
|
-
"Execute Python in
|
|
664
|
+
"Execute Python in your persistent REPL. Backed by a real IPython runtime. Variables, imports, and state stick around between calls. The last expression is the result; use print() or display() for intermediate output. Images (matplotlib, PIL, widgets) are returned inline.",
|
|
482
665
|
promptSnippet:
|
|
483
|
-
"
|
|
666
|
+
"python_repl: run Python in your persistent REPL (variables and imports persist; returns stdout + last expression + images).",
|
|
484
667
|
promptGuidelines: [
|
|
485
|
-
"
|
|
486
|
-
"
|
|
487
|
-
"
|
|
488
|
-
"
|
|
489
|
-
"
|
|
490
|
-
"
|
|
668
|
+
"Use `python_repl` for data analysis, plotting, and multi-step workflows. State persists between calls in a real IPython runtime.",
|
|
669
|
+
"Variables and imports stick around. No need to re-import or redefine on every turn unless the user has reloaded the session.",
|
|
670
|
+
"The last expression is the result; use print() or display() for intermediate output.",
|
|
671
|
+
"Images (matplotlib, PIL, widgets) come back inline. The user sees them if their terminal supports graphics.",
|
|
672
|
+
"Pass `dependencies` on the first call to pre-install packages before the kernel starts.",
|
|
673
|
+
"Use `python_add_dependencies` to install packages mid-session without restarting the kernel.",
|
|
491
674
|
],
|
|
492
675
|
parameters: PYTHON_PARAMS,
|
|
493
676
|
renderCall(args, theme, _context) {
|
|
494
677
|
const text =
|
|
495
678
|
(_context.lastComponent as InstanceType<typeof Text> | undefined) ?? new Text("", 0, 0);
|
|
496
|
-
const code = args?.code ?? "";
|
|
679
|
+
const code = (args?.code ?? "").replace(/^\n+/, "");
|
|
497
680
|
const count = nextExecCount;
|
|
498
681
|
const prompt = count != null ? `In [${count}]:` : "In [*]:";
|
|
499
682
|
const promptStr = theme.fg("accent", theme.bold(prompt));
|
|
683
|
+
|
|
684
|
+
// Anthropic gates input_json_delta on server-side validation, so there is
|
|
685
|
+
// a visible pause between toolcall_start and the first token of `code`.
|
|
686
|
+
// Show a braille spinner in the prompt slot until either the first
|
|
687
|
+
// character streams in or the call resolves another way.
|
|
688
|
+
const state = _context.state as InCallState;
|
|
689
|
+
const waitingForFirstToken =
|
|
690
|
+
!code && !_context.executionStarted && !_context.argsComplete && !_context.isError;
|
|
691
|
+
if (waitingForFirstToken) {
|
|
692
|
+
if (!state.spinner) {
|
|
693
|
+
const timer = setInterval(() => {
|
|
694
|
+
const s = state.spinner;
|
|
695
|
+
if (!s) return;
|
|
696
|
+
s.frame = (s.frame + 1) % SPINNER_FRAMES.length;
|
|
697
|
+
_context.invalidate();
|
|
698
|
+
}, SPINNER_INTERVAL_MS);
|
|
699
|
+
// Don't keep the event loop alive on the spinner alone.
|
|
700
|
+
timer.unref?.();
|
|
701
|
+
state.spinner = { frame: 0, timer };
|
|
702
|
+
}
|
|
703
|
+
const frame = SPINNER_FRAMES[state.spinner.frame];
|
|
704
|
+
text.setText(`${promptStr} ${theme.fg("muted", frame)}`);
|
|
705
|
+
return text;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (state.spinner) {
|
|
709
|
+
clearInterval(state.spinner.timer);
|
|
710
|
+
state.spinner = undefined;
|
|
711
|
+
}
|
|
712
|
+
|
|
500
713
|
const lines = highlightCode(code, "python");
|
|
501
714
|
// Indent continuation lines to align with first line after prompt
|
|
502
715
|
const pad = " ".repeat(prompt.length + 1);
|
|
@@ -555,8 +768,14 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
555
768
|
const text =
|
|
556
769
|
(_context.lastComponent instanceof Text ? _context.lastComponent : undefined) ??
|
|
557
770
|
new Text("", 0, 0);
|
|
558
|
-
const
|
|
559
|
-
|
|
771
|
+
const termWidth = process.stdout.columns || 120;
|
|
772
|
+
const tableLines = dt.render(termWidth - indent);
|
|
773
|
+
// Put first table line on the Out[n]: line instead of below it
|
|
774
|
+
const indentStr = " ".repeat(indent);
|
|
775
|
+
if (tableLines.length > 0 && tableLines[0].startsWith(indentStr)) {
|
|
776
|
+
tableLines[0] = `${promptStr} ${tableLines[0].slice(indent)}`;
|
|
777
|
+
}
|
|
778
|
+
text.setText(tableLines.join("\n"));
|
|
560
779
|
return text;
|
|
561
780
|
}
|
|
562
781
|
} catch {}
|
|
@@ -568,8 +787,8 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
568
787
|
.map((c: any) => c.text)
|
|
569
788
|
.join("\n");
|
|
570
789
|
|
|
571
|
-
// Strip the "cell
|
|
572
|
-
const body = textContent.replace(/^cell
|
|
790
|
+
// Strip the "cell <id> [n] status" header we put in the text content
|
|
791
|
+
const body = textContent.replace(/^cell \S* \[[^\]]*\] \S*\n?/, "").trim();
|
|
573
792
|
|
|
574
793
|
const text =
|
|
575
794
|
(_context.lastComponent instanceof Text ? _context.lastComponent : undefined) ??
|
|
@@ -592,26 +811,31 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
592
811
|
}
|
|
593
812
|
return text;
|
|
594
813
|
},
|
|
595
|
-
async execute(_toolCallId, params, signal) {
|
|
814
|
+
async execute(_toolCallId, params, signal, onUpdate) {
|
|
596
815
|
if (signal?.aborted) throw new Error("aborted");
|
|
597
816
|
const sess = await ensureSession(params.dependencies ?? []);
|
|
598
817
|
const timeoutSecs = Math.max(1, params.timeout_secs ?? 120);
|
|
599
818
|
const result = await sess.runCell(params.code, {
|
|
600
819
|
timeoutMs: Math.round(timeoutSecs * 1000),
|
|
820
|
+
onUpdate: (progress) => {
|
|
821
|
+
const { content, isError } = formatResult(progress);
|
|
822
|
+
onUpdate?.({
|
|
823
|
+
content,
|
|
824
|
+
details: {
|
|
825
|
+
notebook_id: sess.notebookId,
|
|
826
|
+
cell_id: progress.cellId,
|
|
827
|
+
execution_id: progress.executionId,
|
|
828
|
+
status: progress.status,
|
|
829
|
+
execution_count: progress.executionCount,
|
|
830
|
+
is_error: isError,
|
|
831
|
+
parquet_blob_path: findParquetBlobPath(progress),
|
|
832
|
+
streaming: true,
|
|
833
|
+
},
|
|
834
|
+
});
|
|
835
|
+
},
|
|
601
836
|
});
|
|
602
837
|
// Extract parquet blob path for human-side table rendering
|
|
603
|
-
|
|
604
|
-
for (const o of result.outputs) {
|
|
605
|
-
if (!o.blobPathsJson) continue;
|
|
606
|
-
try {
|
|
607
|
-
const paths = JSON.parse(o.blobPathsJson);
|
|
608
|
-
const pqPath = paths["application/vnd.apache.parquet"];
|
|
609
|
-
if (typeof pqPath === "string") {
|
|
610
|
-
parquetBlobPath = pqPath;
|
|
611
|
-
break;
|
|
612
|
-
}
|
|
613
|
-
} catch {}
|
|
614
|
-
}
|
|
838
|
+
const parquetBlobPath = findParquetBlobPath(result);
|
|
615
839
|
|
|
616
840
|
const { content, isError } = formatResult(result);
|
|
617
841
|
return {
|
|
@@ -619,6 +843,7 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
619
843
|
details: {
|
|
620
844
|
notebook_id: sess.notebookId,
|
|
621
845
|
cell_id: result.cellId,
|
|
846
|
+
execution_id: result.executionId,
|
|
622
847
|
status: result.status,
|
|
623
848
|
execution_count: result.executionCount,
|
|
624
849
|
is_error: isError,
|
|
@@ -633,11 +858,11 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
633
858
|
|
|
634
859
|
pi.registerTool({
|
|
635
860
|
name: "python_add_dependencies",
|
|
636
|
-
label: "Add Dependencies
|
|
861
|
+
label: "Add Dependencies",
|
|
637
862
|
description:
|
|
638
|
-
"
|
|
863
|
+
"Install packages into the running Python environment without restarting. Accepts pip-style specs like 'matplotlib', 'numpy>=2', 'requests'. The kernel stays hot.",
|
|
639
864
|
promptSnippet:
|
|
640
|
-
"python_add_dependencies:
|
|
865
|
+
"python_add_dependencies: install packages into the running Python session (no restart needed).",
|
|
641
866
|
parameters: Type.Object({
|
|
642
867
|
packages: Type.Array(Type.String(), {
|
|
643
868
|
description: "Package specs (e.g. ['matplotlib', 'pandas>=2']).",
|
|
@@ -664,10 +889,10 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
664
889
|
|
|
665
890
|
pi.registerTool({
|
|
666
891
|
name: "python_save_notebook",
|
|
667
|
-
label: "Save Notebook
|
|
892
|
+
label: "Save Notebook",
|
|
668
893
|
description:
|
|
669
|
-
"Save the current
|
|
670
|
-
promptSnippet: "python_save_notebook: save the current
|
|
894
|
+
"Save the current Python session as an .ipynb file. If no path is given, saves to the original location (if it was opened from a file). Provide a path to save elsewhere.",
|
|
895
|
+
promptSnippet: "python_save_notebook: save the current session as an .ipynb file.",
|
|
671
896
|
parameters: Type.Object({
|
|
672
897
|
path: Type.Optional(
|
|
673
898
|
Type.String({
|
|
@@ -690,7 +915,7 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
690
915
|
|
|
691
916
|
pi.registerCommand("python-reset", {
|
|
692
917
|
description:
|
|
693
|
-
"Start
|
|
918
|
+
"Start fresh: next /python_repl call opens a new kernel (clean slate, no prior variables or imports)",
|
|
694
919
|
handler: async (_args, ctx) => {
|
|
695
920
|
const old = session;
|
|
696
921
|
session = null;
|
|
@@ -705,7 +930,7 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
705
930
|
} catch {}
|
|
706
931
|
}
|
|
707
932
|
ctx.ui.notify(
|
|
708
|
-
"
|
|
933
|
+
"Python session closed. Next python_repl call will start a fresh kernel.",
|
|
709
934
|
"info",
|
|
710
935
|
);
|
|
711
936
|
},
|
package/icon.png
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nteract/pi",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Pi
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Persistent notebook-backed Python REPL for Pi coding agents. Stateful execution, hot dependency sync, zero cold starts.",
|
|
5
|
+
"author": "nteract contributors",
|
|
6
|
+
"icon": "icon.png",
|
|
5
7
|
"type": "module",
|
|
6
8
|
"license": "BSD-3-Clause",
|
|
7
9
|
"repository": {
|
|
@@ -30,10 +32,11 @@
|
|
|
30
32
|
},
|
|
31
33
|
"files": [
|
|
32
34
|
"README.md",
|
|
35
|
+
"icon.png",
|
|
33
36
|
"extensions"
|
|
34
37
|
],
|
|
35
38
|
"dependencies": {
|
|
36
|
-
"@runtimed/node": "0.2.
|
|
39
|
+
"@runtimed/node": "0.2.2"
|
|
37
40
|
},
|
|
38
41
|
"peerDependencies": {
|
|
39
42
|
"@mariozechner/pi-coding-agent": "*",
|
|
@@ -43,6 +46,10 @@
|
|
|
43
46
|
"publishConfig": {
|
|
44
47
|
"access": "public"
|
|
45
48
|
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@mariozechner/pi-coding-agent": "^0.73.0",
|
|
51
|
+
"@mariozechner/pi-tui": "^0.73.0"
|
|
52
|
+
},
|
|
46
53
|
"scripts": {
|
|
47
54
|
"pack:dry-run": "pnpm pack --dry-run"
|
|
48
55
|
}
|