pi-repl-py 0.2.8 → 0.4.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.
@@ -57,15 +57,17 @@ That path is stable across updates because it sits outside the package's own dir
57
57
  which npm replaces on each update. If `python3` or the network is missing at install time,
58
58
  `postinstall` prints a clear notice and the host falls back at runtime.
59
59
 
60
- At spawn, `resolvePythonPath` picks the interpreter in this order:
60
+ At spawn, `resolvePythonPath` uses exactly one interpreter, the install venv:
61
61
 
62
- 1. the repo's own `.venv` (development)
63
- 2. a venv in the current directory (per-project)
64
- 3. `~/.pi/agent/pi-repl/venv` (package install)
65
- 4. `$PYTHON`, then `python3` (the fallback)
62
+ 1. `~/.pi/agent/pi-repl/venv` (the package install)
63
+ 2. `$PYTHON`, then `python3` (only if the install venv is missing)
66
64
 
67
- The first one that exists wins. The tool's prompt tells the model it runs in a project-local
68
- venv, not the system interpreter, so it does not leak the wrong assumption into commands.
65
+ It deliberately does NOT auto-pick the repo's or current directory's `.venv`: a per-project
66
+ venv is not guaranteed to have `ipykernel`, so preferring it (as earlier versions did) made
67
+ the kernel die from a `ModuleNotFoundError` whenever cwd happened to contain such a venv. The
68
+ kernel therefore always runs in the stable install environment. The kernel starts in the
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.
69
71
 
70
72
  ## The kernel client
71
73
 
package/index.ts CHANGED
@@ -4,9 +4,10 @@ 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 { withSkillsBlock } from "./src/extension/skill-hook.js";
7
8
  import { EngineManager } from "./src/engine/index.js";
8
9
  import { ExecuteCellComponent, type ExecuteDetails, type ExecuteRenderState } from "./src/extension/render.js";
9
- import { EngineLifecycle, summarizeNames } from "./src/extension/session-engine.js";
10
+ import { EngineLifecycle } from "./src/extension/session-engine.js";
10
11
  import { EXECUTE_DESCRIPTION, buildExecutePromptGuidelines, EXECUTE_PROMPT_SNIPPET } from "./src/extension/tool-meta.js";
11
12
 
12
13
  const executeSchema = Type.Object({
@@ -87,23 +88,13 @@ export default function (pi: ExtensionAPI) {
87
88
  }
88
89
  // --- active: the whole surface collapses to the one tool ---
89
90
  pi.setActiveTools(["execute"]);
90
- // --- revive the previous run; the engine also self-revives if session_start was skipped ---
91
+ // --- warm the engine (and its revive) in the background; no popup. ---
92
+ // --- acquire() dedupes, so the first execute awaits this same in-flight boot ---
91
93
  location = { cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile() ?? undefined };
92
- const { restore } = await lifecycle.acquire("startup");
93
- if (restore && restore.restored.length > 0) {
94
- pi.sendMessage({
95
- customType: "pi-repl-restore",
96
- content: `Revived ${restore.restored.length} variable(s) from the previous run: ${summarizeNames(restore.restored, 8)}${
97
- restore.failed.length > 0
98
- ? `. Failed: ${summarizeNames(
99
- restore.failed.map((f) => f.name),
100
- 8,
101
- )}`
102
- : ""
103
- }`,
104
- display: true,
105
- });
106
- }
94
+ void lifecycle.acquire("startup").catch(() => {
95
+ // --- boot/revive handled on the execute path; swallow so a background warm can never
96
+ // --- surface an unhandled rejection and the model never needs the restore notice ---
97
+ });
107
98
  });
108
99
 
