@nteract/pi 0.1.1 → 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 +38 -14
- package/extensions/repl.ts +363 -81
- package/icon.png +0 -0
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -1,29 +1,53 @@
|
|
|
1
1
|
# @nteract/pi
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
The package provides a persistent notebook-backed REPL for coding agents and
|
|
5
|
-
terminal workflows that need stateful Python execution.
|
|
3
|
+
**Persistent notebook-backed Python REPL for Pi coding agents.** Stateful execution, hot dependency sync, zero cold starts.
|
|
6
4
|
|
|
7
|
-
|
|
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.
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
- `
|
|
12
|
-
|
|
13
|
-
-
|
|
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.
|
|
14
16
|
|
|
15
17
|
## Install
|
|
16
18
|
|
|
17
19
|
```bash
|
|
18
|
-
pi install npm:@nteract/pi
|
|
20
|
+
pi install npm:@nteract/pi
|
|
19
21
|
```
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
```
|
|
23
47
|
|
|
24
|
-
## Local
|
|
48
|
+
## Local development
|
|
25
49
|
|
|
26
|
-
From this
|
|
50
|
+
From this repo:
|
|
27
51
|
|
|
28
52
|
```bash
|
|
29
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,16 +29,19 @@ import { Type } from "typebox";
|
|
|
25
29
|
|
|
26
30
|
type RuntimedNode = {
|
|
27
31
|
defaultSocketPath(): string;
|
|
28
|
-
|
|
32
|
+
PackageManager?: { Uv: "uv"; Conda: "conda"; Pixi: "pixi" };
|
|
29
33
|
createNotebook(opts?: {
|
|
30
34
|
runtime?: string;
|
|
31
35
|
workingDir?: string;
|
|
32
36
|
socketPath?: string;
|
|
33
37
|
peerLabel?: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
dependencies?: string[];
|
|
40
|
+
packageManager?: "uv" | "conda" | "pixi";
|
|
34
41
|
}): Promise<Session>;
|
|
35
42
|
openNotebook(
|
|
36
43
|
notebookId: string,
|
|
37
|
-
opts?: { socketPath?: string; peerLabel?: string },
|
|
44
|
+
opts?: { socketPath?: string; peerLabel?: string; description?: string },
|
|
38
45
|
): Promise<Session>;
|
|
39
46
|
readParquetFile(
|
|
40
47
|
filePath: string,
|
|
@@ -68,18 +75,44 @@ type JsOutput = {
|
|
|
68
75
|
|
|
69
76
|
type CellResult = {
|
|
70
77
|
cellId: string;
|
|
78
|
+
executionId: string;
|
|
71
79
|
executionCount?: number;
|
|
72
80
|
status: string; // "done" | "error" | "timeout" | "kernel_error"
|
|
73
81
|
success: boolean;
|
|
74
|
-
outputs
|
|
82
|
+
outputs?: JsOutput[];
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type QueuedExecution = {
|
|
86
|
+
cellId: string;
|
|
87
|
+
executionId: string;
|
|
75
88
|
};
|
|
76
89
|
|
|
77
90
|
type Session = {
|
|
78
91
|
readonly notebookId: string;
|
|
79
|
-
runCell(
|
|
80
|
-
|
|
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>;
|
|
102
|
+
addDependencies?(
|
|
103
|
+
packages: string[],
|
|
104
|
+
opts?: { packageManager?: "uv" | "conda" | "pixi" },
|
|
105
|
+
): Promise<void>;
|
|
106
|
+
getDependencyStatus?(): Promise<{ uv?: { dependencies: string[] }; fingerprint?: string }>;
|
|
107
|
+
getRuntimeStatus?(): Promise<{
|
|
108
|
+
status: string;
|
|
109
|
+
lifecycle: string;
|
|
110
|
+
errorReason?: string;
|
|
111
|
+
errorDetails?: string;
|
|
112
|
+
}>;
|
|
81
113
|
syncEnvironment(): Promise<void>;
|
|
82
114
|
saveNotebook(path?: string): Promise<void>;
|
|
115
|
+
shutdownNotebook?(): Promise<boolean>;
|
|
83
116
|
close(): Promise<void>;
|
|
84
117
|
};
|
|
85
118
|
|
|
@@ -105,15 +138,42 @@ function loadRuntimedNode(): RuntimedNode | null {
|
|
|
105
138
|
return null;
|
|
106
139
|
}
|
|
107
140
|
|
|
108
|
-
|
|
109
|
-
|
|
141
|
+
// --- bootstrap detection -----------------------------------------------------
|
|
142
|
+
|
|
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";
|
|
110
147
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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;
|
|
114
158
|
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
115
161
|
|
|
116
|
-
|
|
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" };
|
|
117
177
|
}
|
|
118
178
|
|
|
119
179
|
// --- DataTable TUI component --------------------------------------------------
|
|
@@ -148,6 +208,63 @@ function formatStat(stats: ColumnStats | null): string {
|
|
|
148
208
|
}
|
|
149
209
|
}
|
|
150
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
|
+
|
|
151
268
|
class DataTable {
|
|
152
269
|
private columns: string[];
|
|
153
270
|
private rows: string[][];
|
|
@@ -189,11 +306,12 @@ class DataTable {
|
|
|
189
306
|
}
|
|
190
307
|
|
|
191
308
|
const t = this.theme;
|
|
192
|
-
const numCols = this.columns.length;
|
|
193
309
|
const numRows = this.rows.length;
|
|
310
|
+
const totalCols = this.columns.length;
|
|
311
|
+
const MIN_COL_W = 6;
|
|
194
312
|
|
|
195
|
-
// Calculate column widths: max of header, type, stat, and data
|
|
196
|
-
const
|
|
313
|
+
// Calculate natural column widths: max of header, type, stat, and data
|
|
314
|
+
const naturalWidths = this.columns.map((col, ci) => {
|
|
197
315
|
let w = col.length;
|
|
198
316
|
w = Math.max(w, (this.colTypes[ci] ?? "").length);
|
|
199
317
|
const statStr = formatStat(this.colStats[ci] ?? null);
|
|
@@ -204,14 +322,35 @@ class DataTable {
|
|
|
204
322
|
return w;
|
|
205
323
|
});
|
|
206
324
|
|
|
207
|
-
//
|
|
208
|
-
|
|
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;
|
|
209
348
|
const totalColWidth = colWidths.reduce((a, b) => a + b, 0);
|
|
210
349
|
const availableForCols = width - borderOverhead;
|
|
211
350
|
if (totalColWidth > availableForCols && availableForCols > numCols) {
|
|
212
351
|
const ratio = availableForCols / totalColWidth;
|
|
213
352
|
for (let i = 0; i < numCols; i++) {
|
|
214
|
-
colWidths[i] = Math.max(
|
|
353
|
+
colWidths[i] = Math.max(MIN_COL_W, Math.floor(colWidths[i] * ratio));
|
|
215
354
|
}
|
|
216
355
|
}
|
|
217
356
|
|
|
@@ -226,7 +365,7 @@ class DataTable {
|
|
|
226
365
|
};
|
|
227
366
|
|
|
228
367
|
// Detect numeric columns for right-alignment
|
|
229
|
-
const isNumeric =
|
|
368
|
+
const isNumeric = colTypes.map((dt) => /int|float|decimal|uint/.test(dt));
|
|
230
369
|
|
|
231
370
|
const align = (s: string, ci: number, w: number) => (isNumeric[ci] ? rpad(s, w) : pad(s, w));
|
|
232
371
|
|
|
@@ -237,21 +376,28 @@ class DataTable {
|
|
|
237
376
|
lines.push(topBorder);
|
|
238
377
|
|
|
239
378
|
// ── Column headers ──
|
|
240
|
-
const headerCells =
|
|
241
|
-
t.fg("accent", t.bold(pad(col, colWidths[ci]))),
|
|
242
|
-
);
|
|
379
|
+
const headerCells = columns.map((col, ci) => t.fg("accent", t.bold(pad(col, colWidths[ci]))));
|
|
243
380
|
lines.push(
|
|
244
381
|
t.fg("muted", "│") + " " + headerCells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│"),
|
|
245
382
|
);
|
|
246
383
|
|
|
247
384
|
// ── Type row ──
|
|
248
|
-
const typeCells =
|
|
385
|
+
const typeCells = colTypes.map((dt, ci) => t.fg("dim", pad(dt, colWidths[ci])));
|
|
249
386
|
lines.push(
|
|
250
387
|
t.fg("muted", "│") + " " + typeCells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│"),
|
|
251
388
|
);
|
|
252
389
|
|
|
253
|
-
// ──
|
|
254
|
-
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) => {
|
|
255
401
|
const statStr = formatStat(stats);
|
|
256
402
|
return t.fg("dim", pad(statStr, colWidths[ci]));
|
|
257
403
|
});
|
|
@@ -265,7 +411,7 @@ class DataTable {
|
|
|
265
411
|
|
|
266
412
|
// ── Data rows ──
|
|
267
413
|
for (let r = 0; r < numRows; r++) {
|
|
268
|
-
const row =
|
|
414
|
+
const row = rows[r];
|
|
269
415
|
const cells = row.map((v, ci) => align(v, ci, colWidths[ci]));
|
|
270
416
|
const rowStr =
|
|
271
417
|
t.fg("muted", "│") + " " + cells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│");
|
|
@@ -277,8 +423,12 @@ class DataTable {
|
|
|
277
423
|
lines.push(botBorder);
|
|
278
424
|
|
|
279
425
|
// ── Footer info ──
|
|
280
|
-
const
|
|
281
|
-
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
|
+
);
|
|
282
432
|
lines.push(info);
|
|
283
433
|
|
|
284
434
|
// Indent all lines to align with Out[n]: prompt
|
|
@@ -304,16 +454,18 @@ function formatResult(result: CellResult): {
|
|
|
304
454
|
content: (TextContent | ImageContent)[];
|
|
305
455
|
isError: boolean;
|
|
306
456
|
} {
|
|
457
|
+
const outputs = result.outputs ?? [];
|
|
307
458
|
const isError =
|
|
308
459
|
result.status === "error" ||
|
|
309
460
|
result.status === "kernel_error" ||
|
|
310
|
-
result.
|
|
461
|
+
result.status === "kernel_failed" ||
|
|
462
|
+
outputs.some((o) => o.outputType === "error");
|
|
311
463
|
|
|
312
464
|
const parts: (TextContent | ImageContent)[] = [];
|
|
313
465
|
const header = `cell ${result.cellId} [${result.executionCount ?? "?"}] ${result.status}`;
|
|
314
466
|
const textChunks: string[] = [];
|
|
315
467
|
|
|
316
|
-
for (const o of
|
|
468
|
+
for (const o of outputs) {
|
|
317
469
|
switch (o.outputType) {
|
|
318
470
|
case "stream": {
|
|
319
471
|
const prefix = o.name === "stderr" ? "[stderr] " : "";
|
|
@@ -381,13 +533,41 @@ function formatResult(result: CellResult): {
|
|
|
381
533
|
return { content: parts, isError };
|
|
382
534
|
}
|
|
383
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
|
+
|
|
384
548
|
// --- extension ---------------------------------------------------------------
|
|
385
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
|
+
|
|
386
560
|
const PYTHON_PARAMS = Type.Object({
|
|
387
561
|
code: Type.String({
|
|
388
562
|
description:
|
|
389
563
|
"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
564
|
}),
|
|
565
|
+
dependencies: Type.Optional(
|
|
566
|
+
Type.Array(Type.String(), {
|
|
567
|
+
description:
|
|
568
|
+
"Packages to add before executing this code. On the first call they are recorded before the kernel starts; on later calls they are hot-synced into the running environment.",
|
|
569
|
+
}),
|
|
570
|
+
),
|
|
391
571
|
timeout_secs: Type.Optional(
|
|
392
572
|
Type.Number({
|
|
393
573
|
description: "Max seconds to wait for execution (default 120).",
|
|
@@ -397,25 +577,76 @@ const PYTHON_PARAMS = Type.Object({
|
|
|
397
577
|
});
|
|
398
578
|
|
|
399
579
|
export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
400
|
-
const
|
|
401
|
-
if (
|
|
402
|
-
|
|
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
|
+
});
|
|
403
605
|
return;
|
|
404
606
|
}
|
|
607
|
+
const rn = bootstrap.rn;
|
|
405
608
|
|
|
406
609
|
let session: Session | null = null;
|
|
407
610
|
let opening: Promise<Session> | null = null;
|
|
408
611
|
let nextExecCount: number | null = 1;
|
|
409
612
|
|
|
410
|
-
async function
|
|
411
|
-
|
|
412
|
-
if (
|
|
613
|
+
async function addDependenciesAndSync(sess: Session, packages: string[]): Promise<void> {
|
|
614
|
+
const unique = Array.from(new Set(packages.map((pkg) => pkg.trim()).filter(Boolean)));
|
|
615
|
+
if (!unique.length) return;
|
|
616
|
+
if (sess.addDependencies) {
|
|
617
|
+
await sess.addDependencies(unique);
|
|
618
|
+
} else if (sess.addUvDependency) {
|
|
619
|
+
for (const pkg of unique) {
|
|
620
|
+
await sess.addUvDependency(pkg);
|
|
621
|
+
}
|
|
622
|
+
} else {
|
|
623
|
+
throw new Error("@runtimed/node Session does not support dependency edits");
|
|
624
|
+
}
|
|
625
|
+
await sess.syncEnvironment();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function ensureSession(initialDependencies: string[] = []): Promise<Session> {
|
|
629
|
+
const dependencies = Array.from(
|
|
630
|
+
new Set(initialDependencies.map((pkg) => pkg.trim()).filter(Boolean)),
|
|
631
|
+
);
|
|
632
|
+
if (session) {
|
|
633
|
+
await addDependenciesAndSync(session, dependencies);
|
|
634
|
+
return session;
|
|
635
|
+
}
|
|
636
|
+
if (opening) {
|
|
637
|
+
const opened = await opening;
|
|
638
|
+
await addDependenciesAndSync(opened, dependencies);
|
|
639
|
+
return opened;
|
|
640
|
+
}
|
|
413
641
|
opening = (async () => {
|
|
414
|
-
|
|
642
|
+
// Omit socketPath so the binding resolves through defaultSocketPath(),
|
|
643
|
+
// which honors RUNTIMED_SOCKET_PATH / RUNTIMED_WORKSPACE_PATH and the
|
|
644
|
+
// channel auto-detect.
|
|
415
645
|
session = await rn.createNotebook({
|
|
416
646
|
runtime: "python",
|
|
417
|
-
socketPath,
|
|
418
647
|
peerLabel: "pi",
|
|
648
|
+
description: "pi Python REPL",
|
|
649
|
+
dependencies,
|
|
419
650
|
});
|
|
420
651
|
return session;
|
|
421
652
|
})();
|
|
@@ -426,28 +657,59 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
426
657
|
}
|
|
427
658
|
}
|
|
428
659
|
|
|
429
|
-
pi.registerTool({
|
|
430
|
-
name: "
|
|
431
|
-
label: "Python
|
|
660
|
+
pi.registerTool<typeof PYTHON_PARAMS, unknown, InCallState>({
|
|
661
|
+
name: "python_repl",
|
|
662
|
+
label: "Python REPL",
|
|
432
663
|
description:
|
|
433
|
-
"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.",
|
|
434
665
|
promptSnippet:
|
|
435
|
-
"
|
|
666
|
+
"python_repl: run Python in your persistent REPL (variables and imports persist; returns stdout + last expression + images).",
|
|
436
667
|
promptGuidelines: [
|
|
437
|
-
"
|
|
438
|
-
"
|
|
439
|
-
"
|
|
440
|
-
"
|
|
441
|
-
"
|
|
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.",
|
|
442
674
|
],
|
|
443
675
|
parameters: PYTHON_PARAMS,
|
|
444
676
|
renderCall(args, theme, _context) {
|
|
445
677
|
const text =
|
|
446
678
|
(_context.lastComponent as InstanceType<typeof Text> | undefined) ?? new Text("", 0, 0);
|
|
447
|
-
const code = args?.code ?? "";
|
|
679
|
+
const code = (args?.code ?? "").replace(/^\n+/, "");
|
|
448
680
|
const count = nextExecCount;
|
|
449
681
|
const prompt = count != null ? `In [${count}]:` : "In [*]:";
|
|
450
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
|
+
|
|
451
713
|
const lines = highlightCode(code, "python");
|
|
452
714
|
// Indent continuation lines to align with first line after prompt
|
|
453
715
|
const pad = " ".repeat(prompt.length + 1);
|
|
@@ -506,8 +768,14 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
506
768
|
const text =
|
|
507
769
|
(_context.lastComponent instanceof Text ? _context.lastComponent : undefined) ??
|
|
508
770
|
new Text("", 0, 0);
|
|
509
|
-
const
|
|
510
|
-
|
|
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"));
|
|
511
779
|
return text;
|
|
512
780
|
}
|
|
513
781
|
} catch {}
|
|
@@ -519,8 +787,8 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
519
787
|
.map((c: any) => c.text)
|
|
520
788
|
.join("\n");
|
|
521
789
|
|
|
522
|
-
// Strip the "cell
|
|
523
|
-
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();
|
|
524
792
|
|
|
525
793
|
const text =
|
|
526
794
|
(_context.lastComponent instanceof Text ? _context.lastComponent : undefined) ??
|
|
@@ -543,26 +811,31 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
543
811
|
}
|
|
544
812
|
return text;
|
|
545
813
|
},
|
|
546
|
-
async execute(_toolCallId, params, signal) {
|
|
814
|
+
async execute(_toolCallId, params, signal, onUpdate) {
|
|
547
815
|
if (signal?.aborted) throw new Error("aborted");
|
|
548
|
-
const sess = await ensureSession();
|
|
816
|
+
const sess = await ensureSession(params.dependencies ?? []);
|
|
549
817
|
const timeoutSecs = Math.max(1, params.timeout_secs ?? 120);
|
|
550
818
|
const result = await sess.runCell(params.code, {
|
|
551
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
|
+
},
|
|
552
836
|
});
|
|
553
837
|
// Extract parquet blob path for human-side table rendering
|
|
554
|
-
|
|
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
|
-
}
|
|
838
|
+
const parquetBlobPath = findParquetBlobPath(result);
|
|
566
839
|
|
|
567
840
|
const { content, isError } = formatResult(result);
|
|
568
841
|
return {
|
|
@@ -570,10 +843,14 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
570
843
|
details: {
|
|
571
844
|
notebook_id: sess.notebookId,
|
|
572
845
|
cell_id: result.cellId,
|
|
846
|
+
execution_id: result.executionId,
|
|
573
847
|
status: result.status,
|
|
574
848
|
execution_count: result.executionCount,
|
|
575
849
|
is_error: isError,
|
|
576
850
|
parquet_blob_path: parquetBlobPath,
|
|
851
|
+
runtime: sess.getRuntimeStatus
|
|
852
|
+
? await sess.getRuntimeStatus().catch(() => undefined)
|
|
853
|
+
: undefined,
|
|
577
854
|
},
|
|
578
855
|
};
|
|
579
856
|
},
|
|
@@ -581,11 +858,11 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
581
858
|
|
|
582
859
|
pi.registerTool({
|
|
583
860
|
name: "python_add_dependencies",
|
|
584
|
-
label: "Add Dependencies
|
|
861
|
+
label: "Add Dependencies",
|
|
585
862
|
description:
|
|
586
|
-
"
|
|
863
|
+
"Install packages into the running Python environment without restarting. Accepts pip-style specs like 'matplotlib', 'numpy>=2', 'requests'. The kernel stays hot.",
|
|
587
864
|
promptSnippet:
|
|
588
|
-
"python_add_dependencies:
|
|
865
|
+
"python_add_dependencies: install packages into the running Python session (no restart needed).",
|
|
589
866
|
parameters: Type.Object({
|
|
590
867
|
packages: Type.Array(Type.String(), {
|
|
591
868
|
description: "Package specs (e.g. ['matplotlib', 'pandas>=2']).",
|
|
@@ -597,10 +874,7 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
597
874
|
return { content: [{ type: "text", text: "No packages given." }], details: {} };
|
|
598
875
|
}
|
|
599
876
|
const sess = await ensureSession();
|
|
600
|
-
|
|
601
|
-
await sess.addUvDependency(pkg);
|
|
602
|
-
}
|
|
603
|
-
await sess.syncEnvironment();
|
|
877
|
+
await addDependenciesAndSync(sess, params.packages);
|
|
604
878
|
return {
|
|
605
879
|
content: [
|
|
606
880
|
{
|
|
@@ -615,10 +889,10 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
615
889
|
|
|
616
890
|
pi.registerTool({
|
|
617
891
|
name: "python_save_notebook",
|
|
618
|
-
label: "Save Notebook
|
|
892
|
+
label: "Save Notebook",
|
|
619
893
|
description:
|
|
620
|
-
"Save the current
|
|
621
|
-
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.",
|
|
622
896
|
parameters: Type.Object({
|
|
623
897
|
path: Type.Optional(
|
|
624
898
|
Type.String({
|
|
@@ -641,18 +915,22 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
641
915
|
|
|
642
916
|
pi.registerCommand("python-reset", {
|
|
643
917
|
description:
|
|
644
|
-
"Start
|
|
918
|
+
"Start fresh: next /python_repl call opens a new kernel (clean slate, no prior variables or imports)",
|
|
645
919
|
handler: async (_args, ctx) => {
|
|
646
920
|
const old = session;
|
|
647
921
|
session = null;
|
|
648
922
|
nextExecCount = 1;
|
|
649
923
|
if (old) {
|
|
650
924
|
try {
|
|
651
|
-
|
|
925
|
+
if (old.shutdownNotebook) {
|
|
926
|
+
await old.shutdownNotebook();
|
|
927
|
+
} else {
|
|
928
|
+
await old.close();
|
|
929
|
+
}
|
|
652
930
|
} catch {}
|
|
653
931
|
}
|
|
654
932
|
ctx.ui.notify(
|
|
655
|
-
"
|
|
933
|
+
"Python session closed. Next python_repl call will start a fresh kernel.",
|
|
656
934
|
"info",
|
|
657
935
|
);
|
|
658
936
|
},
|
|
@@ -661,7 +939,11 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
|
|
|
661
939
|
pi.on("session_shutdown", async () => {
|
|
662
940
|
if (session) {
|
|
663
941
|
try {
|
|
664
|
-
|
|
942
|
+
if (session.shutdownNotebook) {
|
|
943
|
+
await session.shutdownNotebook();
|
|
944
|
+
} else {
|
|
945
|
+
await session.close();
|
|
946
|
+
}
|
|
665
947
|
} catch {}
|
|
666
948
|
session = null;
|
|
667
949
|
}
|
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.
|
|
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
|
}
|