pi-repl-py 0.6.7 → 0.6.8

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 CHANGED
@@ -64,22 +64,25 @@ On **Termux (Android)**, the `postinstall` venv build can fail because `ipykerne
64
64
 
65
65
  A **helper** is a `.py` file that gets exec'd into every kernel, so whatever it defines
66
66
  (like functions, classes, constants, imports, or a module that manages a tricky piece of
67
- complexity) is available in the workspace. Drop a file in the one helpers directory and
68
- restart the session; e.g. `helpers/double.py` defining `def double(x)` becomes callable as
69
- `double(...)`. It ships **empty** (shell and file IO are already plain Python), so a fresh
70
- install preloads nothing until you add one. Each helper's `helper_description` is shown to
71
- the model verbatim; the full contract lives in [docs/helpers.md](docs/helpers.md).
67
+ complexity) is available in the workspace. Drop a file in a `.pi/helpers/` directory in
68
+ your project (or `~/.pi/agent/pi-repl/helpers/` for every project) and restart the session;
69
+ e.g. `helpers/double.py` defining `def double(x)` becomes callable as `double(...)`. Global
70
+ helpers ship **empty** (shell and file IO are already plain Python), so a fresh install
71
+ preloads nothing until you add one. Project helpers shadow same-named global ones. Each
72
+ helper's `helper_description` is shown to the model verbatim; the full contract lives in
73
+ [docs/helpers.md](docs/helpers.md).
72
74
 
73
- Everything the extension keeps lives under one folder in your home directory:
75
+ The extension keeps its runtime under one folder in your home directory:
74
76
 
75
77
  ```
76
78
  ~/.pi/agent/pi-repl/
77
79
  venv/ the Python interpreter + ipykernel
78
- helpers/ your helpers (created empty on install; every *.py loads)
80
+ helpers/ global helpers (created empty on install; every *.py loads)
79
81
  state/ per-session namespace snapshots
80
82
  ```
81
83
 
82
- The helpers directory is fixed at `~/.pi/agent/pi-repl/helpers`. No config file.
84
+ Project helpers live in `<project>/.pi/helpers/` instead; both tiers are scanned with the
85
+ project one first. No config file.
83
86
 
84
87
  Changing a helper (adding/removing a file, renaming one with a `_` prefix) needs a
85
88
  **session restart / `/reload`**: the prompt list is built when `execute` is registered and
@@ -175,11 +175,13 @@ watchdog timeout.
175
175
  state/ per-session namespace snapshots
176
176
  ```
177
177
 
178
- The helpers directory is fixed at `~/.pi/agent/pi-repl/helpers` (matching the kernel's
179
- `readHelperSources` default), so both sides are guaranteed to read the same directory. The
180
- venv is built automatically, and the interpreter follows the order above. No setting is
181
- needed. The per-cell silence watchdog is off by default (`PI_REPL_TIMEOUT_MS=0`: a silent
182
- but working cell may run on).
178
+ Helpers merge project and global dirs: `resolveHelperDirs` walks from the working
179
+ directory up to the git root collecting `.pi/helpers/`, then appends
180
+ `~/.pi/agent/pi-repl/helpers`. Both the prompt loader and the kernel's `readHelperSources`
181
+ walk the same ordered list with first-seen-wins, so a project helper shadows the same-named
182
+ global one and both sides are guaranteed to agree. The venv is built automatically, and the
183
+ interpreter follows the order above. No setting is needed. The per-cell silence watchdog is
184
+ off by default (`PI_REPL_TIMEOUT_MS=0`: a silent but working cell may run on).
183
185
 
184
186
  ## Reference documentation
185
187
 
package/docs/helpers.md CHANGED
@@ -9,13 +9,26 @@ name or any other public name in the file.
9
9
 
10
10
  ## Where helpers live
11
11
 
12
+ Helpers come from two places: a **project** directory and a **global** directory.
13
+
12
14
  ```text
13
- ~/.pi/agent/pi-repl/helpers/
15
+ <project>/.pi/helpers/ project helpers (looked up from the working dir)
16
+ ~/.pi/agent/pi-repl/helpers/ global helpers (every project)
14
17
  ```
15
18
 
