pi-repl-py 0.1.1 → 0.2.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.
@@ -1,54 +1,76 @@
1
- // --- prompt: the execute tool's model-facing contract (pure, no pi/toolbox dependency) ---
2
- // --- mirrors pi-robust-edit's schema/domain split: content lives here; the thin adapter in tool-meta wires it in ---
1
+ // --- prompt: the execute tool's model-facing contract (pure, no pi/helper dep) ---
3
2
 
4
3
  export const executeToolDescription =
5
- "Execute Python in a persistent evaluator the session's working memory. Variables, imports, " +
6
- "functions, and data survive across every call. read, write, edit, and bash are Python functions " +
7
- "available in every cell, not separate tools. A cell returns its final expression; anything else " +
8
- "prints. Runs in the project-local venv, so a command that starts python or pip must target that venv.";
4
+ "You have one tool: a persistent Python workspace backed by a real `ipython` kernel. " +
5
+ "Variables, imports, functions, and data survive across cells and turns. Use this tool to read files, " +
6
+ "run shell commands, search code, transform data, and build up solutions all inside Python. " +
7
+ "Helpers in `~/.pi/agent/pi-repl/helpers/` are loaded at boot as functions; see what's loaded with " +
8
+ "`[k for k in globals() if not k.startswith('_')]`. A cell returns its final expression; printed output " +
9
+ "is captured separately.";
9
10
 
10
11
  export const executePromptSnippet =
11
- "Execute Python in a persistent evaluator whose variables, imports, and functions survive across " +
12
- "calls; read/write/edit/bash are Python functions in every cell, plus anything you define and reuse; " +
13
- "ls() lists them, help(name) shows usage";
12
+ "Use the Python workspace: keep state in variables, batch independent reads/searches in one cell, " +
13
+ "edit files safely, and iterate in small cells.";
14
14
 
