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.
- package/docs/ARCHITECTURE.md +4 -2
- package/docs/helpers.md +13 -0
- package/index.ts +40 -38
- 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 +93 -95
- 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 +42 -40
- package/src/extension/skill-hook.ts +1 -2
- package/src/extension/state-layout.ts +30 -17
- package/src/extension/tool-meta.ts +2 -9
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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;
|
|
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
|
|
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
|
-
// ---
|
|
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
|
-
// ---
|
|
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
|
|
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
|
|
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
|
|
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);
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
)
|
|
151
|
-
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 };
|
|
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(
|
|
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
|
|
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
|
|
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)
|
|
207
|
-
|
|
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.
|
|
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) {
|