16
- The directory is created empty when pi-repl is installed. Every `.py` file in it is loaded when the evaluator starts. Files whose names begin with `_` are ignored.
19
+ The global directory is created empty when pi-repl is installed. In a project, any
20
+ `.pi/helpers/` directory is picked up by walking up from the working directory to the
21
+ git repo root, so a helper works no matter how deep in the project you are.
22
+
23
+ Every `.py` file found is loaded when the evaluator starts; files whose names begin with
24
+ `_` are ignored.
25
+
26
+ The two tiers merge: a project helper **shadows** a same-named global helper, and global
27
+ helpers fill in whatever the project does not define. One file name appears once in the
28
+ tool prompt and once in the kernel.
17
29
 
18
- After adding, changing, renaming, or disabling a helper, run `/reload` or start a new `pi --repl` session. The running evaluator does not watch the directory for changes.
30
+ After adding, changing, renaming, or disabling a helper, run `/reload` or start a new
31
+ `pi --repl` session. The running evaluator does not watch the directories for changes.
19
32
 
20
33
  ## A small function helper
21
34
 
@@ -109,7 +122,7 @@ Use docstrings for argument details, defaults, return values, errors, environmen
109
122
 
110
123
  ## How loading works
111
124
 
112
- At startup, two parts of pi-repl read the same helper directory:
125
+ At startup, two parts of pi-repl read the same merged helper list (project dirs first, global last):
113
126
 
114
127
  1. The kernel executes each eligible `.py` file. Its definitions become names in the Python workspace.
115
128
  2. The host reads `helper_description` to build the helper guidance shown to the model.
@@ -196,7 +209,7 @@ The loader skips it. Rename it back and reload when you want it again.
196
209
 
197
210
  ## Checklist
198
211
 
199
- - [ ] The file is in `~/.pi/agent/pi-repl/helpers/`.
212
+ - [ ] The file is in `~/.pi/agent/pi-repl/helpers/` (global) or in `<project>/.pi/helpers/` (project-scoped).
200
213
  - [ ] Its public names and call shapes are clear.
201
214
  - [ ] `helper_description` is short enough for every-turn context.
202
215
  - [ ] Detailed behavior is in docstrings.