15
- // --- the function doctrine riding the execute tool; sections keep every rule findable and rankable as hard or soft ---
15
+ // --- the workspace doctrine riding the execute tool ---
16
16
  export function buildPromptGuidelines(preloaded: string[]): string[] {
17
17
  return [
18
- "## What's in every cell",
19
- ...preloaded,
20
- "Not sure what's available? Call ls() first; help(name) shows a signature and notes.",
21
- "",
22
- "## How to use them",
23
- "These are your file and shell tools call them. Don't reimplement read/write/edit/bash in Python, " +
24
- "and don't fork a near-copy under a new name; a new def overwrites an old one by name, so extend " +
25
- "the existing function instead.",
26
- "Define a new function only to reuse it: if you'll run this shape again with different inputs, write " +
27
- "it once as def and call it by arguments — otherwise just run the cell.",
28
- "Do the job, then answer with the result. Don't tell the user you 'defined a function' or 'built a " +
29
- "tool'; that's internal machinery.",
30
- "",
31
- "## Examples",
32
- "Good — defined once, called by arguments:",
33
- " def fetch_news(query, hl='en', gl='US', limit=15): <fetch + parse to a list>",
34
- " fetch_news('Turkey')",
35
- " fetch_news('Nigeria', hl='en-NG')",
36
- "Compose them:",
37
- " def find_files(pattern, root='.'): <walk root, filter by pattern>",
38
- " def count_lines(paths): ...",
39
- " count_lines(find_files('*.csv')) # one call",
40
- "",
41
- "## Efficiency",
42
- "Everything a cell prints stays in context for the whole turn, so print slices, matches, or counts — " +
43
- "never whole files — and keep large values in variables.",
44
- "For whole-filesystem or large-directory scans, use the shell tools (find, fd, du, grep), not a " +
45
- "Python os.walk: it pays a syscall per file and runs minutes on a big tree. Example: " +
46
- "`find -xdev -type f -size +100M | sort -rn | head`. Reserve Python for analysing the results.",
47
- "",
48
- "## When it breaks",
49
- "If the output starts with <rlm_engine_reset>, the kernel was rebuilt: data is restored but your " +
50
- "functions are gone recreate any helper you need and re-verify a variable before trusting it.",
51
- "The standard library is available; don't install packages into the evaluator. Run out-of-tree " +
52
- "projects through their own environment.",
18
+ "## This workspace is your only tool",
19
+ "In `--repl` mode, `execute` is the only callable tool. Read files, run shell, search, and edit " +
20
+ "all happen inside Python.",
21
+ "",
22
+ "## The loop is generate → execute → observe → iterate",
23
+ "Write a cell, run it, observe the result, then write the next cell. Build solutions incrementally.",
24
+ "",
25
+ "## State persists",
26
+ "Variables, imports, and functions survive across cells and turns. Assign read/search results to " +
27
+ "named variables and reuse them.",
28
+ "",
29
+ "## Chain big tasks into verifiable steps",
30
+ "Break ambitious requests into independently checkable steps. Confirm assumptions before writing " +
31
+ "code that depends on them.",
32
+ "",
33
+ "## Shell & files are plain Python",
34
+ "`!cmd` / `%%bash` for fire-and-forget shell; `subprocess.run(..., timeout=...)` when you need the result back " +
35
+ "as a value — always set a `timeout` on anything that could hang (the evaluator does not kill a " +
36
+ "silent cell automatically). `open()` / `pathlib` read and write files. For safe edits: read the full " +
37
+ "file, modify in memory, write once, then re-read to verify.",
38
+ "",
39
+ "## Batch independent work, keep exploratory cells small",
40
+ "Batch independent reads, searches, and setup steps in one cell to reduce round-trips. Keep " +
41
+ "exploratory/iterative cells small so you can observe and adjust.",
42
+ "",
43
+ "## Search efficiently",
44
+ "Use `rg`, `fd`, `grep`, `find` via `subprocess.run` for deep searches, not Python loops.",
45
+ "",
46
+ ...(preloaded.length
47
+ ? [
48
+ "## Helpers",
49
+ "User helpers load from `~/.pi/agent/pi-repl/helpers/`. Their descriptions appear below. " +
50
+ "List what's loaded with `[k for k in globals() if not k.startswith('_')]`.",
51
+ "",
52
+ ...preloaded,
53
+ "",
54
+ ]
55
+ : []),
56
+ "## Compose and reuse",
57
+ "If the same pattern appears more than once, wrap it in a `def` and reuse it.",
58
+ "",
59
+ "## Output discipline",
60
+ "Printing is a context cost: everything a cell prints stays in the transcript. Print slices, " +
61
+ "counts, and summaries. Keep large values in variables. End a cell with `;` to suppress the " +
62
+ "last-expression echo.",
63
+ "",
64
+ "## Environment boundary",
65
+ "The evaluator runs in a project-local venv, not the system Python. Do not install a target project's " +
66
+ "dependencies into the evaluator just to make that project run there. Run external projects " +
67
+ "through their own interface and normal commands.",
68
+ "",
69
+ "## Engine reset guard",
70
+ "If the output begins with `<repl_engine_reset>`, the kernel was rebuilt from the last snapshot. " +
71
+ "Some variables may be revived, some lost, and anything defined after the snapshot is gone. " +
72
+ "Re-verify variables before reusing them — never interpolate a restored variable into a shell " +
73
+ "command until you have confirmed it still holds what you expect. Functions, classes, and live " +
74
+ "handles cannot be snapshotted and must be redefined.",
53
75
  ];
54
76
  }
@@ -1,4 +1,4 @@
1
- // --- pure layout, free of pi imports so unit tests can drive it directly ---
1
+ /** Pure layout, free of pi imports so unit tests drive it directly. */
2
2
 
