pi-repl-py 0.6.14 → 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.
@@ -107,9 +107,11 @@ mid-session rebuild (kernel death) forces the restore before the cell that found
107
107
  Functions and classes defined in cells are captured by source and re-executed on restore (plain
108
108
  pickle cannot revive them in `__main__`); bindings that still fail are reported by name, never
109
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; session dirs are
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
111
113
  pruned to the newest 25, and dirs whose conversation file no longer exists are swept entirely —
112
- deleting a conversation deletes its snapshots. "ephemeral" and the live session are exempt.
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.
113
115
 
114
116
  A revive that never completes (a poisoned pickle) is bounded by an engine restore-cell watchdog
115
117
  (`PI_REPL_BOOT_TIMEOUT_MS`, default 90s): the kernel is killed and the restore marked skipped —
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,10 +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 { conversationName, resolveStateDir } from "./src/extension/state-layout.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";
12
13
  import { EXECUTE_DESCRIPTION, buildExecutePromptGuidelines, EXECUTE_PROMPT_SNIPPET } from "./src/extension/tool-meta.js";
13
14
 
14
15
  const executeSchema = Type.Object({
@@ -59,44 +60,38 @@ export default function (pi: ExtensionAPI) {
59
60
  const pendingErrorResults = new Map<string, { details: ExecuteDetails }>();
60
61
 
61
62
  const lifecycle = new EngineLifecycle<EngineManager>({
62
- // --- boot deadline: bounds kernel start + helpers preload (recovery is a background
63
- // --- quiet-gap job, bounded by the engine's own restore-cell watchdog). An npm update
64
- // --- swaps the venv and helpers under a live kernel, and the first boot after it can
65
- // --- wedge; without this the first cell hangs forever, because acquire() dedupes onto
66
- // --- the same hung boot. ---
63
+ // --- bound the boot: a wedged first boot would hang every cell (acquire() dedupes onto it) ---
67
64
  bootTimeoutMs: Number(process.env.PI_REPL_BOOT_TIMEOUT_MS ?? 90_000) || 90_000,
68
65
  create(skipRestore = false) {
69
66
  const { cwd, sessionFile } = location;
70
- // --- kernel namespace state lives under ~/.pi/agent/pi-repl/state, keyed by
71
- // --- <project-slug>__<conversation>, so it never clutters the project and two
72
- // --- conversations can never share a snapshot dir (resolveStateDir migrates any
73
- // --- pre-slug legacy dir on start). Ephemeral sessions get no snapshot at all. ---
67
+ // --- state lives under ~/.pi/agent/pi-repl/state/<slug>__<conv>; conversations never share a snapshot; ephemeral sessions get none ---
74
68
  const stateRoot = join(homedir(), ".pi", "agent", "pi-repl", "state");
75
69
  let snapshot: { path: string } | undefined;
70
+ let forkInherited = false;
76
71
  let currentDir: string | undefined;
77
72
  if (sessionFile) {
78
73
  const { dir, snapshotPath } = resolveStateDir(stateRoot, sessionFile);
79
74
  currentDir = basename(dir);
80
75
  snapshot = { path: snapshotPath };
76
+ // --- a /fork'd conversation inherits the parent's last namespace (copied once into the fork's own key) ---
77
+ try {
78
+ forkInherited = inheritForkSnapshot(stateRoot, sessionFile, snapshotPath);
79
+ } catch {}
81
80
  // --- keep the state root from growing one dir per session forever; the live dir is exempt ---
82
81
  try {
83
82
  pruneSnapshotDirs(stateRoot, 25, currentDir);
84
83
  } catch {}
85
- // --- cascade deletions: if a conversation is deleted, its snapshots die with it.
86
- // --- sessionFile is sessions/<project-root>/<name>.jsonl, so the sessions root is
87
- // --- two parent hops up; dirs whose conversation file exists in no project root
88
- // --- (and that aren't this session or the ephemeral fallback) are swept, in both
89
- // --- the legacy bare-name and slug-keyed formats. ---
84
+ // --- sweep state dirs whose conversation file exists in no project root: deleting a conversation deletes its snapshots (both dir formats) ---
90
85
  try {
91
86
  pruneOrphanedSnapshotDirs(stateRoot, sessionFile ? dirname(dirname(sessionFile)) : undefined, currentDir);
92
87
  } catch {}
93
88
  }
94
89
  return new EngineManager({
95
90
  cwd,
96
- // --- snapshots are keyed to the conversation; ephemeral sessions get none.
97
- // --- skipRestore is true on the lifecycle's retry after a wedged boot. ---
91
+ // --- snapshots are per-conversation; skipRestore marks the wedged-boot retry ---
98
92
  snapshot,
99
93
  skipRestore,
94
+ forkInherited,
100
95
  });
101
96
  },
102
97
  async dispose(engine) {
@@ -116,15 +111,12 @@ export default function (pi: ExtensionAPI) {
116
111
  pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "execute"));
117
112
  return;
118
113
  }
119
- // --- active: the whole surface collapses to the one tool ---
120
114
  pi.setActiveTools(["execute"]);
121
- // --- warm the engine (and its revive) in the background; no popup. ---
122
- // --- 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 ---
123
116
  location = { cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile() ?? undefined };
124
117
  const sessionKey = location.sessionFile ? conversationName(location.sessionFile) : undefined;
125
118
  void lifecycle.acquire("startup", sessionKey).catch(() => {
126
- // --- boot/revive handled on the execute path; swallow so a background warm can never
127
- // --- surface an unhandled rejection. A resume's notice lands on the first cell. ---
119
+ // --- swallow the warm boot's rejection: boot/revive are handled on the execute path ---
128
120
  });
129
121
  });
130
122
 
@@ -141,14 +133,14 @@ export default function (pi: ExtensionAPI) {
141
133
  return { content: event.content, details: stashed.details, isError: true };
142
134
  });
143
135
 
144
- // --- pi gates skills on the read tool (absent in repl); re-emit them via withSkillsBlock. ---
145
- 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) => {
146
138
  if (!active()) return;
147
- const systemPrompt = withSkillsBlock(
148
- event.systemPrompt,
149
- event.systemPromptOptions?.skills ?? [],
150
- );
151
- return systemPrompt === undefined ? undefined : { 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 };
152
144
  });
