pi-repl-py 0.6.13 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/ARCHITECTURE.md +103 -138
- package/docs/helpers.md +13 -0
- package/index.ts +57 -41
- package/package.json +1 -1
- package/scripts/setup-venv.mjs +5 -22
- package/src/engine/helpers-locate.ts +0 -1
- package/src/engine/index.ts +213 -64
- package/src/engine/kernel.ts +55 -46
- package/src/engine/session.ts +7 -16
- package/src/engine/zmtp.ts +1 -13
- package/src/extension/helpers.ts +27 -16
- package/src/extension/preview/candidates.ts +0 -4
- package/src/extension/preview/descriptor.ts +0 -1
- package/src/extension/preview/types.ts +0 -2
- package/src/extension/prompt.ts +7 -11
- package/src/extension/render-core.ts +9 -36
- package/src/extension/render.ts +1 -4
- package/src/extension/session-engine.ts +101 -83
- package/src/extension/skill-hook.ts +1 -2
- package/src/extension/state-layout.ts +58 -0
- package/src/extension/tool-meta.ts +2 -9
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Architecture
|
|
2
2
|
|
|
3
|
-
pi-repl runs in **two processes
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
pi-repl runs in **two processes**: pi hosts the TypeScript extension, which manages a separate
|
|
4
|
+
Python `ipykernel` process where user code runs, speaking the standard Jupyter protocol directly
|
|
5
|
+
(no Python middleman, no private framing). A cell can raise or wedge the kernel without taking pi
|
|
6
|
+
down; the host stays answerable.
|
|
6
7
|
|
|
7
8
|
```
|
|
8
9
|
pi
|
|
@@ -16,150 +17,111 @@ pi
|
|
|
16
17
|
└─ python -m ipykernel -f <connection-file> the evaluator
|
|
17
18
|
```
|
|
18
19
|
|
|
19
|
-
The host is TypeScript, and the evaluator is Python in a separate process. This means a cell can
|
|
20
|
-
raise an exception or make the kernel unusable without taking pi down. The host can still report
|
|
21
|
-
what happened.
|
|
22
|
-
|
|
23
20
|
## Why the host speaks ZMTP itself
|
|
24
21
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
The current design removes the middleman. Instead of working around the missing library, the
|
|
31
|
-
host implements the small slice of ZMTP 3.0 that a Jupyter client needs. ZMTP is the socket
|
|
32
|
-
protocol used by Jupyter's channels: the host uses a DEALER socket for shell and control, and a
|
|
33
|
-
SUB socket for iopub (`src/engine/zmtp.ts`).
|
|
34
|
-
The payoff:
|
|
22
|
+
A TypeScript host cannot load libzmq's native Node bindings (they crash `bun`), and the earlier
|
|
23
|
+
Python middleman (`guest.py`) that translated a private JSON protocol is gone. The host instead
|
|
24
|
+
implements the small slice of ZMTP 3.0 a Jupyter client needs — DEALER for shell/control, SUB for
|
|
25
|
+
iopub (`src/engine/zmtp.ts`). The payoff:
|
|
35
26
|
|
|
36
27
|
- **one process boundary** instead of two;
|
|
37
28
|
- **one standard protocol** (Jupyter) instead of a private one on top of it;
|
|
38
29
|
- **no invented framing** to maintain;
|
|
39
|
-
- **
|
|
40
|
-
|
|
41
|
-
the standard message signature now provides that check.
|
|
30
|
+
- **messages are authenticated with HMAC** — the host signs and verifies every message with the
|
|
31
|
+
kernel's HMAC key, replacing the old nonce that guarded against false completion messages.
|
|
42
32
|
|
|
43
33
|
## The Python environment (the venv)
|
|
44
34
|
|
|
45
|
-
The evaluator is a real
|
|
46
|
-
`
|
|
47
|
-
(`
|
|
35
|
+
The evaluator is a real ipykernel, so it needs Python with `ipykernel` installed — a hard runtime
|
|
36
|
+
dependency (`jupyter_client` is *not* needed: the host is the client). A package install runs
|
|
37
|
+
`postinstall` (`scripts/setup-venv.mjs`), which builds a stable per-user venv at
|
|
38
|
+
`~/.pi/agent/pi-repl/venv/bin/python3` — stable because it sits outside the package dir that npm
|
|
39
|
+
replaces on each update. If `python3` or the network is missing at install time, it prints a
|
|
40
|
+
notice and the host falls back at runtime.
|
|
48
41
|
|
|
49
|
-
|
|
50
|
-
|
|
42
|
+
At spawn, `resolvePythonPath` uses exactly one interpreter: the install venv, else `$PYTHON` /
|
|
43
|
+
`python3`. It never auto-picks a repo or cwd `.venv` — such a venv may lack ipykernel, which
|
|
44
|
+
killed the kernel whenever cwd happened to contain one. The kernel starts in the session's cwd
|
|
45
|
+
and falls back to the host cwd if that directory is gone, so a stale cwd never prevents boot.
|
|
51
46
|
|
|
52
|
-
|
|
53
|
-
~/.pi/agent/pi-repl/venv/bin/python3
|
|
54
|
-
```
|
|
47
|
+
## The kernel client
|
|
55
48
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
49
|
+
`KernelClient.start` spawns `python -m ipykernel -f <connection-file>` (a per-run connection file
|
|
50
|
+
in the temp dir), connects the three channels over ZMTP, and waits for `kernel_info_reply` before
|
|
51
|
+
declaring the kernel ready. Cells run as standard `execute_request`s, routed by `msg_id`:
|
|
59
52
|
|
|
60
|
-
|
|
53
|
+
- **iopub** — output: `stream`, `execute_result`, `display_data`, `error`, plus private-MIME
|
|
54
|
+
payloads for snapshot/restore/namespace data;
|
|
55
|
+
- **shell** — the authoritative `execute_reply` (status, ename, evalue);
|
|
56
|
+
- **control** — interrupts (`interrupt_request`) and shutdown.
|
|
61
57
|
|
|
62
|
-
|
|
63
|
-
2. `$PYTHON`, then `python3` (only if the install venv is missing)
|
|
58
|
+
Four protocol details have contract tests.
|
|
64
59
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
session's cwd, falling back to the host's own cwd if that directory no longer exists (a
|
|
70
|
-
deleted project dir), so a stale cwd can never prevent the kernel from coming up.
|
|
60
|
+
**A cell settles only on two messages.** The shell reply and the iopub stream travel on different
|
|
61
|
+
connections, so a tiny reply can beat a large output. A cell completes only when **both** the
|
|
62
|
+
`execute_reply` and the matching iopub `status idle` (published after every byte) arrive;
|
|
63
|
+
settling on the reply alone would drop output still in flight.
|
|
71
64
|
|
|
72
|
-
|
|
65
|
+
**Output is capped per channel and per line**, both announced with markers: channels accumulate
|
|
66
|
+
against `maxOutputChars` (checked within each message), and each line is capped at 4096 chars, so
|
|
67
|
+
one oversized line cannot own the budget while long JSON/reprs/errors pass whole.
|
|
73
68
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
- **iopub** carries output messages such as `stream`, `execute_result`, `display_data`, and
|
|
80
|
-
`error`. It also carries private-MIME payloads for snapshot, restore, and namespace data.
|
|
81
|
-
- **shell** carries the authoritative `execute_reply` (status, ename, evalue).
|
|
82
|
-
- **control** carries interrupts (`interrupt_request`) and shutdown.
|
|
83
|
-
|
|
84
|
-
Two details of this protocol are important enough to have dedicated contract tests.
|
|
85
|
-
|
|
86
|
-
**A cell is not complete until two messages arrive.** The shell reply and the iopub output stream
|
|
87
|
-
travel on different connections, so a tiny reply can arrive before a large output has
|
|
88
|
-
finished draining on iopub. A cell settles only when **both** the `execute_reply` and the
|
|
89
|
-
matching iopub `status idle` (published after every byte of output) have arrived. Settling
|
|
90
|
-
on the reply alone would drop output that was still in flight.
|
|
91
|
-
|
|
92
|
-
**Output is capped per channel and per line.** Each channel accumulates output against a character
|
|
93
|
-
budget (`maxOutputChars`), checked within each message, so overflow trips the moment a message
|
|
94
|
-
exceeds the budget rather than when it churns on. Each individual line is also capped at a generous
|
|
95
|
-
length (4096 chars), so a single genuinely oversized line cannot own the whole budget — while
|
|
96
|
-
legitimately long REPL output (JSON, reprs, errors) still passes through whole. Both truncations
|
|
97
|
-
are announced with explicit markers so the model knows output was cut.
|
|
98
|
-
|
|
99
|
-
**Cancellation is real.** An abort sends an `interrupt_request` on the control channel,
|
|
100
|
-
which raises a genuine `KeyboardInterrupt` in the running cell; the namespace survives. As a
|
|
101
|
-
backstop for cells wedged in C code (which ignore interrupts), the engine gives an aborted
|
|
102
|
-
cell up to 20 seconds to settle and keeps the kernel if it does; only a cell that is still
|
|
103
|
-
running after that grace is killed, and the next call rebuilds from the last snapshot.
|
|
104
|
-
|
|
105
|
-
**History is off.** Every execute goes out with `store_history: false`. IPython's `In`/`Out`
|
|
106
|
-
retention keeps every last-expression result object alive in the kernel, and that retention
|
|
107
|
-
cannot be reclaimed from a user cell — deleting `Out` and `_`/`__`/`___` from `user_ns`
|
|
108
|
-
followed by `gc.collect()` leaves the objects alive (measured: 62 MB idle grows past 400 MB
|
|
109
|
-
after two bare big results and never comes back). Disabling history bounds the kernel to at
|
|
110
|
-
most the latest result. The transcript is the record instead, and results still publish over
|
|
111
|
-
iopub: single-mode execution calls `sys.displayhook` regardless of `store_history`.
|
|
69
|
+
**Cancellation is real.** An abort sends `interrupt_request`, raising a genuine
|
|
70
|
+
`KeyboardInterrupt`; the namespace survives. Cells wedged in C code (which ignore interrupts)
|
|
71
|
+
get a 20-second grace, then the kernel is killed and the next call rebuilds from the last
|
|
72
|
+
snapshot.
|
|
112
73
|
|
|
113
|
-
|
|
74
|
+
**History is off.** IPython's `In`/`Out` retention pins every last-expression result and cannot be
|
|
75
|
+
reclaimed from a user cell (measured: 62 MB → 400+ MB after two bare big results, unrecoverable),
|
|
76
|
+
so every execute goes out with `store_history: false`, bounding the kernel to the latest result.
|
|
77
|
+
Results still publish over iopub: single-mode execution calls `sys.displayhook` regardless.
|
|
114
78
|
|
|
115
|
-
|
|
116
|
-
`.pi/helpers/` directories plus the global `~/.pi/agent/pi-repl/helpers/`), so what the
|
|
117
|
-
prompt advertises is what the kernel holds. Both directories are optional; nothing ships
|
|
118
|
-
with the package. The exact merge order is under "The fixed layout" below.
|
|
79
|
+
## Helpers loading
|
|
119
80
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
81
|
+
At boot, the kernel and the host both read the same merged helper list (project `.pi/helpers/` up
|
|
82
|
+
to the git root, then the global `~/.pi/agent/pi-repl/helpers/`), so what the prompt advertises is
|
|
83
|
+
what the kernel holds: the kernel execs each eligible `*.py` into its namespace; the host reads
|
|
84
|
+
the same files' `helper_description` verbatim into the tool prompt. `_`-prefixed files are
|
|
85
|
+
skipped by both. Merge order and shadowing are under "The fixed layout" below.
|
|
124
86
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
`promptGuidelines` are built once, when the `execute` tool is registered, so a helpers
|
|
128
|
-
change needs a **session restart or `/reload`** to reach the prompt. The kernel also loads
|
|
129
|
-
helpers only at boot.
|
|
87
|
+
The `promptGuidelines` are built once, when `execute` is registered, and the kernel loads helpers
|
|
88
|
+
only at boot, so a helpers change needs a **session restart or `/reload`**.
|
|
130
89
|
|
|
131
|
-
|
|
132
|
-
The model discovers what is loaded by listing the namespace with ordinary Python:
|
|
90
|
+
No discovery intrinsics (`ls()` / `help()`) are injected; list the namespace with ordinary Python:
|
|
133
91
|
|
|
134
92
|
```python
|
|
135
93
|
[k for k in globals() if not k.startswith('_')]
|
|
136
94
|
```
|
|
137
95
|
|
|
138
|
-
|
|
139
|
-
[helpers.md](helpers.md).
|
|
96
|
+
Full contract: [helpers.md](helpers.md).
|
|
140
97
|
|
|
141
98
|
## Snapshots & honest resets
|
|
142
99
|
|
|
143
|
-
After each successful cell,
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
pickle cannot revive them
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
its snapshots
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
100
|
+
After each successful cell, a debounced snapshot pickles the kernel's `globals` entry by entry
|
|
101
|
+
(one un-picklable value costs only itself) and publishes it back over a private MIME payload; the
|
|
102
|
+
host stores it as `namespace.snapshot` under `~/.pi/agent/pi-repl/state/<session>/`.
|
|
103
|
+
|
|
104
|
+
A fresh engine restores that snapshot **in the background**: recovery is a quiet-gap job that
|
|
105
|
+
never runs ahead of a user cell, so a large revive does not delay the first cell; only a
|
|
106
|
+
mid-session rebuild (kernel death) forces the restore before the cell that found the kernel dead.
|
|
107
|
+
Functions and classes defined in cells are captured by source and re-executed on restore (plain
|
|
108
|
+
pickle cannot revive them in `__main__`); bindings that still fail are reported by name, never
|
|
109
|
+
dropped silently. Entries are capped per-binding and in total (128 MiB default); the file is
|
|
110
|
+
written via temp-file-and-rename so a crash cannot corrupt the last good copy; a binding skipped
|
|
111
|
+
at save time is named in the resume notice, never dropped silently, and a failed snapshot leaves
|
|
112
|
+
the retry gate in place (only a persisted write advances it); a periodic refresh (default 2 min, `snapshot.periodMs`, 0 disables) bounds the loss window for same-name mutations and stands down when the last snapshot exceeded 8 MiB; value entries are zlib-compressed (file format version 3 — v1/v2 files remain restorable); session dirs are
|
|
113
|
+
pruned to the newest 25, and dirs whose conversation file no longer exists are swept entirely —
|
|
114
|
+
deleting a conversation deletes its snapshots. "ephemeral" and the live session are exempt. A /fork'd conversation inherits the parent's last snapshot — copied once into the fork's own key at first start, so it resumes with state, carries the standard reset marker on its first cell, and the human gets a dedicated fork toast; the parent is untouched.
|
|
115
|
+
|
|
116
|
+
A revive that never completes (a poisoned pickle) is bounded by an engine restore-cell watchdog
|
|
117
|
+
(`PI_REPL_BOOT_TIMEOUT_MS`, default 90s): the kernel is killed and the restore marked skipped —
|
|
118
|
+
"wedged while reviving; skipped" — instead of hanging the queue.
|
|
119
|
+
|
|
120
|
+
The first cell's result after a revive carries a `<repl_engine_reset>` block naming what was
|
|
121
|
+
revived and lost, so the model re-verifies before trusting state that may be gone; because
|
|
122
|
+
recovery is async, that is the first cell **after the restore completes** (usually the first cell
|
|
123
|
+
of a resumed conversation). The human gets only a terse `ui.notify` toast, derived from the same
|
|
124
|
+
restore result so the two never disagree. A resumed conversation announces only when it has a
|
|
163
125
|
saved past; a first-ever session stays quiet.
|
|
164
126
|
|
|
165
127
|
## Failure modes
|
|
@@ -168,7 +130,7 @@ saved past; a first-ever session stays quiet.
|
|
|
168
130
|
| --- | --- |
|
|
169
131
|
| Cell throws | `error` status with traceback; kernel namespace intact |
|
|
170
132
|
| Cell silent or wedged | the watchdog sends an `interrupt_request`; a caller abort kills the kernel only if it is still running after a 20-second grace |
|
|
171
|
-
| Kernel dies | the running cell settles with an error; the next call builds a fresh kernel and restores the last snapshot |
|
|
133
|
+
| Kernel dies | the running cell settles with an error; the next call builds a fresh kernel and restores the last snapshot **before** the triggering cell |
|
|
172
134
|
| Host exits | `process.on("exit")` SIGKILLs live kernels (a child does not die with its parent) |
|
|
173
135
|
| Output flood | capped per channel, truncation announced |
|
|
174
136
|
|
|
@@ -181,14 +143,14 @@ saved past; a first-ever session stays quiet.
|
|
|
181
143
|
snapshot/restore round-trips, output caps, silence timeout, abort, and rebuilding from a
|
|
182
144
|
snapshot after the kernel dies.
|
|
183
145
|
|
|
184
|
-
The gate is `just check
|
|
185
|
-
|
|
146
|
+
The gate is `just check` (Biome formatting/lint, dead-code checks, host tests); `just integration`
|
|
147
|
+
adds the real-kernel suite.
|
|
186
148
|
|
|
187
149
|
## The fixed layout
|
|
188
150
|
|
|
189
|
-
There is no configuration file. Most state lives under one directory in the user's home. A
|
|
190
|
-
|
|
191
|
-
|
|
151
|
+
There is no configuration file. Most state lives under one directory in the user's home. A small
|
|
152
|
+
number of environment variables can still change runtime behavior, such as the silence watchdog
|
|
153
|
+
timeout.
|
|
192
154
|
|
|
193
155
|
```
|
|
194
156
|
~/.pi/agent/pi-repl/
|
|
@@ -197,21 +159,24 @@ watchdog timeout.
|
|
|
197
159
|
state/ per-session namespace snapshots
|
|
198
160
|
```
|
|
199
161
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
`
|
|
213
|
-
|
|
214
|
-
|
|
162
|
+
State dirs are keyed `<project-slug>__<conversation>` so two conversations that share a filename
|
|
163
|
+
can never share a snapshot (a pre-slug dir migrates on the owning conversation's next start), and
|
|
164
|
+
`EngineLifecycle.acquire` binds each engine to its conversation, tearing a foreign engine down
|
|
165
|
+
before building a new one — sessions cannot bleed into each other.
|
|
166
|
+
|
|
167
|
+
Helpers merge project and global dirs: `resolveHelperDirs` walks up to the git root collecting
|
|
168
|
+
`.pi/helpers/`, then appends the global dir; the prompt loader and the kernel's `readHelperSources`
|
|
169
|
+
walk the same ordered list first-seen-wins, so a project helper shadows a same-named global one
|
|
170
|
+
and both sides agree. No setting is needed. The per-cell silence watchdog is off by default
|
|
171
|
+
(`PI_REPL_TIMEOUT_MS=0`).
|
|
172
|
+
|
|
173
|
+
Boot is bounded regardless: kernel start and helpers preload are kernel cells with no deadline of
|
|
174
|
+
their own, and `acquire()` dedupes, so one wedged boot (an npm update swapping the venv under a
|
|
175
|
+
live kernel, a hanging helper import) would hang the first cell and every cell after. The
|
|
176
|
+
lifecycle races each boot attempt against `PI_REPL_BOOT_TIMEOUT_MS` (default 90s): a wedged
|
|
177
|
+
attempt is killed and retried once with the snapshot deliberately skipped — "wedged while
|
|
178
|
+
reviving; skipped" — and a second wedge fails loudly. The same deadline bounds a restore whose
|
|
179
|
+
unpickling never returns.
|
|
215
180
|
|
|
216
181
|
## Reference documentation
|
|
217
182
|
|
package/docs/helpers.md
CHANGED
|
@@ -79,6 +79,10 @@ A helper may define `helper_description`:
|
|
|
79
79
|
helper_description = """double(x) — multiply a value by two."""
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
+
The value must be a **triple-quoted string** (`"""..."""` or `'''...'''`): a plain-quoted
|
|
83
|
+
assignment is not recognized, and the helper is advertised with a pointer text telling the
|
|
84
|
+
model to inspect it (`print(double.__doc__)`) instead.
|
|
85
|
+
|
|
82
86
|
The host reads this value and puts it in the `execute` tool description verbatim. It is guidance for the model, not a registration mechanism or generated API. Keep it short: it
|
|
83
87
|
is included in the model's context on every turn.
|
|
84
88
|
|
|
@@ -127,6 +131,15 @@ At startup, two parts of pi-repl read the same merged helper list (project dirs
|
|
|
127
131
|
1. The kernel executes each eligible `.py` file. Its definitions become names in the Python workspace.
|
|
128
132
|
2. The host reads `helper_description` to build the helper guidance shown to the model.
|
|
129
133
|
|
|
134
|
+
Both resolve the list from the **session's working directory** (not the folder pi was launched
|
|
135
|
+
from), so a resumed session advertises exactly the helpers its kernel loads. The per-session
|
|
136
|
+
list is rebuilt at every agent start and stated in the system prompt.
|
|
137
|
+
|
|
138
|
+
Each helper file executes in its **own cell**, so a broken helper (a syntax error or a top-level
|
|
139
|
+
raise) fails alone and does not stop the others. When a helper fails to load, the next cell's
|
|
140
|
+
output carries a `<repl_helpers_failed: name (error)>` line, and you get a toast; an
|
|
141
|
+
all-good boot stays silent.
|
|
142
|
+
|
|
130
143
|
The host does not inspect `def` lines or infer signatures from filenames. A helper does not need to define one particular symbol. The file is the unit of loading; its public names are the names it defines or imports for use in
|
|
131
144
|
the workspace.
|
|
132
145
|
|
package/index.ts
CHANGED
|
@@ -5,9 +5,11 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
7
|
import { withSkillsBlock } from "./src/extension/skill-hook.js";
|
|
8
|
+
import { buildHelpersPromptSection } from "./src/extension/helpers.js";
|
|
8
9
|
import { EngineManager, pruneOrphanedSnapshotDirs, pruneSnapshotDirs } from "./src/engine/index.js";
|
|
9
10
|
import { ExecuteCellComponent, type ExecuteDetails, type ExecuteRenderState } from "./src/extension/render.js";
|
|
10
|
-
import { EngineLifecycle, formatResetToast } from "./src/extension/session-engine.js";
|
|
11
|
+
import { EngineLifecycle, formatForkToast, formatHelperFailuresLine, formatHelperToast, formatResetToast } from "./src/extension/session-engine.js";
|
|
12
|
+
import { conversationName, inheritForkSnapshot, resolveStateDir } from "./src/extension/state-layout.js";
|
|
11
13
|
import { EXECUTE_DESCRIPTION, buildExecutePromptGuidelines, EXECUTE_PROMPT_SNIPPET } from "./src/extension/tool-meta.js";
|
|
12
14
|
|
|
13
15
|
const executeSchema = Type.Object({
|
|
@@ -58,33 +60,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
58
60
|
const pendingErrorResults = new Map<string, { details: ExecuteDetails }>();
|
|
59
61
|
|
|
60
62
|
const lifecycle = new EngineLifecycle<EngineManager>({
|
|
61
|
-
// --- boot
|
|
62
|
-
// --- update swaps the venv and helpers under a live kernel, and the first boot after
|
|
63
|
-
// --- it can wedge (poisoned pickle, half-built venv); without this the first cell
|
|
64
|
-
// --- hangs forever, because acquire() dedupes onto the same hung boot. ---
|
|
63
|
+
// --- bound the boot: a wedged first boot would hang every cell (acquire() dedupes onto it) ---
|
|
65
64
|
bootTimeoutMs: Number(process.env.PI_REPL_BOOT_TIMEOUT_MS ?? 90_000) || 90_000,
|
|
66
|
-
create() {
|
|
65
|
+
create(skipRestore = false) {
|
|
67
66
|
const { cwd, sessionFile } = location;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
// --- state lives under ~/.pi/agent/pi-repl/state/<slug>__<conv>; conversations never share a snapshot; ephemeral sessions get none ---
|
|
68
|
+
const stateRoot = join(homedir(), ".pi", "agent", "pi-repl", "state");
|
|
69
|
+
let snapshot: { path: string } | undefined;
|
|
70
|
+
let forkInherited = false;
|
|
71
|
+
let currentDir: string | undefined;
|
|
72
|
+
if (sessionFile) {
|
|
73
|
+
const { dir, snapshotPath } = resolveStateDir(stateRoot, sessionFile);
|
|
74
|
+
currentDir = basename(dir);
|
|
75
|
+
snapshot = { path: snapshotPath };
|
|
76
|
+
// --- a /fork'd conversation inherits the parent's last namespace (copied once into the fork's own key) ---
|
|
73
77
|
try {
|
|
74
|
-
|
|
78
|
+
forkInherited = inheritForkSnapshot(stateRoot, sessionFile, snapshotPath);
|
|
75
79
|
} catch {}
|
|
76
|
-
// ---
|
|
77
|
-
// --- sessionFile is sessions/<project-root>/<name>.jsonl, so the sessions root is
|
|
78
|
-
// --- two parent hops up; dirs whose conversation file exists in no project root
|
|
79
|
-
// --- (and that aren't this session or the ephemeral fallback) are swept. ---
|
|
80
|
+
// --- keep the state root from growing one dir per session forever; the live dir is exempt ---
|
|
80
81
|
try {
|
|
81
|
-
|
|
82
|
+
pruneSnapshotDirs(stateRoot, 25, currentDir);
|
|
83
|
+
} catch {}
|
|
84
|
+
// --- sweep state dirs whose conversation file exists in no project root: deleting a conversation deletes its snapshots (both dir formats) ---
|
|
85
|
+
try {
|
|
86
|
+
pruneOrphanedSnapshotDirs(stateRoot, sessionFile ? dirname(dirname(sessionFile)) : undefined, currentDir);
|
|
82
87
|
} catch {}
|
|
83
88
|
}
|
|
84
89
|
return new EngineManager({
|
|
85
90
|
cwd,
|
|
86
|
-
// --- snapshots are
|
|
87
|
-
snapshot
|
|
91
|
+
// --- snapshots are per-conversation; skipRestore marks the wedged-boot retry ---
|
|
92
|
+
snapshot,
|
|
93
|
+
skipRestore,
|
|
94
|
+
forkInherited,
|
|
88
95
|
});
|
|
89
96
|
},
|
|
90
97
|
async dispose(engine) {
|
|
@@ -104,14 +111,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
104
111
|
pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "execute"));
|
|
105
112
|
return;
|
|
106
113
|
}
|
|
107
|
-
// --- active: the whole surface collapses to the one tool ---
|
|
108
114
|
pi.setActiveTools(["execute"]);
|
|
109
|
-
// --- warm the engine
|
|
110
|
-
// --- acquire() dedupes, so the first execute awaits this same in-flight boot ---
|
|
115
|
+
// --- warm the engine in the background; acquire() dedupes, so the first execute awaits this same boot ---
|
|
111
116
|
location = { cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile() ?? undefined };
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// ---
|
|
117
|
+
const sessionKey = location.sessionFile ? conversationName(location.sessionFile) : undefined;
|
|
118
|
+
void lifecycle.acquire("startup", sessionKey).catch(() => {
|
|
119
|
+
// --- swallow the warm boot's rejection: boot/revive are handled on the execute path ---
|
|
115
120
|
});
|
|
116
121
|
});
|
|
117
122
|
|
|
@@ -128,14 +133,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
128
133
|
return { content: event.content, details: stashed.details, isError: true };
|
|
129
134
|
});
|
|
130
135
|
|
|
131
|
-
// --- pi gates skills on the read tool (absent in repl);
|
|
132
|
-
pi.on("before_agent_start", (event) => {
|
|
136
|
+
// --- pi gates skills on the read tool (absent in repl); the helper roster is rebuilt per session from the session cwd, not the launch cwd, so resumes advertise what the kernel loaded ---
|
|
137
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
133
138
|
if (!active()) return;
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
)
|
|
138
|
-
return systemPrompt ===
|
|
139
|
+
const skillsPrompt = withSkillsBlock(event.systemPrompt, event.systemPromptOptions?.skills ?? []);
|
|
140
|
+
let systemPrompt = skillsPrompt ?? event.systemPrompt;
|
|
141
|
+
const helpersBlock = buildHelpersPromptSection(ctx?.cwd ?? process.cwd());
|
|
142
|
+
if (helpersBlock) systemPrompt = `${systemPrompt}\n\n${helpersBlock}`;
|
|
143
|
+
return systemPrompt === event.systemPrompt ? undefined : { systemPrompt };
|
|
139
144
|
});
|
|
140
145
|
|
|
141
146
|
pi.registerTool<typeof executeSchema, ExecuteDetails, Partial<ExecuteRenderState>>({
|
|
@@ -143,7 +148,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
143
148
|
label: "execute",
|
|
144
149
|
description: EXECUTE_DESCRIPTION,
|
|
145
150
|
promptSnippet: EXECUTE_PROMPT_SNIPPET,
|
|
146
|
-
promptGuidelines: buildExecutePromptGuidelines(
|
|
151
|
+
promptGuidelines: buildExecutePromptGuidelines(),
|
|
147
152
|
parameters: executeSchema,
|
|
148
153
|
renderShell: "self",
|
|
149
154
|
renderCall(args, theme, context) {
|
|
@@ -171,11 +176,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
171
176
|
throw new Error("pi-repl is dormant in this session. Start pi with --repl (or PI_REPL_FORCE=1) to use execute.");
|
|
172
177
|
}
|
|
173
178
|
if (ctx?.cwd) location = { cwd: ctx.cwd, sessionFile: ctx.sessionManager?.getSessionFile?.() ?? undefined };
|
|
174
|
-
// --- establish the body slot at call time so Ctrl+O can expand a live
|
|
175
|
-
// --- without this the host only renders the result once the first partial or the final result lands ---
|
|
179
|
+
// --- establish the body slot at call time so Ctrl+O can expand a live, still-streaming cell ---
|
|
176
180
|
onUpdate?.({ content: [], details: {} });
|
|
177
|
-
|
|
178
|
-
const { engine: m } = await lifecycle.acquire("cell");
|
|
181
|
+
const sessionKey = location.sessionFile ? conversationName(location.sessionFile) : undefined;
|
|
182
|
+
const { engine: m } = await lifecycle.acquire("cell", sessionKey);
|
|
179
183
|
try {
|
|
180
184
|
// --- accumulate partial updates so the row height doesn't oscillate ---
|
|
181
185
|
let streamed = "";
|
|
@@ -186,11 +190,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
186
190
|
onUpdate?.({ content: [{ type: "text", text: streamed }], details: {} });
|
|
187
191
|
},
|
|
188
192
|
});
|
|
189
|
-
// --- reset notice leads so the model reads
|
|
190
|
-
// --- human gets a terse notification instead of the marker, fire and forget ---
|
|
193
|
+
// --- reset notice leads so the model reads the rebuild; the human gets a terse toast instead ---
|
|
191
194
|
const reset = lifecycle.takeResetNotice();
|
|
192
|
-
if (reset?.notice)
|
|
193
|
-
|
|
195
|
+
if (reset?.notice)
|
|
196
|
+
ctx?.ui?.notify?.(
|
|
197
|
+
m.inheritedFromFork ? formatForkToast(reset.restore) : formatResetToast(reset.origin, reset.restore, reset.wedged),
|
|
198
|
+
"info",
|
|
199
|
+
);
|
|
200
|
+
// --- helper verdicts once per boot: toast for the human, marker for the model only when a helper failed (all-good boots stay silent) ---
|
|
201
|
+
const helperReport = m.takeHelperReport();
|
|
202
|
+
if (helperReport && helperReport.length > 0) ctx?.ui?.notify?.(formatHelperToast(helperReport), "info");
|
|
203
|
+
const sections = [
|
|
204
|
+
reset?.notice,
|
|
205
|
+
formatHelperFailuresLine(helperReport),
|
|
206
|
+
r.stdout,
|
|
207
|
+
r.stderr,
|
|
208
|
+
r.result,
|
|
209
|
+
];
|
|
194
210
|
const errorLines = r.error ? composeErrorLines(r.error) : undefined;
|
|
195
211
|
if (r.status === "error" && errorLines) sections.push(errorLines.join("\n"));
|
|
196
212
|
if (r.status === "aborted") sections.push("[cell aborted]");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-repl-py",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A pi extension with a single tool: execute, running a TypeScript host with a persistent Python (ipykernel) evaluator and a user-configurable toolbox of functions.",
|
|
6
6
|
"keywords": [
|
package/scripts/setup-venv.mjs
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* postinstall: build the stable per-user Python venv the evaluator needs.
|
|
4
|
-
*
|
|
5
|
-
* On a package install the venv is built at a stable path the engine knows:
|
|
6
|
-
*
|
|
7
|
-
* ~/.pi/agent/pi-repl/venv/bin/python3
|
|
8
|
-
*
|
|
9
|
-
* The venv is repaired in place: if it exists but ipykernel is not importable,
|
|
10
|
-
* this reflushes the venv and reinstalls rather than trusting a half-built one.
|
|
11
|
-
* Failures are NOT silent: a bad build exits non-zero so `npm install` / `pi
|
|
12
|
-
* install` visibly fails instead of leaving a broken evaluator.
|
|
13
|
-
*/
|
|
2
|
+
/** postinstall: build the stable per-user venv at ~/.pi/agent/pi-repl/venv; repair in place if ipykernel is missing; a bad build fails the install loudly. */
|
|
14
3
|
|
|
15
4
|
import { execSync } from "node:child_process";
|
|
16
5
|
import { mkdirSync } from "node:fs";
|
|
@@ -22,8 +11,7 @@ const PY = join(VENV_DIR, "bin", "python3");
|
|
|
22
11
|
const DEPS = ["ipykernel"];
|
|
23
12
|
const HELPERS_DIR = join(homedir(), ".pi", "agent", "pi-repl", "helpers");
|
|
24
13
|
|
|
25
|
-
// A venv that
|
|
26
|
-
// binary alone — a half-built venv otherwise looks "already up" forever.
|
|
14
|
+
// A venv that can't import ipykernel is broken — never trust the binary alone.
|
|
27
15
|
function ipykernelOk() {
|
|
28
16
|
try {
|
|
29
17
|
execSync(`${PY} -c "import ipykernel"`, { stdio: "ignore" });
|
|
@@ -50,10 +38,7 @@ function findSystemPython() {
|
|
|
50
38
|
return null;
|
|
51
39
|
}
|
|
52
40
|
|
|
53
|
-
//
|
|
54
|
-
// The helpers dir is user-owned. We create it empty on install. The REPL
|
|
55
|
-
// provides shell and file IO natively; helpers are for things the user adds
|
|
56
|
-
// themselves (e.g. web_search, custom skills). Existing files are never clobbered.
|
|
41
|
+
// --- helpers dir: user-owned, created empty; existing files are never clobbered ---
|
|
57
42
|
function seedHelpersDir() {
|
|
58
43
|
try {
|
|
59
44
|
mkdirSync(HELPERS_DIR, { recursive: true });
|
|
@@ -89,9 +74,7 @@ function main() {
|
|
|
89
74
|
}
|
|
90
75
|
log("done. The pi-repl evaluator will use this venv.");
|
|
91
76
|
} catch (error) {
|
|
92
|
-
//
|
|
93
|
-
// nonzero exit and report the install as failed instead of silently
|
|
94
|
-
// handing the user a dead evaluator.
|
|
77
|
+
// a real failure must exit non-zero — never hand the user a dead evaluator
|
|
95
78
|
fail(`could not build the evaluator venv (${error && error.message ? error.message : error}). `);
|
|
96
79
|
}
|
|
97
80
|
}
|
|
@@ -101,4 +84,4 @@ function fail(m) {
|
|
|
101
84
|
process.exit(1);
|
|
102
85
|
}
|
|
103
86
|
|
|
104
|
-
main();
|
|
87
|
+
main();
|
|
@@ -5,7 +5,6 @@ import { dirname, join, resolve } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
const GLOBAL_HELPERS_DIR = join(homedir(), ".pi", "agent", "pi-repl", "helpers");
|
|
7
7
|
|
|
8
|
-
/** Ordered candidate dirs: nearest .pi/helpers up to the git root, then the global dir last. */
|
|
9
8
|
export function resolveHelperDirs(cwd?: string, globalDir?: string): string[] {
|
|
10
9
|
const dirs: string[] = [];
|
|
11
10
|
if (cwd) {
|