pi-repl-py 0.1.1 → 0.2.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.
@@ -2,87 +2,95 @@
2
2
 
3
3
  ## The bet
4
4
 
5
- Most coding agents are a pile of point tools. A read tool, a bash tool, an edit
6
- tool, a find tool, each with its own schema, its own failure modes, its own
7
- token cost to describe. The model spends context deciding *which* tool, then
8
- *how* the output should be threaded into the next one.
5
+ Most coding agents carry a toolbox of point tools: a read tool, a bash tool, an edit tool,
6
+ a search tool, each with its own schema, its own failure modes, and its own token cost to
7
+ describe. The model spends context deciding *which* tool to call, then *how* to thread one
8
+ tool's output into the next.
9
9
 
10
- pi-repl makes the opposite bet: **give the model one persistent Python
11
- workspace and let it write the composition itself.** Configuration, state, and
12
- file access all happen in code, in one living namespace. The model's interface
13
- to the world never grows — the *code* it writes adapts instead.
10
+ pi-repl makes the opposite bet: **give the model one persistent Python workspace, and let it
11
+ write the composition itself.** Reading, running, searching, and editing all happen in code,
12
+ in a single living namespace. The model's interface to the world never grows — the *code* it
13
+ writes adapts instead.
14
14
 
15
- That is a "REPL" the way an agent actually wants one. Not an interactive
16
- lozenge to type into, but a long-lived working memory the model owns.
15
+ This is what an agent actually wants from a "REPL". Not an interactive prompt to type into,
16
+ but long-lived working memory the model owns.
17
17
 
18
18
  ## What persistence buys
19
19
 
20
- A separate-tool loop re-parses text every step. `read` returns a string, the
21
- agent pastes it, `grep` returns lines, the agent re-reads them. Every
22
- transformation is round-tripped through the transcript and billed as tokens.
20
+ A point-tool loop re-parses text at every step. The `read` tool returns a string, so the
21
+ agent pastes it back into context. The `grep` tool returns lines, so the agent re-reads
22
+ them. Every transformation is round-tripped through the transcript and billed as tokens.
23
23
 
24
- In a persistent kernel, that work happens once and stays put:
24
+ In a persistent kernel that work happens once and stays put:
25
25
 
26
- - a variable assigned in one cell is there in the next, and the next turn;
26
+ - a variable assigned in one cell is still there in the next cell, and the next turn;
27
27
  - a function defined once is reusable for the whole session;
28
- - `bash()` returns a real `subprocess.CompletedProcess`, not a transcript
29
- snippet, so the agent branches on `.returncode` and slices `.stdout` with
30
- normal code.
28
+ - `subprocess.run(...)` returns a structured result (`.returncode`, `.stdout`, `.stderr`)
29
+ the agent branches on with normal code no re-parsing a tool's text output.
31
30
 
32
- The savings compound harder for small models. Holding a whole file in context
33
- to avoid re-reading it is expensive precisely when context is scarce; the
34
- kernel lets the model aggregate, filter, and store in code, printing only what
35
- the current step needs.
31
+ The savings compound for small models. Holding a whole file in context to avoid re-reading
32
+ it is expensive precisely when context is scarce. The kernel lets the model load, filter,
33
+ and store in code, printing only what the current step needs.
36
34
 
37
35
  ## Why a real kernel
38
36
 
39
- pi-repl does not hand-roll an `exec` loop. It drives a genuine `ipython`
40
- kernel in a subprocess via `jupyter_client`. That buys:
37
+ pi-repl does not hand-roll an `exec` loop. It drives a genuine `ipython` kernel in a
38
+ separate process. That buys four things a script string passed to `exec` cannot give:
41
39
 