153
145
 
154
146
  pi.registerTool<typeof executeSchema, ExecuteDetails, Partial<ExecuteRenderState>>({
@@ -156,7 +148,7 @@ export default function (pi: ExtensionAPI) {
156
148
  label: "execute",
157
149
  description: EXECUTE_DESCRIPTION,
158
150
  promptSnippet: EXECUTE_PROMPT_SNIPPET,
159
- promptGuidelines: buildExecutePromptGuidelines(process.cwd()),
151
+ promptGuidelines: buildExecutePromptGuidelines(),
160
152
  parameters: executeSchema,
161
153
  renderShell: "self",
162
154
  renderCall(args, theme, context) {
@@ -184,10 +176,8 @@ export default function (pi: ExtensionAPI) {
184
176
  throw new Error("pi-repl is dormant in this session. Start pi with --repl (or PI_REPL_FORCE=1) to use execute.");
185
177
  }
186
178
  if (ctx?.cwd) location = { cwd: ctx.cwd, sessionFile: ctx.sessionManager?.getSessionFile?.() ?? undefined };
187
- // --- establish the body slot at call time so Ctrl+O can expand a live (still-awaiting) stream;
188
- // --- 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 ---
189
180
  onUpdate?.({ content: [], details: {} });
190
- // --- previous engine died mid-session; acquire revives it ---
191
181
  const sessionKey = location.sessionFile ? conversationName(location.sessionFile) : undefined;
192
182
  const { engine: m } = await lifecycle.acquire("cell", sessionKey);
193
183
  try {
@@ -200,11 +190,23 @@ export default function (pi: ExtensionAPI) {
200
190
  onUpdate?.({ content: [{ type: "text", text: streamed }], details: {} });
201
191
  },
202
192
  });
203
- // --- reset notice leads so the model reads that its namespace was rebuilt; the
204
- // --- 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 ---
205
194
  const reset = lifecycle.takeResetNotice();
206
- if (reset?.notice) ctx?.ui?.notify?.(formatResetToast(reset.origin, reset.restore, reset.wedged), "info");
207
- const sections = [reset?.notice, r.stdout, r.stderr, r.result];
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
+ ];
208
210
  const errorLines = r.error ? composeErrorLines(r.error) : undefined;
209
211
  if (r.status === "error" && errorLines) sections.push(errorLines.join("\n"));
210
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.6.14",
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": [
@@ -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 exists but can't import ipykernel is broken. Never trust the
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
- // ---------------------------------------------------------------- helpers dir
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
- // A real failure must not exit 0 with a broken venv. npm/pi will see the
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) {