package/index.ts CHANGED
@@ -125,7 +125,7 @@ export default function (pi: ExtensionAPI) {
125
125
  label: "execute",
126
126
  description: EXECUTE_DESCRIPTION,
127
127
  promptSnippet: EXECUTE_PROMPT_SNIPPET,
128
- promptGuidelines: buildExecutePromptGuidelines(),
128
+ promptGuidelines: buildExecutePromptGuidelines(process.cwd()),
129
129
  parameters: executeSchema,
130
130
  renderShell: "self",
131
131
  renderCall(args, theme, context) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-repl-py",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
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": [
@@ -0,0 +1,24 @@
1
+ // --- shared helper-dir resolution: prompt and kernel must read the same ordered list ---
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+
6
+ const GLOBAL_HELPERS_DIR = join(homedir(), ".pi", "agent", "pi-repl", "helpers");
7
+
8
+ /** Ordered candidate dirs: nearest .pi/helpers up to the git root, then the global dir last. */
9
+ export function resolveHelperDirs(cwd?: string, globalDir?: string): string[] {
10
+ const dirs: string[] = [];
11
+ if (cwd) {
12
+ let cur = resolve(cwd);
13
+ for (;;) {
14
+ const d = join(cur, ".pi", "helpers");
15
+ if (existsSync(d)) dirs.push(d);
16
+ if (existsSync(join(cur, ".git"))) break;
17
+ const parent = dirname(cur);
18
+ if (parent === cur) break;
19
+ cur = parent;
20
+ }
21
+ }
22
+ dirs.push(globalDir ?? GLOBAL_HELPERS_DIR);
23
+ return dirs;
24
+ }
@@ -3,8 +3,9 @@
3
3
  import { type ChildProcess, spawn } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
6
- import { homedir, tmpdir } from "node:os";
6
+ import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
+ import { resolveHelperDirs } from "./helpers-locate.js";
8
9
  import {
9
10
  type ConnectionFile,
10
11
  executeRequest,
@@ -62,18 +63,22 @@ function resolveCwd(requested?: string): string {
62
63
  if (requested && existsSync(requested)) return requested;
63
64
  return process.cwd();
64
65
  }
65
- function readHelperSources(dir?: string): { name: string; source: string }[] {
66
- // --- one fixed dir, resolved like the prompt side (helpers.ts) so both always agree ---
67
- const d = dir ?? join(homedir(), ".pi", "agent", "pi-repl", "helpers");
68
- if (!existsSync(d)) return [];
66
+ function readHelperSources(dirs: string[]): { name: string; source: string }[] {
67
+ // --- merged dirs come pre-ordered (project first, global last); first-seen name wins ---
68
+ const seen = new Set<string>();
69
69
  const out: { name: string; source: string }[] = [];
70
- for (const file of readdirSync(d).sort()) {
71
- if (!file.endsWith(".py")) continue;
72
- const name = file.slice(0, -3);
73
- if (!/^[A-Za-z_]\w*$/.test(name) || name.startsWith("_")) continue;
74
- try {
75
- out.push({ name, source: readFileSync(join(d, file), "utf8") });
76
- } catch {}
70
+ for (const d of dirs) {
71
+ if (!existsSync(d)) continue;
72
+ for (const file of readdirSync(d).sort()) {
73
+ if (!file.endsWith(".py")) continue;
74
+ const name = file.slice(0, -3);
75
+ if (!/^[A-Za-z_]\w*$/.test(name) || name.startsWith("_")) continue;
76
+ if (seen.has(name)) continue;
77
+ seen.add(name);
78
+ try {
79
+ out.push({ name, source: readFileSync(join(d, file), "utf8") });
80
+ } catch {}
81
+ }
77
82
  }
78
83
  return out;
79
84
  }
@@ -184,7 +189,9 @@ export class KernelClient {
184
189
 
185
190
  private constructor(conn: ConnectionFile, opts: KernelOptions) {
186
191
  this.session = new JupyterSession({ key: conn.key });
187
- this.helperSources = readHelperSources(opts.env?.PI_HELPERS_DIR);
192
+ this.helperSources = opts.env?.PI_HELPERS_DIR
193
+ ? readHelperSources([opts.env.PI_HELPERS_DIR])
194
+ : readHelperSources(resolveHelperDirs(opts.cwd, opts.env?.PI_HELPERS_GLOBAL_DIR));
188
195
  this.timeoutMs = opts.timeoutMs ?? 0;
189
196
  }
190
197
 
@@ -1,8 +1,9 @@
1
- /** Loads helpers from the ONE fixed dir; `helper_description` surfaces verbatim (no signature parsing). */
1
+ /** Loads helpers from project then global dirs; `helper_description` surfaces verbatim (no signature parsing). */
2
2
 
3
3
  import { existsSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { resolveHelperDirs } from "../engine/helpers-locate.js";
6
7
 
7
8
  const DEFAULT_HELPERS_DIR = join(homedir(), ".pi", "agent", "pi-repl", "helpers");
8
9
 
@@ -11,35 +12,47 @@ interface HelperEntry {
11
12
  description: string; // full helper_description body, "" if absent
12
13
  }
13
14
 
14
- /** Extract `helper_description = """..."""` (or `'''`) verbatim; no signature parsing. */
15
+ /** Extract `helper_description` verbatim; no signature parsing. */
15
16
  function parseDescription(source: string): string {
16
17
  const m = source.match(/helper_description\s*=\s*("""|''')([\s\S]*?)\1/);
17
18
  return m ? m[2].trim() : "";
18
19
  }
19
20
 
20
- /** Load {name entry} for each non-underscore *.py in the helpers dir. */
21
- function loadHelperEntries(dir?: string): HelperEntry[] {
22
- const d = dir ?? DEFAULT_HELPERS_DIR;
23
- if (!existsSync(d)) return [];
21
+ /** Merge entries from ordered dirs; first-seen name wins, so a project helper shadows the global one. */
22
+ function loadHelperEntries(dirs: string[]): HelperEntry[] {
23
+ const seen = new Set<string>();
24
24
  const entries: HelperEntry[] = [];
25
- for (const file of readdirSync(d).sort()) {
26
- if (!file.endsWith(".py")) continue;
27
- const name = file.slice(0, -3);
28
- if (!/^[A-Za-z_]\w*$/.test(name)) continue;
29
- // --- underscore-prefixed files are neither loaded nor advertised ---
30
- if (name.startsWith("_")) continue;
31
- try {
32
- const source = readFileSync(join(d, file), "utf8");
33
- entries.push({ name, description: parseDescription(source) });
34
- } catch {}
25
+ for (const d of dirs) {
26
+ if (!existsSync(d)) continue;
27
+ for (const file of readdirSync(d).sort()) {
28
+ if (!file.endsWith(".py")) continue;
29
+ const name = file.slice(0, -3);
30
+ if (!/^[A-Za-z_]\w*$/.test(name)) continue;
31
+ // --- underscore-prefixed files are neither loaded nor advertised ---
32
+ if (name.startsWith("_")) continue;
33
+ if (seen.has(name)) continue;
34
+ seen.add(name);
35
+ try {
36
+ const source = readFileSync(join(d, file), "utf8");
37
+ entries.push({ name, description: parseDescription(source) });
38
+ } catch {}
39
+ }
35
40
  }
36
41
  return entries;
37
42
  }
38
43
 
39
- /** The prompt-facing list, one bullet per loaded file (verbatim description, or an introspection pointer). */
44
+ /** The prompt-facing list for ONE dir: verbatim description, or an introspection pointer. */
40
45
  export function buildHelpersMap(dir?: string): string[] {
41
- // pi renders each prompt guideline as "- <line>"; these lines are bare, no bullet prefix.
42
- return loadHelperEntries(dir).map((t) =>
46
+ return loadHelperEntries([dir ?? DEFAULT_HELPERS_DIR]).map((t) =>
47
+ t.description
48
+ ? t.description.replace(/\n/g, "\n ")
49
+ : `${t.name} (no description, inspect it with print(${t.name}.__doc__))`,
50
+ );
51
+ }
52
+
53
+ /** The prompt-facing list at a cwd: project .pi/helpers first (up to the git root), global fallback, project shadows. */
54
+ export function buildHelpersMapForCwd(cwd: string, globalDir?: string): string[] {
55
+ return loadHelperEntries(resolveHelperDirs(cwd, globalDir)).map((t) =>
43
56
  t.description
44
57
  ? t.description.replace(/\n/g, "\n ")
45
58
  : `${t.name} (no description, inspect it with print(${t.name}.__doc__))`,
@@ -12,7 +12,7 @@ export const executePromptSnippet = "Execute Python in a persistent shell (read,
12
12
  // --- the model-facing guidelines, flat bullets like pi's own tool contributions ---
13
13
  export function buildPromptGuidelines(preloaded: string[]): string[] {
14
14
  return [
15
- "State persists across cells, so define a function once and keep building on it.",
15
+ "State persists across cells, so keep building on it.",
16
16
  "Find, filter, fetch, read: narrow the output in Python, then print the exact slice you need.",
17
17
  "Keep the result in a variable and reuse it, instead of re-fetching the same thing.",
18
18
  "Make surgical, precise changes over rewrites or whole-file dumps: a small unique anchor, replace, verify, read the file back before trusting it.",
@@ -1,15 +1,15 @@
1
1
  // --- tool-meta: thin surface assembling the execute tool's prompt from pure modules ---
2
2
  // --- the model contract lives in prompt.ts; only the helpers wiring stays here ---
3
3
 
4
- import { buildHelpersMap } from "./helpers.js";
4
+ import { buildHelpersMap, buildHelpersMapForCwd } from "./helpers.js";
5
5
  import { buildPromptGuidelines, executePromptSnippet, executeToolDescription } from "./prompt.js";
6
6
 
7
7
  export const EXECUTE_DESCRIPTION = executeToolDescription;
8
8
  export const EXECUTE_PROMPT_SNIPPET = executePromptSnippet;
9
9
 
10
- // --- build the guidelines from the one helpers dir (default ~/.pi/agent/pi-repl/helpers) ---
11
- export function buildExecutePromptGuidelines(): string[] {
12
- const map = buildHelpersMap();
10
+ // --- build the guidelines from project + global helper dirs ---
11
+ export function buildExecutePromptGuidelines(cwd?: string): string[] {
12
+ const map = cwd ? buildHelpersMapForCwd(cwd) : buildHelpersMap();
13
13
  const preloaded = map.length > 0 ? map : [];
14
14
  return buildPromptGuidelines(preloaded);
15
15
  }