42
- - rich, real tracebacks instead of a wrapped `except`;
43
- - the full standard library and real `import` semantics;
44
- - last-expression capture;
45
- - a namespace that genuinely survives errors, instead of a script string passed
46
- to `exec`.
40
+ - **rich, real tracebacks** instead of a wrapped `except`;
41
+ - **real interrupts** a stuck cell can be interrupted mid-run without losing the session;
42
+ - **last-expression capture** (a cell's final expression becomes its result);
43
+ - **a namespace that survives errors** a cell that throws leaves the kernel, and
44
+ everything defined before it, intact.
47
45
 
48
- And it is an honest isolation boundary: the kernel is a separate process from
49
- pi. A cell can raise, or consume memory, or spin, and pi keeps answering because
50
- pi is not the process that failed. The host restores from the last completed
51
- snapshot and tells the model exactly what came back in a `<rlm_engine_reset>`
52
- notice. More in `ARCHITECTURE.md`.
46
+ It is also an honest isolation boundary. The kernel is its own process, not part of pi. A
47
+ cell that raises leaves pi answering and the namespace intact, because pi is not the process
48
+ that failed. A cell that wedges the *whole* kernel instead stops cells from running until
49
+ the next call notices the dead kernel and rebuilds it from the last completed snapshot.
50
+ Either way the result carries a `<repl_engine_reset>` notice that names what the rebuild
51
+ revived and what it lost, so the model re-verifies before trusting state that may be gone.
52
+ (How that machinery works is in `ARCHITECTURE.md`.)
53
53
 
54
54
  ## The venv as part of the design
55
55
 
56
- Because the evaluator is real Python, it needs a real Python environment with
57
- `ipykernel` + `jupyter_client`. You cannot conjure that from nothing.
56
+ Because the evaluator is real Python, it needs a real Python environment with `ipykernel`.
57
+ You cannot conjure that from a script; it is a hard runtime dependency.
58
58
 
59
- The package's `postinstall` creates it once, at a stable user path
60
- (`~/.pi/agent/pi-repl/venv`), so a `pi install` ends with a working evaluator
61
- and updates do not lose it (the venv is outside the ephemeral package dir
62
- where it would vanish). At runtime the host resolves the interpreter in a
63
- short deterministic order (repo venv, cwd venv, the install venv, then
64
- `$PYTHON`/`python3`). The system interpreter is the fallback, never the
65
- assumption, because the whole tool quietly breaks if it silently runs in the
66
- wrong environment. This is a fact the toolbox functions' test in `help()`
67
- exist to keep visible.
59
+ The package's `postinstall` creates it once, at a stable per-user path
60
+ (`~/.pi/agent/pi-repl/venv`), so a `pi install` normally ends with a working evaluator. If
61
+ `python3` or the network is missing at install time, `postinstall` prints a clear notice and
62
+ the host falls back to `$PYTHON` or `python3` at runtime. Updates never lose it, because the
63
+ venv lives outside the ephemeral package directory where it would vanish on every update.
64
+
65
+ At runtime the host resolves the interpreter in a short, fixed order:
66
+
67
+ 1. the repo's own `.venv` (development)
68
+ 2. a venv in the current directory (per-project)
69
+ 3. the install venv at `~/.pi/agent/pi-repl/venv`
70
+ 4. `$PYTHON`, then `python3` (the fallback)
71
+
72
+ The first one that exists wins. The system interpreter is the fallback, never the
73
+ assumption, because the whole tool quietly breaks if it silently runs in the wrong
74
+ environment. The tool's prompt tells the model this, so it does not leak the wrong
75
+ assumption into commands.
68
76
 
69
77
  ## Trust, not a sandbox
70
78
 
71
- This is deliberately **not** a sandbox. The kernel runs with your user's
72
- permissions, can read and write anywhere you can, and the toolbox is trusted
73
- as written. If you need to guard against an untrusted model, this is the wrong
74
- tool: reach for a real sandbox the way you would for any untrusted user code.
75
- The philosophy prefers a sharp, honest tool over a pretend-safe one.
79
+ This is deliberately **not a sandbox.** The kernel runs with your user's permissions, can
80
+ read and write anywhere you can, and helpers are trusted as written. If you need to guard
81
+ against an untrusted model, this is the wrong tool — reach for a real sandbox the way you
82
+ would for any untrusted code. The philosophy here prefers a sharp, honest tool over a
83
+ pretend-safe one.
76
84
 
77
85
  ## What it isn't
78
86
 
79
- - A subagent framework. There is no `rlm.run`. To delegate, the model spawns
80
- a process with `bash()`.
81
- - A drop-in pi-tool parcel. It exposes one `execute` tool; everything else is
82
- inside that workspace.
83
- - A replacement for your own editing/browsing tools required. It is there
84
- when the working style above is worth it, dormant otherwise.
87
+ - **A subagent framework.** There is no `repl.run`. To delegate, the model spawns a process
88
+ with `subprocess.run` (or `!cmd` / `%%bash`).
89
+ - **A pi tool-rack.** It exposes one `execute` tool; everything else lives inside that
90
+ workspace.
91
+ - **A replacement for your own editing and browsing tools.** It is there when the working
92
+ style above is worth it, and dormant otherwise.
85
93
 
86
- The trade-off is real and accepted: the agent pays a little more per-call to
87
- hold a heavier environment, and it gets back far fewer re-reads, fewer
88
- transcript round-trips, and sharper small-model behavior.
94
+ The trade-off is real and accepted: the agent pays a little more per call to hold a heavier
95
+ environment, and gets back far fewer re-reads, fewer transcript round-trips, and sharper
96
+ small-model behaviour.
package/index.ts CHANGED
@@ -4,8 +4,7 @@ import { basename, join } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
6
  import { Type } from "typebox";
7
- import { EngineBusyError, EngineManager } from "./src/engine/index.js";
8
- import { loadConfig } from "./src/extension/config.js";
7
+ import { EngineManager } from "./src/engine/index.js";
9
8
  import { ExecuteCellComponent, type ExecuteDetails, type ExecuteRenderState } from "./src/extension/render.js";
10
9
  import { EngineLifecycle, summarizeNames } from "./src/extension/session-engine.js";
11
10
  import { EXECUTE_DESCRIPTION, buildExecutePromptGuidelines, EXECUTE_PROMPT_SNIPPET } from "./src/extension/tool-meta.js";
@@ -45,8 +44,6 @@ function composeErrorLines(error: { name: string; message: string; stack: string
45
44
  return stack[0]?.trim() === header ? stack : [header, ...stack];
46
45
  }
47
46
 
48
- const CFG = loadConfig();
49
-
50
47
  export default function (pi: ExtensionAPI) {
51
48
  pi.registerFlag("repl", {
52
49
  type: "boolean",
@@ -67,9 +64,6 @@ export default function (pi: ExtensionAPI) {
67
64
  const stateDir = join(homedir(), ".pi", "agent", "pi-repl", "state", sessionKey ?? "ephemeral");
68
65
  return new EngineManager({
69
66
  cwd,
70
- pythonPath: CFG.pythonPath,
71
- timeoutMs: CFG.timeoutMs,
72
- toolboxDir: CFG.toolboxDir,
73
67
  // --- snapshots are keyed to a session file; ephemeral sessions get none ---
74
68
  snapshot: sessionKey ? { path: join(stateDir, "namespace.snapshot") } : undefined,
75
69
  });
@@ -130,7 +124,7 @@ export default function (pi: ExtensionAPI) {
130
124
  label: "execute",
131
125
  description: EXECUTE_DESCRIPTION,
132
126
  promptSnippet: EXECUTE_PROMPT_SNIPPET,
133
- promptGuidelines: buildExecutePromptGuidelines(CFG.toolboxDir),
127
+ promptGuidelines: buildExecutePromptGuidelines(),
134
128
  parameters: executeSchema,
135
129
  renderShell: "self",
136
130
  renderCall(args, theme, context) {
@@ -189,22 +183,10 @@ export default function (pi: ExtensionAPI) {
189
183
  pendingErrorResults.set(toolCallId, { details });
190
184
  throw new Error(text || "(no output)");
191
185
  }
192
- if (r.status === "aborted") {
193
- // --- a busy kernel can't be interrupted mid-cell; discard+rebuild so the next run doesn't queue ---
194
- await lifecycle.discard();
195
- }
186
+ // --- an aborted cell was interrupted, not wedged: the kernel keeps running, nothing to discard ---
196
187
  return result;
197
188
  } catch (error) {
198
- if (error instanceof EngineBusyError) {
199
- // --- discard the wedged engine; the next cell revives from the last snapshot ---
200
- await lifecycle.discard();
201
- throw new Error(
202
- "The evaluator was wedged by a previously interrupted cell and has been killed. " +
203
- "Run the next cell to get a fresh evaluator revived from the last snapshot; " +
204
- "anything newer than that snapshot is gone, so re-verify variables before reusing them.",
205
- );
206
- }
207
- // --- a guest that died leaves the engine shutdown; drop it so the next cell rebuilds fresh ---
189
+ // --- a kernel that died (or was killed as the abort backstop) is down; drop it so the next cell rebuilds ---
208
190
  if (m.isRunning === false) {
209
191
  await lifecycle.discard();
210
192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-repl-py",
3
- "version": "0.1.1",
3
+ "version": "0.2.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": [
@@ -13,7 +13,7 @@
13
13
  "license": "MIT",
14
14
  "repository": {
15
15
  "type": "git",
16
- "url": "https://github.com/k3-2o/pi-repl-py.git"
16
+ "url": "git+https://github.com/k3-2o/pi-repl-py.git"
17
17
  },
18
18
  "homepage": "https://github.com/k3-2o/pi-repl-py",
19
19
  "bugs": {
@@ -35,14 +35,13 @@
35
35
  "engines": {
36
36
  "node": ">=22"
37
37
  },
38
- "scripts": {
38
+ "scripts": {
39
39
  "typecheck": "tsc --noEmit",
40
40
  "format": "biome format --write .",
41
41
  "lint": "biome check .",
42
42
  "knip": "knip",
43
- "check": "tsc --noEmit && biome check . && npm run test:ts && npm run test:py",
43
+ "check": "biome check . && npm run test:ts",
44
44
  "test:ts": "bun test test/units.test.ts test/preview-core.test.ts",
45
- "test:py": ".venv/bin/python -m pytest test/guest_contract.py -q",
46
45
  "test:integ": "bun test test/engine.integration.test.ts",
47
46
  "postinstall": "node scripts/setup-venv.mjs"
48
47
  },
@@ -61,4 +60,4 @@
61
60
  "typebox": "^1.3.11",
62
61
  "typescript": "^5.6.0"
63
62
  }
64
- }
63
+ }
@@ -1,18 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * postinstall: build the stable per-user Python venv the guest evaluator needs.
3
+ * postinstall: build the stable per-user Python venv the evaluator needs.
4
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:
5
+ * The evaluator (src/engine/kernel.ts) drives a real ipykernel directly over
6
+ * the Jupyter protocol; `ipykernel` is the only hard runtime dependency. When
7
+ * this is installed as a pi package there is no repo-local `.venv` (gitignored
8
+ * and excluded from the npm tarball), so we create one at a stable path the
9
+ * engine also knows about:
10
10
  *
11
11
  * ~/.pi/agent/pi-repl/venv/bin/python3
12
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).
13
+ * HELPERS live ONLY in the user-owned config dir:
14
+ *
15
+ * ~/.pi/agent/pi-repl/helpers/
16
+ *
17
+ * There is no helper/config folder anywhere in this package (no
18
+ * src/engine/helpers, no templates/). The helpers dir is created if missing,
19
+ * but no default helpers are seeded — the REPL itself already provides shell
20
+ * (via `!cmd`, `%%bash`, `subprocess`) and file IO (via `open`, `pathlib`).
21
+ * Users add their own helper .py files freely; existing files are never clobbered.
22
+ *
23
+ * Failures are non-fatal: if there's no system python3 or no network we print a
24
+ * clear notice and let the engine fall back to '$PYTHON' or 'python3' at runtime.
16
25
  */
17
26
 
18
27
  import { execSync } from "node:child_process";
@@ -22,13 +31,14 @@ import { join } from "node:path";
22
31
 
23
32
  const VENV_DIR = join(homedir(), ".pi", "agent", "pi-repl", "venv");
24
33
  const PY = join(VENV_DIR, "bin", "python3");
25
- const DEPS = ["ipykernel", "jupyter_client"];
34
+ const DEPS = ["ipykernel"];
35
+ const HELPERS_DIR = join(homedir(), ".pi", "agent", "pi-repl", "helpers");
26
36
 
27
- function log(msg) {
28
- process.stdout.write(`[pi-repl] ${msg}\n`);
37
+ function log(m) {
38
+ process.stdout.write(`[pi-repl] ${m}\n`);
29
39
  }
30
- function warn(msg) {
31
- process.stderr.write(`[pi-repl] warning: ${msg}\n`);
40
+ function warn(m) {
41
+ process.stderr.write(`[pi-repl] warning: ${m}\n`);
32
42
  }
33
43
 
34
44
  function findSystemPython() {
@@ -41,8 +51,20 @@ function findSystemPython() {
41
51
  return null;
42
52
  }
43
53
 
54
+ // ---------------------------------------------------------------- helpers dir
55
+ // The helpers dir is user-owned. We create it empty on install. The REPL
56
+ // provides shell and file IO natively; helpers are for things the user adds
57
+ // themselves (e.g. web_search, custom skills). Existing files are never clobbered.
58
+ function seedHelpersDir() {
59
+ try {
60
+ mkdirSync(HELPERS_DIR, { recursive: true });
61
+ } catch (e) {
62
+ warn(`could not create the helpers dir (${e?.message ?? e}); custom helpers won't preload.`);
63
+ }
64
+ }
65
+
44
66
  function main() {
45
- // Already built from a previous install/run.
67
+ seedHelpersDir();
46
68
  if (existsSync(PY)) {
47
69
  log(`venv already present at ${VENV_DIR}`);
48
70
  return;
@@ -51,7 +73,7 @@ function main() {
51
73
  if (!systemPython) {
52
74
  warn(
53
75
  `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.`
76
+ `Install python3 and run '${PY.slice(-60)} -m venv' manually, or set $PYTHON to point at one.`
55
77
  );
56
78
  return;
57
79
  }
@@ -64,7 +86,7 @@ function main() {
64
86
  log("done. The pi-repl evaluator will use this venv.");
65
87
  } catch (error) {
66
88
  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.");
89
+ warn("You must install ipykernel in that venv before the evaluator runs.");
68
90
  }
69
91
  }
70
92