3
3
  export interface ExecuteDetails {
4
4
  status?: "ok" | "error" | "aborted" | string;
@@ -21,7 +21,7 @@ export interface ExecuteRenderState {
21
21
  hasResult: boolean;
22
22
  }
23
23
 
24
- import { previewCell } from "./preview-core.js";
24
+ import { previewCell } from "./preview/index.js";
25
25
 
26
26
  export type StatusKind = "error" | "aborted" | "running" | "queued" | "done";
27
27
  export type BgKind = "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
@@ -49,11 +49,7 @@ export function formatDuration(durationMs: number | undefined): string | undefin
49
49
 
50
50
  const SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
51
51
 
52
- /**
53
- * Append a reset when `line` ends with a foreground or background color still
54
- * open, so a span that wrapping split across lines cannot bleed into the
55
- * trailing padding or the next row.
56
- */
52
+ /** Close an open color so a line wrapped across words can't bleed into padding or the next row. */
57
53
  export function closeOpenSgr(line: string): string {
58
54
  let fgOpen = false;
59
55
  let bgOpen = false;
@@ -134,7 +130,7 @@ function outputText(state: ExecuteRenderState): string {
134
130
  function topLine(state: ExecuteRenderState, width: number, deps: RenderDeps): string {
135
131
  const code = state.code.trimEnd();
136
132
  const preview = previewCell(code);
137
- const language = preview.kind === "shell" ? "repl · shell" : preview.kind === "agent" ? "repl · agent" : "repl";
133
+ const language = preview.kind === "shell" ? "repl · shell" : "repl";
138
134
  const prefix = `${marker(state, deps)} ${deps.fg("muted", language)}`;
139
135
 
140
136
  // --- suffix priority: expand hint > error > duration > counts, so truncation never hides the expand key ---
@@ -168,7 +164,7 @@ function topLine(state: ExecuteRenderState, width: number, deps: RenderDeps): st
168
164
  // --- budget: width minus leading space, prefix, suffix, and separators ---
169
165
  const fixed = 1 + deps.visibleWidth(prefix) + separatorWidth + deps.visibleWidth(suffix);
170
166
  const previewBudget = Math.max(8, width - fixed - separatorWidth);
171
- // --- a semantic preview is a one-line summary; highlight Python code, accent shell/agent intent ---
167
+ // --- a semantic preview is a one-line summary; highlight Python code, accent shell intent ---
172
168
  let middle = "";
173
169
  if (preview.text) {
174
170
  const previewText =
@@ -1,9 +1,4 @@
1
- /**
2
- * TUI adapter for the `execute` cell renderer.
3
- *
4
- * Binds pi's theme, syntax highlighting, key hints, and width primitives to the
5
- * pure layout in render-core.ts, which is unit-tested outside pi's runtime.
6
- */
1
+ /** TUI adapter binding pi's theme/width to the unit-tested pure layout in render-core.ts. */
7
2
 
8
3
  import { highlightCode, keyHint, keyText, rawKeyHint, type Theme } from "@mariozechner/pi-coding-agent";
9
4
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
@@ -35,11 +30,7 @@ function makeDeps(theme: Theme): RenderDeps {
35
30
  };
36
31
  }
37
32
 
38
- /**
39
- * The layout of a cell only changes when its state or the spinner frame does,
40
- * but the TUI repaints on every frame. Rendering from a key of both stops the
41
- * recompute-per-frame (and with it, flicker on wide panes).
42
- */
33
+ /** The layout only changes on state/spinner change, but the TUI repaints every frame; key by both to avoid recompute flicker. */
43
34
  function renderVersion(state: ExecuteRenderState): string {
44
35
  const details = state.details ? JSON.stringify(state.details) : "";
45
36
  return [
@@ -1,12 +1,8 @@
1
- // A session may get teardown without session_start on reload, so revival is part of create() (was a real defect)
1
+ // revival is part of create() so a session that gets teardown without a session_start reload still revives
2
2
 
3
3
  import type { RestoreResult } from "../engine/index.js";
4
4
 
5
- /**
6
- * A revived session can carry hundreds of variables; listing them all turns
7
- * the banner and the reset notice into a wall. Show enough to orient, then
8
- * count the rest.
9
- */
5
+ /** Show enough names to orient, then count the rest (a revive can carry hundreds). */
10
6
  export function summarizeNames(names: readonly string[], limit: number): string {
11
7
  if (names.length <= limit) return names.join(", ");
12
8
  return `${names.slice(0, limit).join(", ")} … and ${names.length - limit} more`;
@@ -22,23 +18,15 @@ export interface EngineLifecycleDeps<E extends RevivableEngine> {
22
18
  create(): E;
23
19
  /** Tears the current engine down, flushing its final snapshot. */
24
20
  dispose(engine: E): Promise<void>;
25
- /**
26
- * Tears down an engine that cannot cooperate — a wedged guest cannot serve
27
- * the snapshot flush dispose would ask of it. Falls back to dispose.
28
- */
21
+ /** Kill-then-rebuild when a wedged engine cannot serve the snapshot flush. */
29
22
  discard?(engine: E): Promise<void>;
30
23
  }
31
24
 
32
- /**
33
- * Why an engine came into existence. `startup` is the expected path and is
34
- * already announced in the transcript; `cell` means an engine had to be built
35
- * to serve a tool call, which only happens when the previous one went away
36
- * mid-session — the case the model needs told about in-band.
37
- */
25
+ /** `startup` is announced in the transcript; `cell` means an engine was rebuilt mid-session and needs an in-band notice. */
38
26
  export type AcquireOrigin = "startup" | "cell";
39
27
 
40
28
  function formatEngineResetNotice(restore: RestoreResult | null): string {
41
- const lines = ["<rlm_engine_reset>"];
29
+ const lines = ["<repl_engine_reset>"];
42
30
  if (!restore) {
43
31
  // --- no snapshot at all: namespace is genuinely empty ---
44
32
  lines.push(
@@ -73,7 +61,7 @@ function formatEngineResetNotice(restore: RestoreResult | null): string {
73
61
  }
74
62
  lines.push("Anything defined after the last snapshot is also gone.");
75
63
  }
76
- lines.push("Re-verify a variable before reusing it, especially inside shell interpolation.", "</rlm_engine_reset>");
64
+ lines.push("Re-verify a variable before reusing it, especially inside shell interpolation.", "</repl_engine_reset>");
77
65
  return lines.join("\n");
78
66
  }
79
67
 
@@ -81,25 +69,19 @@ export class EngineLifecycle<E extends RevivableEngine> {
81
69
  private engine?: E;
82
70
  private revival?: Promise<RestoreResult | null>;
83
71
  private pendingNotice?: string;
84
- /** Teardown in progress; a rebuild must not overlap the final snapshot flush. */
85
72
  private teardown?: Promise<void>;
86
- /** First-build in progress: concurrent acquire() must not spawn two engines. */
73
+ /** First-build in progress. */
87
74
  private acquiring?: Promise<{ engine: E; restore: RestoreResult | null; created: boolean }>;
88
75
 
89
76
  constructor(private readonly deps: EngineLifecycleDeps<E>) {}
90
77
 
91
- /**
92
- * The live engine, built and revived if it does not exist yet.
93
- * Revival is awaited here so a caller never sees an un-revived namespace.
94
- */
78
+ /** Built and revived on demand; awaited so callers never see an un-revived namespace. */
95
79
  async acquire(origin: AcquireOrigin): Promise<{ engine: E; restore: RestoreResult | null; created: boolean }> {
96
80
  if (this.engine) {
97
81
  return { engine: this.engine, restore: await this.revival!, created: false };
98
82
  }
99
- // --- two concurrent acquires on an empty engine must share one build ---
100
83
  if (this.acquiring) return this.acquiring;
101
84
  const build = (async () => {
102
- // --- a teardown flushing its final snapshot must finish before the rebuild reads it ---
103
85
  while (this.teardown) await this.teardown;
104
86
  if (this.engine) {
105
87
  const held: E = this.engine;
@@ -131,11 +113,7 @@ export class EngineLifecycle<E extends RevivableEngine> {
131
113
  await this.teardownWith((engine) => this.deps.dispose(engine));
132
114
  }
133
115
 
134
- /**
135
- * Teardown for an engine that cannot cooperate (e.g. wedged in synchronous
136
- * code). Skips the snapshot flush a graceful dispose would attempt; the next
137
- * acquire builds a fresh engine revived from the last completed snapshot.
138
- */
116
+ /** Kill-then-rebuild for a wedged engine; skips the final snapshot flush, uses the last good one. */
139
117
  async discard(): Promise<void> {
140
118
  await this.teardownWith((engine) => (this.deps.discard ?? this.deps.dispose)(engine));
141
119
  }
@@ -1,15 +1,15 @@
1
1
  // --- tool-meta: thin surface assembling the execute tool's prompt from pure modules ---
2
- // --- the model contract lives in prompt.ts; only the toolbox wiring stays here ---
2
+ // --- the model contract lives in prompt.ts; only the helpers wiring stays here ---
3
3
 
4
+ import { buildHelpersMap } from "./helpers.js";
4
5
  import { buildPromptGuidelines, executePromptSnippet, executeToolDescription } from "./prompt.js";
5
- import { buildToolboxMap } from "./toolbox.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 toolbox, falling back when nothing is preloaded ---
11
- export function buildExecutePromptGuidelines(toolboxDir?: string): string[] {
12
- const map = buildToolboxMap(toolboxDir);
13
- const preloaded = map.length > 0 ? map : ["(none preloaded: define your own)"];
10
+ // --- build the guidelines from the one helpers dir (default ~/.pi/agent/pi-repl/helpers) ---
11
+ export function buildExecutePromptGuidelines(): string[] {
12
+ const map = buildHelpersMap();
13
+ const preloaded = map.length > 0 ? map : [];
14
14
  return buildPromptGuidelines(preloaded);
15
15
  }
@@ -1,108 +0,0 @@
1
- # How to add a toolbox function
2
-
3
- A toolbox function is one `.py` file that pi-repl loads into every kernel and
4
- surfaces to the model through the `execute` tool's prompt guidance (its
5
- signature + one-line summary appears in `promptGuidelines`). Add a file, and it
6
- shows up wherever the toolbox is read.
7
-
8
- > **When a change shows up.** The kernel loads the toolbox at boot, and the
9
- > `execute` tool builds its function list at registration (module load), so a
10
- > toolbox change (add/remove a file, rename one with a `_` prefix) is picked up
11
- > by a **session restart / `/reload`** — not mid-session.
12
-
13
- ## Where functions live
14
-
15
- By default the extension ships four (`read`, `write`, `edit`, `bash`) in
16
- `src/engine/toolbox/`. To use your **own** set, set `toolboxDir` in your
17
- config:
18
-
19
- ```jsonc
20
- // ~/.pi/agent/pi-repl/config.json
21
- { "toolboxDir": "~/.pi/agent/pi-repl/functions" }
22
- ```
23
-
24
- Use an absolute path or a `~`-prefixed one (`~` expands to your home). A bare relative
25
- path resolves from the process working directory, which is not reliable, so prefer
26
- an absolute path for a stable per-user folder. Point `toolboxDir` at a directory and
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.
30
-
31
- ## The file contract
32
-
33
- Every toolbox file must:
34
-
35
- 1. have a `def` whose signature is the real call an agent would use, and
36
- 2. may declare `function_description` (a short one-line summary shown in the
37
- prompt).
38
-
39
- A minimal, valid file:
40
-
41
- ```python
42
- # pi-repl/functions/summarize.py
43
- function_description = """Return a first-sentence summary of a text."""
44
-
45
- __all__ = ["summarize"]
46
-
47
- def summarize(text, limit=1):
48
- return ". ".join(text.split(". ")[:limit]) + "."
49
- ```
50
-
51
- That is everything. `summarize` loads into the kernel and the `execute` tool's
52
- prompt guidance shows `summarize(text, limit=1)` after the next session restart.
53
-
54
- ## The two pieces the loader reads
55
-
56
- **1. The signature comes from the `def`, not the description.**
57
- Arguments are read from the actual `def` line rather than hand-copied into a
58
- docstring, so the signature the model sees tracks the code for a normal
59
- single-line signature. Change `def summarize(text,
60
- limit=1):` to `limit=200`, and after the next session restart the prompt
61
- updates to match.
62
-
63
- **2. The description, from `function_description`, optional.**
64
- Used as the one-line summary in the `execute` tool's prompt guidance. If you
65
- omit it, the function is still advertised (by signature), just without a
66
- one-liner.
67
-
68
- Each file should also give the function a real docstring (the text under
69
- `def`). That docstring is shown by `help(name)` in the kernel and carries the
70
- deeper usage and gotchas. It does not go into the execute tool's prompt
71
- guidance (only the one-line `function_description` does). Keep it for
72
- details, the venv note, and edge cases.
73
-
74
- ## How much to document
75
-
76
- `function_description` is the summary; the `def` docstring is the detail. A
77
- good `function_description` is one line ("Run a shell command and return its
78
- result."). A good docstring explains arguments, return value, and any
79
- non-obvious behavior, including environment facts the model needs
80
- ("the evaluator runs in a project-local venv, not the system python").
81
-
82
- ## Disabling a file without deleting it
83
-
84
- Rename the file to start with an underscore: `_test_helper.py`. The loader
85
- **(and the execute tool's prompt guidance)** skip underscore-prefixed files, so
86
- it never reaches the kernel or the model. Use this for scratch or internal
87
- helpers.
88
-
89
- ## Good practice
90
-
91
- - One function per file, name matches the function.
92
- - Keep `function_description` one line. Everything else goes in the docstring.
93
- - Let the signature carry the truth; the description says what it's *for*.
94
- - A function that can hang (a shell call, network) should say so in its
95
- docstring so the model knows the trade-off.
96
-
97
- ## Confirming it worked
98
-
99
- At a `pi --repl` prompt, run a cell:
100
-
101
- ```python
102
- print(ls()) # list what's loaded
103
- print(help('summarize')) # signature + full docstring details
104
- ```
105
-
106
- If `summarize` shows up in `ls()` and `help`, it loaded. The `execute` tool's
107
- prompt guidance also lists it (same first-line summary) after the next session
108
- restart.