109
100
  pi.on("session_shutdown", async () => {
@@ -119,6 +110,16 @@ export default function (pi: ExtensionAPI) {
119
110
  return { content: event.content, details: stashed.details, isError: true };
120
111
  });
121
112
 
113
+ // --- pi gates skills on the read tool (absent in repl); re-emit them via withSkillsBlock. ---
114
+ pi.on("before_agent_start", (event) => {
115
+ if (!active()) return;
116
+ const systemPrompt = withSkillsBlock(
117
+ event.systemPrompt,
118
+ event.systemPromptOptions?.skills ?? [],
119
+ );
120
+ return systemPrompt === undefined ? undefined : { systemPrompt };
121
+ });
122
+
122
123
  pi.registerTool<typeof executeSchema, ExecuteDetails, Partial<ExecuteRenderState>>({
123
124
  name: "execute",
124
125
  label: "execute",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-repl-py",
3
- "version": "0.2.8",
3
+ "version": "0.4.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": [
@@ -5,21 +5,16 @@
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, join } from "node:path";
8
- import { fileURLToPath } from "node:url";
9
8
  import { KernelClient } from "./kernel.js";
10
9
 
11
- const GUEST_REL = fileURLToPath(new URL("./kernel.js", import.meta.url));
12
-
13
10
  function installVenvPython(): string {
14
11
  return join(homedir(), ".pi", "agent", "pi-repl", "venv", "bin", "python3");
15
12
  }
16
13
 
17
14
  /** Prefer a venv with ipykernel; else $PYTHON or python3. */
18
- function resolvePythonPath(cwd: string | undefined): string {
19
- const repoVenv = join(dirname(GUEST_REL), "..", "..", ".venv", "bin", "python3");
20
- if (existsSync(repoVenv)) return repoVenv;
21
- const cwdVenv = cwd ? join(cwd, ".venv", "bin", "python3") : "";
22
- if (cwdVenv && existsSync(cwdVenv)) return cwdVenv;
15
+ function resolvePythonPath(_cwd: string | undefined): string {
16
+ // Only ever use the install venv: a project or repo `.venv` may lack ipykernel and
17
+ // shadow the good environment, killing the kernel. No auto-picking.
23
18
  const installVenv = installVenvPython();
24
19
  if (existsSync(installVenv)) return installVenv;
25
20
  return process.env.PYTHON ?? "python3";
@@ -54,7 +54,14 @@ export interface SnapshotReply {
54
54
  // --- boot preload: exec each helper; ls()/help() are gone, discovery is globals() ---
55
55
 
56
56
  /** Read the helpers dir (same skip rules as the extension's prompt loader). */
57
- export function readHelperSources(dir?: string): { name: string; source: string }[] {
57
+ /** A directory for the kernel to start in; if the requested cwd is gone, fall back to the
58
+ * evaluator's own cwd rather than letting spawn() die with ENOENT. A deleted project dir is
59
+ * a real resume case (pi guards it too) — the kernel must still come up. */
60
+ function resolveCwd(requested?: string): string {
61
+ if (requested && existsSync(requested)) return requested;
62
+ return process.cwd();
63
+ }
64
+ function readHelperSources(dir?: string): { name: string; source: string }[] {
58
65
  // --- one fixed dir, resolved like the prompt side (helpers.ts) so both always agree ---
59
66
  const d = dir ?? join(homedir(), ".pi", "agent", "pi-repl", "helpers");
60
67
  if (!existsSync(d)) return [];
@@ -179,7 +186,7 @@ export class KernelClient {
179
186
  static async start(pythonPath: string, opts: KernelOptions = {}): Promise<KernelClient> {
180
187
  const connPath = join(tmpdir(), `pi-repl-kernel-${randomUUID()}.json`);
181
188
  const child = spawn(pythonPath, ["-m", "ipykernel", "-f", connPath, "--no-stdout"], {
182
- cwd: opts.cwd,
189
+ cwd: resolveCwd(opts.cwd),
183
190
  env: { ...process.env, ...(opts.env ?? {}) },
184
191
  stdio: ["ignore", "pipe", "pipe"],
185
192
  });
@@ -6,14 +6,17 @@
6
6
  // more signal; the machine reads every line every turn.
7
7
 
8
8
  export const executeToolDescription =
9
- "You have one tool: a real `ipython` kernel that stays alive across cells and turns. " +
10
- "This persistent Python workspace is your only surface it does the work of bash, read, write, edit, " +
11
- "search, and file handling, and everything you define (variables, imports, helpers loaded from " +
12
- "`~/.pi/agent/pi-repl/helpers/`) survives for reuse in later cells. A cell returns its final expression; " +
13
- "printed output is captured separately.";
9
+ "Execute Python cells in a persistent ipython kernel that stays alive across cells and turns. " +
10
+ "It replaces the default read, bash, edit, write, and search tools file work, shell commands, and " +
11
+ "searches all run as Python. Everything you define (variables, imports, and helpers preloaded into the " +
12
+ "workspace namespace) survives for reuse in later cells. A cell returns its final expression; printed " +
13
+ "output is captured separately. Oversized output is truncated: 1,000,000 characters per cell, 4,096 per " +
14
+ "line. Reads are expensive — every printed value enters the context, so hold artifacts in variables, " +
15
+ "parse before printing, and print only the small bounded slice the next decision needs. Keep cells lean; " +
16
+ "full-file dumps and raw result lists bloat the conversation.";
14
17
 
15
18
  export const executePromptSnippet =
16
- "The persistent Python workspace is your only tool: keep artifacts in variables across cells for reuse, use the loaded helpers, prefer surgical reads/edits over full-file dumps and rewrites, and parse before you print so context stays lean.";
19
+ "Execute Python cells in a persistent ipython kernel (replaces read, bash, edit, write, and search; state survives across cells and turns)";
17
20
 
18
21
  // --- the workspace doctrine riding the execute tool ---
19
22
  export function buildPromptGuidelines(preloaded: string[]): string[] {
@@ -45,7 +48,7 @@ export function buildPromptGuidelines(preloaded: string[]): string[] {
45
48
  ...(preloaded.length
46
49
  ? [
47
50
  "## Helpers",
48
- "These helpers are given to you by the user to use directly (loaded from `~/.pi/agent/pi-repl/helpers/`). Descriptions appear below.",
51
+ "These helpers are already defined in the workspace namespace. Use them by name as you would any other loaded function, class, or variable. Their code already executed at kernel boot. Descriptions appear below.",
49
52
  "",
50
53
  ...preloaded,
51
54
  "",
@@ -3,7 +3,7 @@
3
3
  import type { RestoreResult } from "../engine/index.js";
4
4
 
5
5
  /** Show enough names to orient, then count the rest (a revive can carry hundreds). */
6
- export function summarizeNames(names: readonly string[], limit: number): string {
6
+ function summarizeNames(names: readonly string[], limit: number): string {
7
7
  if (names.length <= limit) return names.join(", ");
8
8
  return `${names.slice(0, limit).join(", ")} … and ${names.length - limit} more`;
9
9
  }
@@ -0,0 +1,22 @@
1
+ // --- skills cannot reach the prompt in --repl: pi gates <available_skills> on the read tool
2
+ // (hasRead), which repl doesn't have. Re-emit them with pi's own formatter in pi's slot. ---
3
+ import { formatSkillsForPrompt, type Skill } from "@mariozechner/pi-coding-agent";
4
+
5
+ const CWD_MARKER = "\nCurrent working directory:"; // skills sit just before this, pi's last line
6
+ const READ_LINE = "Use the read tool to load a skill's file when the task matches its description."; // canon line
7
+ const EXECUTE_LINE = "Load a skill's SKILL.md file contents via execute (read the file with Python)."; // repl has no read
8
+
9
+ /** The prompt with the skills block in pi's slot; undefined if nothing should change. */
10
+ export function withSkillsBlock(
11
+ prompt: string,
12
+ skills: Skill[],
13
+ alreadyPresent = prompt.includes("<available_skills>"),
14
+ ): string | undefined {
15
+ if (skills.length === 0) return undefined;
16
+ let extra = formatSkillsForPrompt(skills);
17
+ if (!extra) return undefined;
18
+ if (alreadyPresent) return undefined;
19
+ extra = extra.replace(READ_LINE, EXECUTE_LINE);
20
+ const idx = prompt.indexOf(CWD_MARKER);
21
+ return idx === -1 ? prompt + extra : prompt.slice(0, idx) + extra + prompt.slice(idx);
22
+ }