pi-repl-py 0.1.0 → 0.1.1
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/README.md +17 -5
- package/{ARCHITECTURE.md → docs/ARCHITECTURE.md} +6 -5
- package/docs/how-to-functions.md +6 -5
- package/docs/philosophy.md +1 -1
- package/index.ts +5 -10
- package/package.json +9 -2
- package/scripts/setup-venv.mjs +2 -2
- package/src/engine/guest.py +4 -1
- package/src/engine/index.ts +15 -22
- package/src/engine/toolbox/edit.py +1 -1
- package/src/engine/toolbox/read.py +1 -1
- package/src/engine/toolbox/write.py +1 -1
- package/src/extension/config.ts +3 -4
- package/src/extension/preview/candidates.ts +205 -0
- package/src/extension/preview/descriptor.ts +28 -0
- package/src/extension/preview/index.ts +39 -0
- package/src/extension/preview/scan.ts +59 -0
- package/src/extension/preview/shell.ts +156 -0
- package/src/extension/preview/types.ts +23 -0
- package/src/extension/preview-core.ts +2 -518
- package/src/extension/prompt.ts +54 -0
- package/src/extension/render-core.ts +7 -23
- package/src/extension/tool-meta.ts +7 -50
- package/src/extension/toolbox.ts +34 -10
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ A plain `pi` session is untouched; the extension is dormant until `--repl` is pa
|
|
|
33
33
|
## Installing as a pi package
|
|
34
34
|
|
|
35
35
|
`npm install` runs a `postinstall` that creates the Python venv the evaluator needs, at a stable
|
|
36
|
-
per-user path (`~/.pi/agent/pi-repl
|
|
36
|
+
per-user path (`~/.pi/agent/pi-repl/venv`). If `python3` or the network is missing, it prints a
|
|
37
37
|
clear notice. How the interpreter is resolved is in [docs/philosophy.md](docs/philosophy.md).
|
|
38
38
|
|
|
39
39
|
## What you get
|
|
@@ -52,7 +52,19 @@ A small set of Python functions is preloaded into every kernel and surfaced to t
|
|
|
52
52
|
model through the `execute` tool's prompt guidance (their signatures + one-line
|
|
53
53
|
summaries are listed there, and `ls()`/`help()` discover them at runtime), so the
|
|
54
54
|
model can call `read`, `write`, `edit`, and `bash` without reimplementing them.
|
|
55
|
-
Set `toolboxDir` to point at your own folder.
|
|
55
|
+
Set `toolboxDir` to point at your own folder; its functions are loaded **in addition to** the built-ins, and a file there with the **same name** as a built-in overrides it.
|
|
56
|
+
|
|
57
|
+
Everything the extension keeps lives under one folder in your home directory:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
~/.pi/agent/pi-repl/
|
|
61
|
+
config.json settings (toolboxDir, pythonPath, timeoutMs)
|
|
62
|
+
venv/ the Python interpreter + ipykernel
|
|
63
|
+
functions/ your custom toolbox functions, if any
|
|
64
|
+
state/ per-session namespace snapshots
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
These functions are the evaluator's standard file-and-shell surface; the model reaches for them as its builtins and composes its own reusable tools on top.
|
|
56
68
|
|
|
57
69
|
The function list shown to the model is built when the `execute` tool is
|
|
58
70
|
registered, so changing the toolbox (adding/removing a file, renaming one with a
|
|
@@ -63,13 +75,13 @@ the kernel also only loads the toolbox at boot.
|
|
|
63
75
|
|
|
64
76
|
## Configuration
|
|
65
77
|
|
|
66
|
-
`~/.pi/agent/pi-repl.json` (or `$PI_REPL_CONFIG`) sets `toolboxDir`, `pythonPath`, and timeouts.
|
|
67
|
-
Full keys and path rules: [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
78
|
+
`~/.pi/agent/pi-repl/config.json` (or `$PI_REPL_CONFIG`) sets `toolboxDir`, `pythonPath`, and timeouts.
|
|
79
|
+
Full keys and path rules: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
68
80
|
|
|
69
81
|
## More
|
|
70
82
|
|
|
71
83
|
- Why this design: [docs/philosophy.md](docs/philosophy.md)
|
|
72
|
-
- How it works, the venv, config reference: [ARCHITECTURE.md](ARCHITECTURE.md)
|
|
84
|
+
- How it works, the venv, config reference: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
|
73
85
|
|
|
74
86
|
## It is not
|
|
75
87
|
|
|
@@ -26,7 +26,7 @@ When installed as a pi package, `npm install` runs `postinstall`
|
|
|
26
26
|
(`scripts/setup-venv.mjs`), which creates a stable per-user venv:
|
|
27
27
|
|
|
28
28
|
```
|
|
29
|
-
~/.pi/agent/pi-repl
|
|
29
|
+
~/.pi/agent/pi-repl/venv/bin/python3
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
That path is stable across updates because it sits outside the ephemeral
|
|
@@ -38,7 +38,7 @@ At spawn, `resolvePythonPath` chooses the interpreter in order:
|
|
|
38
38
|
|
|
39
39
|
1. the repo's own `.venv` (development)
|
|
40
40
|
2. a cwd-local `.venv` (project)
|
|
41
|
-
3. `~/.pi/agent/pi-repl
|
|
41
|
+
3. `~/.pi/agent/pi-repl/venv` (package install)
|
|
42
42
|
4. `$PYTHON` or `python3`
|
|
43
43
|
|
|
44
44
|
The first existing one wins. The model is told (via `help()`) that it runs in a
|
|
@@ -94,7 +94,8 @@ bare kernel still lets the model discover what is loaded.
|
|
|
94
94
|
After each successful cell the host schedules a debounced snapshot: it asks
|
|
95
95
|
the guest to pickle the kernel's globals (entry-by-entry so one bad value
|
|
96
96
|
costs only itself), and stores that as `namespace.snapshot` keyed to the
|
|
97
|
-
session file
|
|
97
|
+
session file under `~/.pi/agent/pi-repl/state/<session>/`. On a fresh engine it
|
|
98
|
+
restores, and whatever cannot be pickled
|
|
98
99
|
(live handles, some objects) is reported by name.
|
|
99
100
|
|
|
100
101
|
If the evaluator restarts, the result is prefixed with a `<rlm_engine_reset>`
|
|
@@ -125,12 +126,12 @@ Gate: `just check` = biome + bun test (host) + pytest (guest).
|
|
|
125
126
|
|
|
126
127
|
## Configuration reference
|
|
127
128
|
|
|
128
|
-
Loaded from `~/.pi/agent/pi-repl.json` (or `$PI_REPL_CONFIG`), first-found-wins, never
|
|
129
|
+
Loaded from `~/.pi/agent/pi-repl/config.json` (or `$PI_REPL_CONFIG`), first-found-wins, never
|
|
129
130
|
throws on a missing/malformed file.
|
|
130
131
|
|
|
131
132
|
| Key | Type / default | Meaning |
|
|
132
133
|
| --- | --- | --- |
|
|
133
|
-
| `toolboxDir` | string, optional | Directory of one-function-per-`.py` files that
|
|
134
|
+
| `toolboxDir` | string, optional | Directory of one-function-per-`.py` files that ADDS to the shipped `src/engine/toolbox` and, when a name collides, overrides that built-in. `~` is expanded; a bare relative path resolves from the process cwd (not reliable) — prefer an absolute path. |
|
|
134
135
|
| `pythonPath` | string, optional | The interpreter used to spawn the guest. Omit to use `resolvePythonPath` (see venv). |
|
|
135
136
|
| `timeoutMs` | number, 60000 | Per-cell execution timeout in ms. |
|
|
136
137
|
| `snapshotDebounceMs` | number, 1500 | Debounce after an ok cell before snapshot, in ms. |
|
package/docs/how-to-functions.md
CHANGED
|
@@ -17,15 +17,16 @@ By default the extension ships four (`read`, `write`, `edit`, `bash`) in
|
|
|
17
17
|
config:
|
|
18
18
|
|
|
19
19
|
```jsonc
|
|
20
|
-
// ~/.pi/agent/pi-repl.json
|
|
21
|
-
{ "toolboxDir": "~/.pi/agent/pi-repl
|
|
20
|
+
// ~/.pi/agent/pi-repl/config.json
|
|
21
|
+
{ "toolboxDir": "~/.pi/agent/pi-repl/functions" }
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Use an absolute path or a `~`-prefixed one (`~` expands to your home). A bare relative
|
|
25
25
|
path resolves from the process working directory, which is not reliable, so prefer
|
|
26
26
|
an absolute path for a stable per-user folder. Point `toolboxDir` at a directory and
|
|
27
|
-
every `*.py` there is loaded
|
|
28
|
-
|
|
27
|
+
every `*.py` there is loaded **in addition to** the shipped `read`/`write`/`edit`/`bash`.
|
|
28
|
+
If a file in your folder has the **same name** as a built-in (e.g. `read.py`), your
|
|
29
|
+
version wins and the built-in is ignored for that name.
|
|
29
30
|
|
|
30
31
|
## The file contract
|
|
31
32
|
|
|
@@ -38,7 +39,7 @@ Every toolbox file must:
|
|
|
38
39
|
A minimal, valid file:
|
|
39
40
|
|
|
40
41
|
```python
|
|
41
|
-
# pi-repl
|
|
42
|
+
# pi-repl/functions/summarize.py
|
|
42
43
|
function_description = """Return a first-sentence summary of a text."""
|
|
43
44
|
|
|
44
45
|
__all__ = ["summarize"]
|
package/docs/philosophy.md
CHANGED
|
@@ -57,7 +57,7 @@ Because the evaluator is real Python, it needs a real Python environment with
|
|
|
57
57
|
`ipykernel` + `jupyter_client`. You cannot conjure that from nothing.
|
|
58
58
|
|
|
59
59
|
The package's `postinstall` creates it once, at a stable user path
|
|
60
|
-
(`~/.pi/agent/pi-repl
|
|
60
|
+
(`~/.pi/agent/pi-repl/venv`), so a `pi install` ends with a working evaluator
|
|
61
61
|
and updates do not lose it (the venv is outside the ephemeral package dir
|
|
62
62
|
where it would vanish). At runtime the host resolves the interpreter in a
|
|
63
63
|
short deterministic order (repo venv, cwd venv, the install venv, then
|
package/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// --- pi-repl: one execute tool over Python; everything else runs as functions inside it ---
|
|
2
2
|
|
|
3
3
|
import { basename, join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
5
6
|
import { Type } from "typebox";
|
|
6
7
|
import { EngineBusyError, EngineManager } from "./src/engine/index.js";
|
|
@@ -62,7 +63,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
62
63
|
create() {
|
|
63
64
|
const { cwd, sessionFile } = location;
|
|
64
65
|
const sessionKey = sessionFile ? basename(sessionFile).replace(/\.jsonl$/, "") : undefined;
|
|
65
|
-
|
|
66
|
+
// --- kernel namespace state lives under ~/.pi/agent/pi-repl, keyed by session, so it never clutters the project ---
|
|
67
|
+
const stateDir = join(homedir(), ".pi", "agent", "pi-repl", "state", sessionKey ?? "ephemeral");
|
|
66
68
|
return new EngineManager({
|
|
67
69
|
cwd,
|
|
68
70
|
pythonPath: CFG.pythonPath,
|
|
@@ -81,11 +83,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
81
83
|
},
|
|
82
84
|
});
|
|
83
85
|
|
|
84
|
-
// --- no custom prompt: pi's default prompt stands
|
|
85
|
-
// the active set to just `execute`, so the default prompt's built-in
|
|
86
|
-
// read/bash/edit/write never appear. All REPL knowledge (description,
|
|
87
|
-
// promptSnippet, promptGuidelines) lives on the tool itself, not in a
|
|
88
|
-
// prompt builder. ---
|
|
86
|
+
// --- no custom prompt: pi's default prompt stands; active tools collapse to execute, so REPL knowledge lives on the tool ---
|
|
89
87
|
|
|
90
88
|
pi.on("session_start", async (_event, ctx) => {
|
|
91
89
|
if (!active()) {
|
|
@@ -192,10 +190,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
192
190
|
throw new Error(text || "(no output)");
|
|
193
191
|
}
|
|
194
192
|
if (r.status === "aborted") {
|
|
195
|
-
//
|
|
196
|
-
// single-threaded loop can't interrupt. Discard the engine so the
|
|
197
|
-
// NEXT run gets a fresh kernel instead of queuing behind the
|
|
198
|
-
// still-busy one (same class as the stalled-timeout recovery).
|
|
193
|
+
// --- a busy kernel can't be interrupted mid-cell; discard+rebuild so the next run doesn't queue ---
|
|
199
194
|
await lifecycle.discard();
|
|
200
195
|
}
|
|
201
196
|
return result;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-repl-py",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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": [
|
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
"repl"
|
|
12
12
|
],
|
|
13
13
|
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/k3-2o/pi-repl-py.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/k3-2o/pi-repl-py",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/k3-2o/pi-repl-py/issues"
|
|
21
|
+
},
|
|
14
22
|
"pi": {
|
|
15
23
|
"extensions": [
|
|
16
24
|
"./index.ts"
|
|
@@ -22,7 +30,6 @@
|
|
|
22
30
|
"docs",
|
|
23
31
|
"index.ts",
|
|
24
32
|
"README.md",
|
|
25
|
-
"ARCHITECTURE.md",
|
|
26
33
|
"LICENSE"
|
|
27
34
|
],
|
|
28
35
|
"engines": {
|
package/scripts/setup-venv.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* and excluded from the npm tarball), so we create one at a stable path that
|
|
9
9
|
* the engine also knows about:
|
|
10
10
|
*
|
|
11
|
-
* ~/.pi/agent/pi-repl
|
|
11
|
+
* ~/.pi/agent/pi-repl/venv/bin/python3
|
|
12
12
|
*
|
|
13
13
|
* Failures are non-fatal: if there's no system python3 or no network, we print
|
|
14
14
|
* a clear notice and let the engine fall back to '$PYTHON' or 'python3' at
|
|
@@ -20,7 +20,7 @@ import { existsSync, mkdirSync } from "node:fs";
|
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
|
|
23
|
-
const VENV_DIR = join(homedir(), ".pi", "agent", "pi-repl
|
|
23
|
+
const VENV_DIR = join(homedir(), ".pi", "agent", "pi-repl", "venv");
|
|
24
24
|
const PY = join(VENV_DIR, "bin", "python3");
|
|
25
25
|
const DEPS = ["ipykernel", "jupyter_client"];
|
|
26
26
|
|
package/src/engine/guest.py
CHANGED
|
@@ -83,7 +83,10 @@ def _toolbox_files(directory):
|
|
|
83
83
|
|
|
84
84
|
DEFAULT_TOOLBOX_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "toolbox")
|
|
85
85
|
TOOLBOX_DIR = os.environ.get("PI_TOOLBOX_DIR", "").strip()
|
|
86
|
-
|
|
86
|
+
# --- Merge: built-ins are supreme; a config toolboxDir adds others and overrides on name ---
|
|
87
|
+
_TOOLBOX_SRC = _toolbox_files(DEFAULT_TOOLBOX_DIR)
|
|
88
|
+
if TOOLBOX_DIR and os.path.expanduser(TOOLBOX_DIR) != DEFAULT_TOOLBOX_DIR:
|
|
89
|
+
_TOOLBOX_SRC.update(_toolbox_files(TOOLBOX_DIR))
|
|
87
90
|
|
|
88
91
|
# --- help/ls are part of the evaluator, not the toolbox ---
|
|
89
92
|
INTRINSIC = """
|
package/src/engine/index.ts
CHANGED
|
@@ -20,7 +20,7 @@ const GUEST_PATH = fileURLToPath(new URL("./guest.py", import.meta.url));
|
|
|
20
20
|
|
|
21
21
|
// --- venv created by the package postinstall ---
|
|
22
22
|
function installVenvPython(): string {
|
|
23
|
-
return join(homedir(), ".pi", "agent", "pi-repl
|
|
23
|
+
return join(homedir(), ".pi", "agent", "pi-repl", "venv", "bin", "python3");
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
// --- prefer a venv with ipykernel; else PYTHON or python3 ---
|
|
@@ -134,8 +134,7 @@ interface ActiveExecution {
|
|
|
134
134
|
reject(error: Error): void;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
//
|
|
138
|
-
// Guests are killed on host exit; the guest also self-exits on stdin EOF.
|
|
137
|
+
// --- process-wide cleanup: guests killed on exit; the guest self-exits on stdin EOF ---
|
|
139
138
|
|
|
140
139
|
const liveEngines = new Set<EngineManager>();
|
|
141
140
|
let cleanupHandlersInstalled = false;
|
|
@@ -190,7 +189,7 @@ export class EngineManager {
|
|
|
190
189
|
return this.state === "running";
|
|
191
190
|
}
|
|
192
191
|
|
|
193
|
-
|
|
192
|
+
//--- lifecycle ---
|
|
194
193
|
|
|
195
194
|
async start(): Promise<void> {
|
|
196
195
|
if (this.state === "shutdown") throw new Error("Engine has been shut down");
|
|
@@ -220,7 +219,7 @@ export class EngineManager {
|
|
|
220
219
|
PI_REPL_TIMEOUT_MS: String(this.timeoutMs),
|
|
221
220
|
PI_TOOLBOX_DIR: this.toolboxDir ?? "",
|
|
222
221
|
},
|
|
223
|
-
// fd 3
|
|
222
|
+
// --- fd 3 is the protocol pipe; stdout/stderr stay user output ---
|
|
224
223
|
stdio: ["pipe", "pipe", "pipe", "pipe"],
|
|
225
224
|
});
|
|
226
225
|
this.child = child;
|
|
@@ -247,8 +246,7 @@ export class EngineManager {
|
|
|
247
246
|
}
|
|
248
247
|
this.protocolReader = createInterface({ input: protocolStream });
|
|
249
248
|
this.protocolReader.on("line", (line) => this.handleGuestLine(line));
|
|
250
|
-
//
|
|
251
|
-
// output (Bun.$ without .quiet()); attribute it to the running cell.
|
|
249
|
+
// --- guest stdout/stderr are subprocess output; attach to the running cell ---
|
|
252
250
|
child.stdout!.on("data", (buffer: Buffer) => this.appendActiveOutput("stdout", buffer.toString()));
|
|
253
251
|
child.stderr!.on("data", (buffer: Buffer) => {
|
|
254
252
|
const text = buffer.toString();
|
|
@@ -262,7 +260,7 @@ export class EngineManager {
|
|
|
262
260
|
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
263
261
|
? "Engine process failed: '" +
|
|
264
262
|
pythonPath +
|
|
265
|
-
"' was not found on PATH. pi-repl runs its evaluator in Python; ensure it is installed and on your PATH, or set the pythonPath in ~/.pi/agent/pi-repl.json."
|
|
263
|
+
"' was not found on PATH. pi-repl runs its evaluator in Python; ensure it is installed and on your PATH, or set the pythonPath in ~/.pi/agent/pi-repl/config.json."
|
|
266
264
|
: `Engine process failed: ${error.message}`;
|
|
267
265
|
this.failAllPending(new Error(message));
|
|
268
266
|
this.transitionToShutdown(message);
|
|
@@ -280,8 +278,7 @@ export class EngineManager {
|
|
|
280
278
|
}
|
|
281
279
|
});
|
|
282
280
|
|
|
283
|
-
//
|
|
284
|
-
// idle, or a retried start() orphans the previous child and its fd3 pipe.
|
|
281
|
+
// --- on a boot timeout tear down the child and reset state, or a retried start orphans it ---
|
|
285
282
|
await ready.catch((error) => {
|
|
286
283
|
if (this.child === child) this.child = undefined;
|
|
287
284
|
this.protocolReader?.close();
|
|
@@ -348,7 +345,7 @@ export class EngineManager {
|
|
|
348
345
|
await this.kill();
|
|
349
346
|
}
|
|
350
347
|
|
|
351
|
-
|
|
348
|
+
//--- guest messaging ---
|
|
352
349
|
|
|
353
350
|
private sendToGuest(message: HostToGuestMessage): void {
|
|
354
351
|
// --- a write into a dying child can throw; callers learn via the exit path ---
|
|
@@ -373,7 +370,7 @@ export class EngineManager {
|
|
|
373
370
|
}
|
|
374
371
|
|
|
375
372
|
private handleGuestLine(line: string): void {
|
|
376
|
-
// fd 3 is protocol-only;
|
|
373
|
+
// --- fd 3 is protocol-only; undecodable lines are discarded ---
|
|
377
374
|
const message = decodeMessage<GuestToHostMessage>(line, this.nonce);
|
|
378
375
|
if (!message) return;
|
|
379
376
|
switch (message.type) {
|
|
@@ -427,7 +424,7 @@ export class EngineManager {
|
|
|
427
424
|
pending.resolve(message);
|
|
428
425
|
}
|
|
429
426
|
|
|
430
|
-
|
|
427
|
+
//--- output accumulation ---
|
|
431
428
|
|
|
432
429
|
private appendActiveOutput(name: "stdout" | "stderr", text: string): void {
|
|
433
430
|
const active = this.activeExecution;
|
|
@@ -448,15 +445,14 @@ export class EngineManager {
|
|
|
448
445
|
} else {
|
|
449
446
|
active[truncatedKey] = true;
|
|
450
447
|
}
|
|
451
|
-
// --- cap the live stream feed
|
|
452
|
-
// content cannot grow past the same budget the final output is capped at ---
|
|
448
|
+
// --- cap the live stream feed, so partial content can't grow past the output budget ---
|
|
453
449
|
const room = active.maxChars - active.streamedChars;
|
|
454
450
|
const forward = Math.min(text.length, Math.max(0, room));
|
|
455
451
|
if (forward > 0) active.opts.onStream?.(text.slice(0, forward), name);
|
|
456
452
|
active.streamedChars += forward;
|
|
457
453
|
}
|
|
458
454
|
|
|
459
|
-
|
|
455
|
+
//--- execute ---
|
|
460
456
|
|
|
461
457
|
async execute(code: string, opts: ExecuteOptions = {}): Promise<ExecuteResult> {
|
|
462
458
|
// --- claim the queue slot synchronously so order == submission order ---
|
|
@@ -565,8 +561,7 @@ export class EngineManager {
|
|
|
565
561
|
active.settled = true;
|
|
566
562
|
if (this.activeExecution === active) this.activeExecution = undefined;
|
|
567
563
|
|
|
568
|
-
//
|
|
569
|
-
// the caller withdrew interest, so the value is not theirs to consume.
|
|
564
|
+
// --- a cancelled cell reports "aborted" even if it finished first (caller withdrew) ---
|
|
570
565
|
let status = active.status;
|
|
571
566
|
if (active.opts.signal?.aborted) status = "aborted";
|
|
572
567
|
if (status !== "aborted") this.maybeWedged = false;
|
|
@@ -588,7 +583,7 @@ export class EngineManager {
|
|
|
588
583
|
});
|
|
589
584
|
}
|
|
590
585
|
|
|
591
|
-
|
|
586
|
+
//--- snapshot / restore / names ---
|
|
592
587
|
|
|
593
588
|
async snapshotState(): Promise<SnapshotResult | null> {
|
|
594
589
|
const config = this.options.snapshot;
|
|
@@ -596,9 +591,7 @@ export class EngineManager {
|
|
|
596
591
|
try {
|
|
597
592
|
const reply = await this.request({ type: "snapshot", id: randomUUID() }, SNAPSHOT_REQUEST_TIMEOUT_MS);
|
|
598
593
|
if (reply.type !== "snapshot_result") return null;
|
|
599
|
-
//
|
|
600
|
-
// NOT overwrite the last good file — a failed snapshot should cost a
|
|
601
|
-
// throwaway run, never the durable memory.
|
|
594
|
+
// --- an incomplete snapshot must not overwrite the last good file ---
|
|
602
595
|
if (reply.complete === false) return null;
|
|
603
596
|
mkdirSync(dirname(config.path), { recursive: true });
|
|
604
597
|
writeFileSync(config.path, JSON.stringify({ version: 1, vars: reply.vars, failed: reply.failed }));
|
|
@@ -16,7 +16,7 @@ def edit(path, old_text, new_text):
|
|
|
16
16
|
|
|
17
17
|
Environment:
|
|
18
18
|
This evaluator runs in a project-local Python venv, not the system
|
|
19
|
-
interpreter. For a package install that is ~/.pi/agent/pi-repl
|
|
19
|
+
interpreter. For a package install that is ~/.pi/agent/pi-repl/venv; for a
|
|
20
20
|
repo checkout it is .venv/. The file edited is a real file on disk.
|
|
21
21
|
"""
|
|
22
22
|
with open(path, encoding="utf-8") as f:
|
|
@@ -15,7 +15,7 @@ def read(path, offset=1, limit=None):
|
|
|
15
15
|
|
|
16
16
|
Environment:
|
|
17
17
|
This evaluator runs in a project-local Python venv, not the system
|
|
18
|
-
interpreter. For a package install that is ~/.pi/agent/pi-repl
|
|
18
|
+
interpreter. For a package install that is ~/.pi/agent/pi-repl/venv; for a
|
|
19
19
|
repo checkout it is .venv/. python / pip on PATH may point elsewhere, so
|
|
20
20
|
do not assume the system python is what's running.
|
|
21
21
|
"""
|
|
@@ -14,7 +14,7 @@ def write(path, content):
|
|
|
14
14
|
|
|
15
15
|
Environment:
|
|
16
16
|
This evaluator runs in a project-local Python venv, not the system
|
|
17
|
-
interpreter. For a package install that is ~/.pi/agent/pi-repl
|
|
17
|
+
interpreter. For a package install that is ~/.pi/agent/pi-repl/venv; for a
|
|
18
18
|
repo checkout it is .venv/. Files you write are real files on disk in the
|
|
19
19
|
working directory, visible to the host and other processes.
|
|
20
20
|
"""
|
package/src/extension/config.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* configured. First-found-wins, never throws on a missing/malformed file.
|
|
6
6
|
*
|
|
7
7
|
* $PI_REPL_CONFIG explicit env override
|
|
8
|
-
* ~/.pi/agent/pi-repl.json user-global
|
|
8
|
+
* ~/.pi/agent/pi-repl/config.json user-global
|
|
9
9
|
*
|
|
10
10
|
* The loadable function set is the toolbox directory (see engine/toolbox/);
|
|
11
11
|
* there is no separate helpers list. The extension ships with the four default
|
|
@@ -29,8 +29,7 @@ export interface ReplConfig {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const DEFAULT_CONFIG: ReplConfig = {
|
|
32
|
-
// 0 = no
|
|
33
|
-
// SILENCE watchdog (no output for N ms), not a wall-clock deadline.
|
|
32
|
+
// --- timeoutMs: 0 = no cap; nonzero = silence watchdog (no output for N ms) ---
|
|
34
33
|
timeoutMs: 0,
|
|
35
34
|
snapshotDebounceMs: 1500,
|
|
36
35
|
};
|
|
@@ -41,7 +40,7 @@ function num(v: unknown, dflt: number): number {
|
|
|
41
40
|
|
|
42
41
|
function configCandidates(): string[] {
|
|
43
42
|
const env = process.env.PI_REPL_CONFIG;
|
|
44
|
-
const user = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".pi", "agent"
|
|
43
|
+
const user = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".pi", "agent", "pi-repl"), "config.json");
|
|
45
44
|
return [env, user].filter((p): p is string => !!p && p.length > 0);
|
|
46
45
|
}
|
|
47
46
|
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// --- candidates: the five detectors that name a cell's intent, plus generic scoring ---
|
|
2
|
+
|
|
3
|
+
import { descriptor } from "./descriptor.js";
|
|
4
|
+
import { maskSpan, scanTemplate, substituteVars } from "./scan.js";
|
|
5
|
+
import { previewShellCommand, previewShellCommandScored, SHELL_SETUP_WORDS, shellWords } from "./shell.js";
|
|
6
|
+
import { BACKTICK, type Candidate } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const SHELL_OPEN_PATTERN = new RegExp("Bun\\s*\\.\\s*\\$\\s*(?:\\([^)]*\\)\\s*)?" + BACKTICK, "g");
|
|
9
|
+
|
|
10
|
+
export function shellCandidates(
|
|
11
|
+
source: string,
|
|
12
|
+
vars: ReadonlyMap<string, string>,
|
|
13
|
+
): { candidates: Candidate[]; masked: string } {
|
|
14
|
+
const candidates: Candidate[] = [];
|
|
15
|
+
let masked = source;
|
|
16
|
+
SHELL_OPEN_PATTERN.lastIndex = 0;
|
|
17
|
+
let match = SHELL_OPEN_PATTERN.exec(masked);
|
|
18
|
+
while (match) {
|
|
19
|
+
const span = scanTemplate(masked, match.index + match[0].length - 1);
|
|
20
|
+
const command = previewShellCommandScored(substituteVars(span.body, vars));
|
|
21
|
+
// --- the command's own strength breaks ties; setup-only drops lower ---
|
|
22
|
+
if (command.text) {
|
|
23
|
+
const setupOnly = SHELL_SETUP_WORDS.has(shellWords(command.text)[0] ?? "");
|
|
24
|
+
const score = setupOnly ? 72 : 90 + Math.min(command.strength, 200) / 25;
|
|
25
|
+
candidates.push({ kind: "shell", text: command.text, score });
|
|
26
|
+
}
|
|
27
|
+
masked = maskSpan(masked, span);
|
|
28
|
+
SHELL_OPEN_PATTERN.lastIndex = span.end;
|
|
29
|
+
match = SHELL_OPEN_PATTERN.exec(masked);
|
|
30
|
+
}
|
|
31
|
+
return { candidates, masked };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const STRING_ARG_PATTERN = /^\s*(?:"([^"]*)"|'([^']*)')/;
|
|
35
|
+
|
|
36
|
+
export function agentCandidates(
|
|
37
|
+
source: string,
|
|
38
|
+
vars: ReadonlyMap<string, string>,
|
|
39
|
+
): { candidates: Candidate[]; masked: string } {
|
|
40
|
+
const tasks: string[] = [];
|
|
41
|
+
let masked = source;
|
|
42
|
+
const pattern = /rlm\s*\.\s*run\s*\(/g;
|
|
43
|
+
let match = pattern.exec(masked);
|
|
44
|
+
while (match) {
|
|
45
|
+
const argsStart = match.index + match[0].length;
|
|
46
|
+
let task: string | undefined;
|
|
47
|
+
const rest = masked.slice(argsStart);
|
|
48
|
+
const literal = rest.match(STRING_ARG_PATTERN);
|
|
49
|
+
if (literal) {
|
|
50
|
+
task = literal[1] ?? literal[2];
|
|
51
|
+
} else if (rest.trimStart().startsWith(BACKTICK)) {
|
|
52
|
+
const tickIndex = argsStart + rest.indexOf(BACKTICK);
|
|
53
|
+
const span = scanTemplate(masked, tickIndex);
|
|
54
|
+
task = substituteVars(span.body, vars);
|
|
55
|
+
masked = maskSpan(masked, span);
|
|
56
|
+
} else {
|
|
57
|
+
const identifier = rest.match(/^\s*([A-Za-z_$][\w$]*)/)?.[1];
|
|
58
|
+
task = identifier ? (vars.get(identifier) ?? identifier) : undefined;
|
|
59
|
+
}
|
|
60
|
+
// --- a chosen child name is identity; lead with it ---
|
|
61
|
+
const name = masked.slice(argsStart).match(/name\s*:\s*(?:"([^"]*)"|'([^']*)')/);
|
|
62
|
+
const label = name?.[1] ?? name?.[2];
|
|
63
|
+
tasks.push(label && task ? label + ": " + task : (label ?? task ?? "subagent"));
|
|
64
|
+
pattern.lastIndex = argsStart;
|
|
65
|
+
match = pattern.exec(masked);
|
|
66
|
+
}
|
|
67
|
+
const candidates: Candidate[] =
|
|
68
|
+
tasks.length === 0
|
|
69
|
+
? []
|
|
70
|
+
: [
|
|
71
|
+
{
|
|
72
|
+
kind: "agent",
|
|
73
|
+
text: descriptor(tasks.length === 1 ? (tasks[0] ?? "") : tasks[0] + " (+" + (tasks.length - 1) + " more)"),
|
|
74
|
+
score: 100,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
return { candidates, masked };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const FILE_EFFECT_PATTERN =
|
|
81
|
+
/(?:Bun\.write|\b(?:fs|fsp|promises)\.(?:writeFileSync|writeFile|appendFileSync|appendFile|mkdirSync|mkdir|rmSync|rmdirSync|unlinkSync|unlink|renameSync|rename|copyFileSync|copyFile|cpSync|cp)|\b(?:writeFileSync|writeFile|appendFileSync|mkdirSync|rmSync|unlinkSync|renameSync|copyFileSync))\s*\(\s*([^,)\n]+)/g;
|
|
82
|
+
|
|
83
|
+
const FILE_EFFECT_VERBS: ReadonlyArray<[string, string]> = [
|
|
84
|
+
["Bun.write", "write"],
|
|
85
|
+
["writeFileSync", "write"],
|
|
86
|
+
["writeFile", "write"],
|
|
87
|
+
["appendFileSync", "append"],
|
|
88
|
+
["appendFile", "append"],
|
|
89
|
+
["mkdirSync", "mkdir"],
|
|
90
|
+
["mkdir", "mkdir"],
|
|
91
|
+
["rmdirSync", "delete"],
|
|
92
|
+
["rmSync", "delete"],
|
|
93
|
+
["rm", "delete"],
|
|
94
|
+
["unlinkSync", "delete"],
|
|
95
|
+
["unlink", "delete"],
|
|
96
|
+
["renameSync", "rename"],
|
|
97
|
+
["rename", "rename"],
|
|
98
|
+
["copyFileSync", "copy"],
|
|
99
|
+
["copyFile", "copy"],
|
|
100
|
+
["cpSync", "copy"],
|
|
101
|
+
["cp", "copy"],
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
// --- resolve a quoted literal, a known const, or an interpolated template into a plain string ---
|
|
105
|
+
function resolveArgText(arg: string, vars: ReadonlyMap<string, string>): string | undefined {
|
|
106
|
+
const trimmed = arg.trim();
|
|
107
|
+
const literalPattern = new RegExp("^[\"'" + BACKTICK + "]([^\"'" + BACKTICK + "]*)[\"'" + BACKTICK + "]$");
|
|
108
|
+
const literal = trimmed.match(literalPattern);
|
|
109
|
+
if (literal?.[1]) return literal[1];
|
|
110
|
+
if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) return vars.get(trimmed);
|
|
111
|
+
if (trimmed.startsWith(BACKTICK)) return substituteVars(trimmed.slice(1, -1), vars);
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const FILE_READ_PATTERN = /Bun\.file\s*\(\s*([^,)\n]+?)\s*\)\s*\.\s*(?:text|json|arrayBuffer|bytes|stream)\s*\(/g;
|
|
116
|
+
|
|
117
|
+
export function fileCandidates(source: string, vars: ReadonlyMap<string, string>): Candidate[] {
|
|
118
|
+
const candidates: Candidate[] = [];
|
|
119
|
+
for (const match of source.matchAll(FILE_EFFECT_PATTERN)) {
|
|
120
|
+
const call = match[0];
|
|
121
|
+
const verb = FILE_EFFECT_VERBS.find(([name]) => call.includes(name))?.[1];
|
|
122
|
+
if (!verb) continue;
|
|
123
|
+
const path = resolveArgText(match[1] ?? "", vars);
|
|
124
|
+
if (path) candidates.push({ kind: "ts", text: descriptor(verb + " " + path), score: 95 });
|
|
125
|
+
}
|
|
126
|
+
for (const match of source.matchAll(FILE_READ_PATTERN)) {
|
|
127
|
+
const path = resolveArgText(match[1] ?? "", vars);
|
|
128
|
+
if (path) candidates.push({ kind: "ts", text: descriptor("read " + path), score: 70 });
|
|
129
|
+
}
|
|
130
|
+
for (const match of source.matchAll(/\bfetch\s*\(\s*([^,)\n]+)/g)) {
|
|
131
|
+
const url = resolveArgText(match[1] ?? "", vars);
|
|
132
|
+
if (url) candidates.push({ kind: "ts", text: descriptor("fetch " + url), score: 75 });
|
|
133
|
+
}
|
|
134
|
+
return candidates;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- per-tool: which arg names the target, the verb shown, and its scoring band ---
|
|
138
|
+
const BRIDGED_TOOLS: Record<string, { arg: string; verb: string; score: number }> = {
|
|
139
|
+
read: { arg: "path", verb: "read", score: 70 },
|
|
140
|
+
bash: { arg: "command", verb: "", score: 88 },
|
|
141
|
+
edit: { arg: "path", verb: "edit", score: 95 },
|
|
142
|
+
write: { arg: "path", verb: "write", score: 95 },
|
|
143
|
+
grep: { arg: "pattern", verb: "grep", score: 68 },
|
|
144
|
+
find: { arg: "pattern", verb: "find", score: 68 },
|
|
145
|
+
ls: { arg: "path", verb: "ls", score: 68 },
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export function bridgedToolCandidates(source: string, vars: ReadonlyMap<string, string>): Candidate[] {
|
|
149
|
+
const candidates: Candidate[] = [];
|
|
150
|
+
for (const match of source.matchAll(/\btools\.(\w+)\s*\(\s*\{([^}]*)\}/g)) {
|
|
151
|
+
const spec = BRIDGED_TOOLS[match[1] ?? ""];
|
|
152
|
+
if (!spec) continue;
|
|
153
|
+
const props = match[2] ?? "";
|
|
154
|
+
const argMatch = props.match(new RegExp(spec.arg + "\\s*:\\s*([^,}]+)"));
|
|
155
|
+
const target = argMatch ? resolveArgText(argMatch[1] ?? "", vars) : undefined;
|
|
156
|
+
if (!target) continue;
|
|
157
|
+
// --- a bridged bash call is a command like any other ---
|
|
158
|
+
const text = spec.verb ? spec.verb + " " + target : previewShellCommand(target) || target;
|
|
159
|
+
candidates.push({ kind: "ts", text: descriptor(text), score: spec.score });
|
|
160
|
+
}
|
|
161
|
+
return candidates;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const SKIP_LINE_PATTERN = /^(?:$|\/\/|\/\*|\*|import\s|export\s+(?:type\s|\{)|[})\];,]+$)/;
|
|
165
|
+
const DEFINITION_PATTERN = /^(?:export\s+)?(?:async\s+)?(?:function\s|class\s|interface\s|type\s+\w+\s*=)/;
|
|
166
|
+
const ARROW_DEFINITION_PATTERN = /^(?:const|let)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?\(?[^)=]*\)?\s*=>/;
|
|
167
|
+
const CONTROL_PATTERN = /^(?:if|for|while|switch|try|do)\b/;
|
|
168
|
+
const CALL_STATEMENT_PATTERN = /^(?:await\s+)?[A-Za-z_$][\w$.]*\s*\(/;
|
|
169
|
+
const ASSIGNMENT_CALL_PATTERN = /^(?:const|let|var)\s+[^=]{1,60}=\s*(?:await\s+)?(?:new\s+)?[A-Za-z_$][\w$.]*\s*\(/;
|
|
170
|
+
const LOW_SIGNAL_CALL_PATTERN =
|
|
171
|
+
/^(?:await\s+)?(?:console\.\w+|String|Number|Boolean|JSON\.stringify|JSON\.parse|structuredClone)\s*\(/;
|
|
172
|
+
const LOW_SIGNAL_ASSIGNMENT_PATTERN =
|
|
173
|
+
/=\s*(?:await\s+)?(?:JSON\.parse|JSON\.stringify|String|Number|Boolean|Object\.keys|Object\.entries)\s*\(/;
|
|
174
|
+
|
|
175
|
+
function consoleInnerCall(line: string): string | undefined {
|
|
176
|
+
const inner = line.match(/^console\.\w+\(\s*(.+)\)\s*;?\s*$/)?.[1]?.trim();
|
|
177
|
+
return inner && CALL_STATEMENT_PATTERN.test(inner) && !LOW_SIGNAL_CALL_PATTERN.test(inner) ? inner : undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function genericLineScore(line: string): number {
|
|
181
|
+
if (SKIP_LINE_PATTERN.test(line)) return -1;
|
|
182
|
+
if (LOW_SIGNAL_ASSIGNMENT_PATTERN.test(line)) return 25;
|
|
183
|
+
if (consoleInnerCall(line)) return 55;
|
|
184
|
+
if (LOW_SIGNAL_CALL_PATTERN.test(line)) return 15;
|
|
185
|
+
if (DEFINITION_PATTERN.test(line) || ARROW_DEFINITION_PATTERN.test(line)) return 50;
|
|
186
|
+
if (CONTROL_PATTERN.test(line)) return 20;
|
|
187
|
+
if (/^(?:return|throw)\b/.test(line)) return 45;
|
|
188
|
+
if (ASSIGNMENT_CALL_PATTERN.test(line)) return 60;
|
|
189
|
+
if (CALL_STATEMENT_PATTERN.test(line)) return 65;
|
|
190
|
+
if (/^(?:const|let|var)\s/.test(line)) return 22;
|
|
191
|
+
return 30;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function genericCandidates(masked: string): Candidate[] {
|
|
195
|
+
const candidates: Candidate[] = [];
|
|
196
|
+
for (const [index, rawLine] of masked.split("\n").entries()) {
|
|
197
|
+
const line = rawLine.trim();
|
|
198
|
+
const score = genericLineScore(line);
|
|
199
|
+
if (score < 0) continue;
|
|
200
|
+
const text = consoleInnerCall(line) ?? line;
|
|
201
|
+
// --- later lines win ties: cells read as setup-then-act, and the act is the story ---
|
|
202
|
+
candidates.push({ kind: "ts", text: descriptor(text), score: score + Math.min(index, 90) / 100 });
|
|
203
|
+
}
|
|
204
|
+
return candidates;
|
|
205
|
+
}
|