pi-repl-py 0.1.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/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "pi-repl-py",
3
+ "version": "0.1.0",
4
+ "type": "module",
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
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "agent",
10
+ "python",
11
+ "repl"
12
+ ],
13
+ "license": "MIT",
14
+ "pi": {
15
+ "extensions": [
16
+ "./index.ts"
17
+ ]
18
+ },
19
+ "files": [
20
+ "src",
21
+ "scripts",
22
+ "docs",
23
+ "index.ts",
24
+ "README.md",
25
+ "ARCHITECTURE.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
31
+ "scripts": {
32
+ "typecheck": "tsc --noEmit",
33
+ "format": "biome format --write .",
34
+ "lint": "biome check .",
35
+ "knip": "knip",
36
+ "check": "tsc --noEmit && biome check . && npm run test:ts && npm run test:py",
37
+ "test:ts": "bun test test/units.test.ts test/preview-core.test.ts",
38
+ "test:py": ".venv/bin/python -m pytest test/guest_contract.py -q",
39
+ "test:integ": "bun test test/engine.integration.test.ts",
40
+ "postinstall": "node scripts/setup-venv.mjs"
41
+ },
42
+ "dependencies": {},
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-coding-agent": "*",
45
+ "@earendil-works/pi-tui": "*",
46
+ "@sinclair/typebox": "*"
47
+ },
48
+ "devDependencies": {
49
+ "@biomejs/biome": "2.3.5",
50
+ "@mariozechner/pi-coding-agent": "npm:@earendil-works/pi-coding-agent@^0.84.0",
51
+ "@mariozechner/pi-tui": "npm:@earendil-works/pi-tui@^0.84.0",
52
+ "@types/node": "^22.0.0",
53
+ "knip": "^6.32.0",
54
+ "typebox": "^1.3.11",
55
+ "typescript": "^5.6.0"
56
+ }
57
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * postinstall: build the stable per-user Python venv the guest evaluator needs.
4
+ *
5
+ * The guest (src/engine/guest.py) runs a real ipykernel, which requires
6
+ * `ipykernel` + `jupyter_client` installed in a Python environment. When this
7
+ * is installed as a pi package there is no repo-local `.venv` (it's gitignored
8
+ * and excluded from the npm tarball), so we create one at a stable path that
9
+ * the engine also knows about:
10
+ *
11
+ * ~/.pi/agent/pi-repl-venv/bin/python3
12
+ *
13
+ * Failures are non-fatal: if there's no system python3 or no network, we print
14
+ * a clear notice and let the engine fall back to '$PYTHON' or 'python3' at
15
+ * runtime (where the user is told how to install the deps).
16
+ */
17
+
18
+ import { execSync } from "node:child_process";
19
+ import { existsSync, mkdirSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ const VENV_DIR = join(homedir(), ".pi", "agent", "pi-repl-venv");
24
+ const PY = join(VENV_DIR, "bin", "python3");
25
+ const DEPS = ["ipykernel", "jupyter_client"];
26
+
27
+ function log(msg) {
28
+ process.stdout.write(`[pi-repl] ${msg}\n`);
29
+ }
30
+ function warn(msg) {
31
+ process.stderr.write(`[pi-repl] warning: ${msg}\n`);
32
+ }
33
+
34
+ function findSystemPython() {
35
+ for (const cand of ["python3", "python"]) {
36
+ try {
37
+ execSync(`${cand} --version`, { stdio: "ignore" });
38
+ return cand;
39
+ } catch {}
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function main() {
45
+ // Already built from a previous install/run.
46
+ if (existsSync(PY)) {
47
+ log(`venv already present at ${VENV_DIR}`);
48
+ return;
49
+ }
50
+ const systemPython = findSystemPython();
51
+ if (!systemPython) {
52
+ warn(
53
+ `no python3 found on PATH; could not create the evaluator venv. ` +
54
+ `Install python3 and run '${PY.slice(-60)} -m venv' manually, or set PI_REPL pythonPath.`
55
+ );
56
+ return;
57
+ }
58
+ log(`creating evaluator venv at ${VENV_DIR} (uses ${systemPython})...`);
59
+ try {
60
+ mkdirSync(join(VENV_DIR, ".."), { recursive: true });
61
+ execSync(`${systemPython} -m venv ${VENV_DIR}`, { stdio: "inherit" });
62
+ execSync(`${PY} -m pip install --upgrade pip`, { stdio: "inherit" });
63
+ execSync(`${PY} -m pip install ${DEPS.join(" ")}`, { stdio: "inherit" });
64
+ log("done. The pi-repl evaluator will use this venv.");
65
+ } catch (error) {
66
+ warn(`could not build the evaluator venv (${error && error.message ? error.message : error}). `);
67
+ warn("You may need to run npm rebuild pi-repl after fixing python/network, or install ipykernel manually.");
68
+ }
69
+ }
70
+
71
+ main();
@@ -0,0 +1,317 @@
1
+ """
2
+ guest.py — the real IPython kernel guest evaluator for pi-repl.
3
+
4
+ The host spawns this once. It starts a local ipykernel subprocess via
5
+ jupyter_client, keeps it for the session, and bridges the wire protocol to it
6
+ (stdin = commands, fd 3 = results). State survives because the kernel process
7
+ does. Frames carry a nonce the host mints and the guest erases, so agent code
8
+ cannot forge protocol traffic.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ import time
17
+
18
+ # --- protocol envelope ---
19
+ ENVELOPE_KEY = "__rlm"
20
+ NONCE_ENV = "PI_RLM_NONCE"
21
+ PROTOCOL_FD = 3
22
+
23
+ NONCE = os.environ.get(NONCE_ENV, "")
24
+ os.environ.pop(NONCE_ENV, None)
25
+
26
+ # --- per-cell timeout: 0 = no cap; else a silence watchdog (no output for N seconds) ---
27
+ CELL_TIMEOUT_S = float(os.environ.get("PI_REPL_TIMEOUT_MS", "0") or "0") / 1000.0
28
+
29
+ # --- snapshot/restore use a fixed window, not the cell silence timer ---
30
+ SNAPSHOT_TIMEOUT_S = 90.0
31
+
32
+ # --- cap buffered output so a runaway print can't grow guest memory or send one giant frame ---
33
+ MAX_CELL_OUTPUT_CHARS = 1_000_000
34
+
35
+ # --- fd 3 protocol writer; dup'd so we don't close the caller's fd 3 on exit ---
36
+ _proto = os.fdopen(os.dup(PROTOCOL_FD), "w", buffering=1)
37
+
38
+
39
+ def _send(msg):
40
+ envelope = {ENVELOPE_KEY: 1, **msg}
41
+ if NONCE:
42
+ envelope["n"] = NONCE
43
+ _proto.write(json.dumps(envelope) + "\n")
44
+ _proto.flush()
45
+
46
+
47
+ def _decode(line):
48
+ if ENVELOPE_KEY not in line:
49
+ return None
50
+ try:
51
+ obj = json.loads(line)
52
+ except Exception:
53
+ return None
54
+ if obj.get(ENVELOPE_KEY) != 1 or not isinstance(obj.get("type"), str):
55
+ return None
56
+ if NONCE and obj.get("n") != NONCE:
57
+ return None
58
+ return obj
59
+
60
+
61
+ # --- toolbox: one function per *.py, exec'd into every kernel (PI_TOOLBOX_DIR) ---
62
+
63
+ def _toolbox_files(directory):
64
+ """Return {function_name: source} for each *.py in `directory`."""
65
+ if not directory:
66
+ return {}
67
+ d = os.path.expanduser(directory)
68
+ if not os.path.isdir(d):
69
+ return {}
70
+ names = {}
71
+ for entry in sorted(os.listdir(d)):
72
+ if not entry.endswith(".py"):
73
+ continue
74
+ name = entry[:-3]
75
+ if not name.isidentifier() or name.startswith("_"):
76
+ continue
77
+ try:
78
+ with open(os.path.join(d, entry), encoding="utf-8") as f:
79
+ names[name] = f.read()
80
+ except OSError:
81
+ continue
82
+ return names
83
+
84
+ DEFAULT_TOOLBOX_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "toolbox")
85
+ TOOLBOX_DIR = os.environ.get("PI_TOOLBOX_DIR", "").strip()
86
+ _TOOLBOX_SRC = _toolbox_files(TOOLBOX_DIR or DEFAULT_TOOLBOX_DIR)
87
+
88
+ # --- help/ls are part of the evaluator, not the toolbox ---
89
+ INTRINSIC = """
90
+ # --- ls() filters IPython-injected names out of the tool list ---
91
+ _RPL_LS_NOISE = {'exit', 'quit', 'get_ipython', 'open', 'display'}
92
+
93
+ def ls():
94
+ return sorted(n for n in globals() if n not in _RPL_LS_NOISE and not n.startswith('_') and callable(globals()[n]))
95
+
96
+ def help(name=None):
97
+ if name is None:
98
+ return ls()
99
+ fn = globals().get(name)
100
+ if fn is None or not callable(fn):
101
+ return f"no such function: {name!r}"
102
+ return fn.__doc__ or f"{name} (no docstring)"
103
+ """
104
+
105
+
106
+ from jupyter_client import KernelManager
107
+
108
+
109
+ class Kernel:
110
+ """A persistent subprocess ipykernel + blocking client."""
111
+
112
+ def __init__(self):
113
+ self.km = KernelManager(kernel_name="python3")
114
+ self.km.start_kernel()
115
+ self.kc = self.km.client()
116
+ self.kc.start_channels()
117
+ self.kc.wait_for_ready(timeout=30)
118
+ self._preload()
119
+
120
+ def _preload(self):
121
+ """Exec every toolbox function + the intrinsic help/ls into the kernel ns."""
122
+ code = INTRINSIC + "\n"
123
+ for src in _TOOLBOX_SRC.values():
124
+ code += src + "\n"
125
+ if code.strip():
126
+ self.kc.execute(code)
127
+ self._drain()
128
+
129
+ def _drain(self):
130
+ try:
131
+ while True:
132
+ m = self.kc.get_iopub_msg(timeout=1)
133
+ if m.get("msg_type") == "status" and m.get("content", {}).get("execution_state") == "idle":
134
+ break
135
+ except Exception:
136
+ pass
137
+
138
+ def _drain_execution(self, code, timeout):
139
+ """Run `code`; return (stdout, stderr, error_text, result, timed_out).
140
+
141
+ `timeout <= 0` means "no cap": a cell runs until it reports idle.
142
+ `timeout > 0` is a SILENCE watchdog — it trips only once the cell has
143
+ produced no message for `timeout` seconds. A silent-but-running command
144
+ (e.g. `find ... | sort`) is allowed to complete; a stalled one (dead
145
+ kernel, or nothing for the silence window) reports `timed_out=True` so
146
+ the caller can surface a real hang instead of faking success.
147
+ """
148
+ msg_id = self.kc.execute(code)
149
+ out, err, error, result = [], [], None, None
150
+ out_len, err_len = 0, 0
151
+ timed_out = False
152
+ # --- silence clock only starts once the cell begins (grace for a fresh kernel) ---
153
+ last_activity: float | None = None
154
+ while True:
155
+ if not self.km.is_alive():
156
+ timed_out = True
157
+ break
158
+ if last_activity is not None and timeout and (time.monotonic() - last_activity) >= timeout:
159
+ timed_out = True
160
+ break
161
+ wait = (timeout - (time.monotonic() - last_activity)) if (last_activity is not None and timeout) else 0.25
162
+ try:
163
+ m = self.kc.get_iopub_msg(timeout=max(0.01, min(0.25, wait)))
164
+ except Exception:
165
+ continue
166
+ if m.get("parent_header", {}).get("msg_id") != msg_id:
167
+ continue
168
+ if last_activity is None:
169
+ last_activity = time.monotonic()
170
+ else:
171
+ last_activity = time.monotonic()
172
+ mt = m.get("msg_type")
173
+ c = m.get("content", {})
174
+ if mt == "stream":
175
+ is_out = c.get("name") == "stdout"
176
+ text = c.get("text", "") or ""
177
+ if is_out:
178
+ if out_len < MAX_CELL_OUTPUT_CHARS:
179
+ take = text[: MAX_CELL_OUTPUT_CHARS - out_len]
180
+ out.append(take)
181
+ out_len += len(take)
182
+ else:
183
+ if err_len < MAX_CELL_OUTPUT_CHARS:
184
+ take = text[: MAX_CELL_OUTPUT_CHARS - err_len]
185
+ err.append(take)
186
+ err_len += len(take)
187
+ elif mt == "execute_result":
188
+ result = c.get("data", {}).get("text/plain")
189
+ elif mt == "error":
190
+ error = "\n".join(c.get("traceback", ["(no traceback)"]))
191
+ elif mt == "status" and c.get("execution_state") == "idle":
192
+ break
193
+ if timed_out:
194
+ # --- best-effort cancel so the NEXT cell doesn't queue behind this one ---
195
+ try:
196
+ self.kc.interrupt_kernel()
197
+ except Exception:
198
+ pass
199
+ return "".join(out), "".join(err), error, result, timed_out
200
+
201
+ def execute(self, code):
202
+ """Idle-sync path used by snapshot/restore; not a user cell."""
203
+ return self._drain_execution(code, SNAPSHOT_TIMEOUT_S)[:4]
204
+
205
+ def run_cell(self, code):
206
+ """Run a user cell under the per-cell timeout, so the model learns
207
+ when work did not finish."""
208
+ return self._drain_execution(code, CELL_TIMEOUT_S)
209
+
210
+ def snapshot_globals(self):
211
+ # --- snapshot only user state (skip toolbox/intrinsic functions and _ names) ---
212
+ tool_names = sorted(set(_TOOLBOX_SRC) | {"ls", "help", "function_description"})
213
+ skip_names = json.dumps(tool_names)
214
+ out, _, _, _ = self.execute(
215
+ "import pickle as _pk, base64 as _b64, json as _js\n"
216
+ "__rlm_skip = set(" + skip_names + ") | {'In','Out','get_ipython','exit','quit','open'}\n"
217
+ "__rlm_v = {}\n__rlm_f = []\n"
218
+ "for _k, _v in list(globals().items()):\n"
219
+ " # skip IPython bookkeeping and names with a leading underscore\n"
220
+ " if _k.startswith('__') or _k.startswith('_') or _k in __rlm_skip:\n"
221
+ " continue\n"
222
+ " try:\n"
223
+ " __rlm_v[_k] = _b64.b64encode(_pk.dumps(_v)).decode()\n"
224
+ " except Exception as _e:\n"
225
+ " __rlm_f.append({'name': _k, 'reason': str(_e)})\n"
226
+ "print('__RLC_SNAPSHOT__' + _js.dumps({'vars': __rlm_v, 'failed': __rlm_f}))\n"
227
+ )
228
+ marker = "__RLC_SNAPSHOT__"
229
+ if marker not in out:
230
+ # --- marker never printed: serialization stalled; report incomplete so the host keeps the last good file ---
231
+ return {}, [], False
232
+ try:
233
+ o = json.loads(out.split(marker)[-1])
234
+ return o.get("vars", {}), o.get("failed", []), True
235
+ except Exception:
236
+ return {}, [], False
237
+
238
+ def restore_globals(self, vars_):
239
+ if not vars_:
240
+ return [], []
241
+ # --- restore each variable in one atomic kernel call so one failure reports itself ---
242
+ code2 = (
243
+ "import pickle as _pk, base64 as _b64, json as _js\n"
244
+ "__rl_r = {'restored': [], 'failed': []}\n"
245
+ + "\n".join(
246
+ "try:\n"
247
+ f" globals()[{name!r}] = _pk.loads(_b64.b64decode({b64!r}))\n"
248
+ f" __rl_r['restored'].append({name!r})\n"
249
+ "except Exception as _e:\n"
250
+ f" __rl_r['failed'].append({{'name': {name!r}, 'reason': str(_e)}})\n"
251
+ for name, b64 in vars_.items()
252
+ )
253
+ + "\nprint('__RLC_RESTORE__' + _js.dumps(__rl_r))"
254
+ )
255
+ out, _, _, _ = self.execute(code2)
256
+ marker = "__RLC_RESTORE__"
257
+ if marker not in out:
258
+ return [], []
259
+ try:
260
+ obj = json.loads(out.split(marker)[-1])
261
+ return obj.get("restored", []), obj.get("failed", [])
262
+ except Exception:
263
+ return [], []
264
+
265
+
266
+ def _line_error(text):
267
+ lines = text.split("\n")
268
+ return {"name": "", "message": text, "stack": lines[:12]}
269
+
270
+
271
+ def main():
272
+ kernel = Kernel()
273
+ _send({"type": "ready"})
274
+
275
+ for line in sys.stdin:
276
+ msg = _decode(line)
277
+ if not msg:
278
+ continue
279
+ t = msg["type"]
280
+ if t == "ping":
281
+ _send({"type": "pong", "id": msg["id"]})
282
+ elif t == "snapshot":
283
+ vars_, failed, complete = kernel.snapshot_globals()
284
+ _send({"type": "snapshot_result", "id": msg["id"], "vars": vars_, "failed": failed, "complete": complete})
285
+ elif t == "restore":
286
+ restored, failed = kernel.restore_globals(msg.get("vars", {}))
287
+ _send({"type": "restore_result", "id": msg["id"], "restored": restored, "failed": failed})
288
+ elif t == "list_names":
289
+ names = list(kernel.snapshot_globals()[0].keys())
290
+ _send({"type": "names_result", "id": msg["id"], "names": names})
291
+ elif t == "run":
292
+ cell_id = msg.get("cellId")
293
+ stdout, stderr, error, result, timed_out = kernel.run_cell(msg.get("code", ""))
294
+ if stdout:
295
+ _send({"type": "stream", "cellId": cell_id, "name": "stdout", "chunk": stdout})
296
+ if stderr:
297
+ _send({"type": "stream", "cellId": cell_id, "name": "stderr", "chunk": stderr})
298
+ if timed_out:
299
+ tmsg = {
300
+ "name": "Timeout",
301
+ "message": f"cell did not finish within {CELL_TIMEOUT_S:g}s and may still be running",
302
+ "stack": ["[cell timed out]"],
303
+ }
304
+ _send({"type": "done", "cellId": cell_id, "status": "error", "error": tmsg})
305
+ elif error:
306
+ _send({"type": "done", "cellId": cell_id, "status": "error", "error": _line_error(error)})
307
+ else:
308
+ _send({"type": "done", "cellId": cell_id, "status": "ok", "result": result})
309
+ # --- a single-threaded guest can't read 'abort' mid-cell; the host discards+rebuilds ---
310
+
311
+
312
+ if __name__ == "__main__":
313
+ try:
314
+ main()
315
+ except Exception as e:
316
+ _send({"type": "done", "cellId": "", "status": "error", "error": _line_error(str(e))})
317
+ sys.exit(1)