pi-repl-py 0.1.0 → 0.2.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.
@@ -0,0 +1,59 @@
1
+ // --- scan: tokenizer source → template spans, string constants, and masks ---
2
+ import { BACKTICK, type Span } from "./types.js";
3
+
4
+ // --- capture the template opened at start; tracks escapes + interpolation nesting so a shell command reads whole, and an unclosed template returns the rest (partial is better than none) ---
5
+ export function scanTemplate(source: string, start: number): Span {
6
+ let depth = 0;
7
+ let inNested = false;
8
+ for (let i = start + 1; i < source.length; i++) {
9
+ const ch = source[i];
10
+ if (ch === "\\") {
11
+ i += 1;
12
+ continue;
13
+ }
14
+ if (ch === BACKTICK) {
15
+ if (depth === 0 && !inNested) return { start, end: i + 1, body: source.slice(start + 1, i) };
16
+ inNested = !inNested;
17
+ continue;
18
+ }
19
+ if (!inNested && ch === "$" && source[i + 1] === "{") {
20
+ depth += 1;
21
+ i += 1;
22
+ continue;
23
+ }
24
+ if (!inNested && depth > 0 && ch === "}") depth -= 1;
25
+ }
26
+ return { start, end: source.length, body: source.slice(start + 1) };
27
+ }
28
+
29
+ const CONST_STRING_PATTERN = new RegExp(
30
+ '(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*(?:"([^"\\n]*)"|' +
31
+ "'([^'\\n]*)'|" +
32
+ BACKTICK +
33
+ "([^" +
34
+ BACKTICK +
35
+ "$\\n]*)" +
36
+ BACKTICK +
37
+ ")",
38
+ "g",
39
+ );
40
+
41
+ // --- collected simple string constants, for resolving interpolations and path args ---
42
+ export function stringConsts(source: string): Map<string, string> {
43
+ const vars = new Map<string, string>();
44
+ for (const match of source.matchAll(CONST_STRING_PATTERN)) {
45
+ const name = match[1];
46
+ const value = match[2] ?? match[3] ?? match[4];
47
+ if (name && value !== undefined) vars.set(name, value);
48
+ }
49
+ return vars;
50
+ }
51
+
52
+ export function substituteVars(text: string, vars: ReadonlyMap<string, string>): string {
53
+ return text.replace(/\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g, (whole, name: string) => vars.get(name) ?? whole);
54
+ }
55
+
56
+ // --- blank a claimed span so later detectors don't re-read what an earlier one took ---
57
+ export function maskSpan(source: string, span: Span): string {
58
+ return source.slice(0, span.start) + " ".repeat(span.end - span.start) + source.slice(span.end);
59
+ }
@@ -0,0 +1,156 @@
1
+ // --- shell: resolve the strongest single line of a (possibly chained) shell command ---
2
+ import { descriptor } from "./descriptor.js";
3
+
4
+ const CD_PREFIX_PATTERN = /^\s*cd\s+([^&;|]+?)\s*(?:&&|;)\s*/;
5
+ const SHELL_SETUP_PATTERN = /^(?:export\s+\w+=|set\s+[-+]|source\s+\S+|\.\s+\S+)/;
6
+ const HEREDOC_PATTERN = /<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/;
7
+
8
+ export function shellWords(line: string): string[] {
9
+ const words: string[] = [];
10
+ for (const match of line.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
11
+ words.push(match[1] ?? match[2] ?? match[3] ?? "");
12
+ }
13
+ return words;
14
+ }
15
+
16
+ function pathTail(path: string): string {
17
+ const cleaned = path.replace(/\/+$/, "");
18
+ const tail = cleaned.slice(cleaned.lastIndexOf("/") + 1);
19
+ return tail || cleaned;
20
+ }
21
+
22
+ function simplifyRunnerCommand(line: string): string | undefined {
23
+ const words = shellWords(line);
24
+ if (words[0] === "npm" || words[0] === "pnpm") {
25
+ const runIndex = words.indexOf("run");
26
+ if (runIndex >= 0 && words[runIndex + 1]) {
27
+ return `${words[0]} ${words.slice(runIndex + 1).join(" ")}`.trim();
28
+ }
29
+ }
30
+ if (line.includes("node_modules/.bin/")) {
31
+ return line.replace(/\S*node_modules\/\.bin\//g, "");
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ function simplifyMutationCommand(line: string): string | undefined {
37
+ const words = shellWords(line);
38
+ if (words.length === 0) return undefined;
39
+ if (words[0] === "cat" && words[1] === ">" && words[2]) return `write ${pathTail(words[2])}`;
40
+ if (words[0] === "tee" && words.at(-1)) {
41
+ return (words.includes("-a") ? "append " : "write ") + pathTail(words.at(-1) ?? "");
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ // --- collapse noisier command forms (runners, writes) down to the intent ---
47
+ function simplifyShellLine(line: string): string {
48
+ return simplifyRunnerCommand(line) ?? simplifyMutationCommand(line) ?? line;
49
+ }
50
+
51
+ // --- commands that prepare the ground; the shell only wins when it is the story ---
52
+ export const SHELL_SETUP_WORDS = new Set([
53
+ "mkdir",
54
+ "cd",
55
+ "export",
56
+ "touch",
57
+ "chmod",
58
+ "chown",
59
+ "ln",
60
+ "echo",
61
+ "true",
62
+ "sleep",
63
+ "which",
64
+ "sync",
65
+ ]);
66
+
67
+ const SHELL_ACTION_WORDS = new Set([
68
+ "rm",
69
+ "mv",
70
+ "cp",
71
+ "git",
72
+ "npm",
73
+ "pnpm",
74
+ "bun",
75
+ "bunx",
76
+ "npx",
77
+ "make",
78
+ "cargo",
79
+ "docker",
80
+ "curl",
81
+ "gh",
82
+ "pi",
83
+ ]);
84
+
85
+ function shellLineScore(line: string, index: number): number {
86
+ const simplified = simplifyShellLine(line);
87
+ const words = shellWords(line);
88
+ let score = 30;
89
+ if (simplified !== line) score += 40;
90
+ if (SHELL_ACTION_WORDS.has(words[0] ?? "")) score += 20;
91
+ if (/\b(?:rm|mv|cp|git\s+(?:add|commit|push)|sed\s+-i|perl\s+-pi|tee|cat\s*>)\b/.test(line)) score += 40;
92
+ return score + index;
93
+ }
94
+
95
+ function heredocBody(lines: readonly string[], startIndex: number, delimiter: string): string | undefined {
96
+ const body: string[] = [];
97
+ for (let i = startIndex + 1; i < lines.length; i++) {
98
+ if ((lines[i] ?? "").trim() === delimiter) return body.join("\n");
99
+ body.push(lines[i] ?? "");
100
+ }
101
+ return body.length > 0 ? body.join("\n") : undefined;
102
+ }
103
+
104
+ function previewHeredoc(lines: readonly string[]): string | undefined {
105
+ for (let i = 0; i < lines.length; i++) {
106
+ const line = (lines[i] ?? "").trim();
107
+ const delimiter = line.match(HEREDOC_PATTERN)?.[1];
108
+ if (!delimiter) continue;
109
+ const body = heredocBody(lines, i, delimiter);
110
+ if (!body) continue;
111
+ // --- the write target is the story; the body is detail for the expanded view ---
112
+ const catWrite = line.match(/\b(?:cat|tee)\b.*(?:>|\s)(\S+)\s*<<-?/);
113
+ if (catWrite?.[1]) return (line.includes("tee -a") ? "append " : "write ") + pathTail(catWrite[1]);
114
+ return descriptor(body);
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ export function previewShellCommand(command: string): string {
120
+ return previewShellCommandScored(command).text;
121
+ }
122
+
123
+ // --- like previewShellCommand but keeps the winning line's strength so several shell calls can rank ---
124
+ export function previewShellCommandScored(command: string): { text: string; strength: number } {
125
+ const lines = command.split("\n");
126
+ const heredoc = previewHeredoc(lines);
127
+ if (heredoc) return { text: descriptor(heredoc), strength: 90 };
128
+
129
+ let best: { text: string; score: number } | undefined;
130
+ let cwdSuffix: string | undefined;
131
+ let index = 0;
132
+ for (const rawLine of lines) {
133
+ for (const rawPart of rawLine.split(/\s*(?:&&|;)\s*/)) {
134
+ let part = rawPart.trim();
135
+ if (!part || part.startsWith("#") || SHELL_SETUP_PATTERN.test(part)) continue;
136
+ const cd = part.match(CD_PREFIX_PATTERN);
137
+ if (cd?.[1]) {
138
+ cwdSuffix = pathTail(cd[1].trim());
139
+ part = part.replace(CD_PREFIX_PATTERN, "").trim();
140
+ } else if (/^cd\s+\S+$/.test(part)) {
141
+ cwdSuffix = pathTail(part.slice(2).trim());
142
+ continue;
143
+ }
144
+ if (!part) continue;
145
+ const candidate = { text: simplifyShellLine(part), score: shellLineScore(part, index) };
146
+ if (!best || candidate.score > best.score) best = candidate;
147
+ index += 1;
148
+ }
149
+ }
150
+ if (!best) return { text: "", strength: 0 };
151
+ // --- trailing redirections are plumbing, not intent ---
152
+ const cleaned = best.text.replace(/(?:\s*(?:2>&1|[12]?>\s*\/dev\/null|&>\s*\/dev\/null))+\s*$/, "");
153
+ // --- a stripped cd prefix still matters when it names a non-default dir ---
154
+ const text = cwdSuffix && !cleaned.includes(cwdSuffix) ? `${cleaned} (${cwdSuffix})` : cleaned;
155
+ return { text: descriptor(text), strength: best.score };
156
+ }
@@ -0,0 +1,23 @@
1
+ // --- shared preview types; tiny module so every consumer imports only the shape it needs ---
2
+ type CellPreviewKind = "shell" | "ts";
3
+
4
+ export interface CellPreview {
5
+ kind: CellPreviewKind;
6
+ text: string;
7
+ }
8
+
9
+ // --- a [start, end) slice of the source with its captured body ---
10
+ export interface Span {
11
+ start: number;
12
+ end: number;
13
+ body: string;
14
+ }
15
+
16
+ // --- the winner of each detector, ranked by score in the orchestration ---
17
+ export interface Candidate {
18
+ kind: CellPreviewKind;
19
+ text: string;
20
+ score: number;
21
+ }
22
+
23
+ export const BACKTICK = "\u0060";
@@ -0,0 +1,76 @@
1
+ // --- prompt: the execute tool's model-facing contract (pure, no pi/helper dep) ---
2
+
3
+ export const executeToolDescription =
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.";
10
+
11
+ export const executePromptSnippet =
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
+
15
+ // --- the workspace doctrine riding the execute tool ---
16
+ export function buildPromptGuidelines(preloaded: string[]): string[] {
17
+ return [
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.",
75
+ ];
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;
@@ -65,8 +61,7 @@ export function closeOpenSgr(line: string): string {
65
61
  fgOpen = false;
66
62
  bgOpen = false;
67
63
  } else if (code === 38 || code === 48) {
68
- // Skip the payload of 38;5;n / 38;2;r;g;b so a component (e.g. 38)
69
- // is not read as another SGR code.
64
+ // --- skip the 38;5;n / 38;2;r;g;b payload so a component isn't read as another SGR code ---
70
65
  if (code === 38) fgOpen = true;
71
66
  else bgOpen = true;
72
67
  const mode = Number(params[i + 1]);
@@ -135,14 +130,10 @@ function outputText(state: ExecuteRenderState): string {
135
130
  function topLine(state: ExecuteRenderState, width: number, deps: RenderDeps): string {
136
131
  const code = state.code.trimEnd();
137
132
  const preview = previewCell(code);
138
- const language = preview.kind === "shell" ? "repl · shell" : preview.kind === "agent" ? "repl · agent" : "repl";
133
+ const language = preview.kind === "shell" ? "repl · shell" : "repl";
139
134
  const prefix = `${marker(state, deps)} ${deps.fg("muted", language)}`;
140
135
 
141
- // Fixed metadata after the preview must always survive; the preview
142
- // absorbs all truncation. Counts settle-only: live updates jitter the header.
143
- // Suffix order is by priority: the expand hint must survive first, then the
144
- // error, then duration, then counts. Truncation happens from the right, so
145
- // low-priority items are elided before the user loses the expand keybinding.
136
+ // --- suffix priority: expand hint > error > duration > counts, so truncation never hides the expand key ---
146
137
  const suffixParts: string[] = [];
147
138
  suffixParts.push(deps.keyHint(state.expanded));
148
139
 
@@ -170,12 +161,10 @@ function topLine(state: ExecuteRenderState, width: number, deps: RenderDeps): st
170
161
  const separator = deps.fg("dim", " · ");
171
162
  const separatorWidth = deps.visibleWidth(separator);
172
163
  const suffix = suffixParts.join(separator);
173
- // Budget: total width minus leading space, prefix, suffix, separators.
164
+ // --- budget: width minus leading space, prefix, suffix, and separators ---
174
165
  const fixed = 1 + deps.visibleWidth(prefix) + separatorWidth + deps.visibleWidth(suffix);
175
166
  const previewBudget = Math.max(8, width - fixed - separatorWidth);
176
- // A semantic preview is a one-line summary of the code. Highlight Python
177
- // code the same way the expanded block is highlighted; shell/agent previews
178
- // stay accent-colored so they read as intent, not syntax.
167
+ // --- a semantic preview is a one-line summary; highlight Python code, accent shell intent ---
179
168
  let middle = "";
180
169
  if (preview.text) {
181
170
  const previewText =
@@ -191,12 +180,7 @@ function topLine(state: ExecuteRenderState, width: number, deps: RenderDeps): st
191
180
  }
192
181
 
193
182
  function sanitizeTuiOutput(text: string): string {
194
- // Terminal escape sequences and control characters from user code output can
195
- // move the cursor, change colors, or print zero-width glyphs that break the
196
- // TUI layout. Color SGR / CSI sequences (e.g. IPython's colored tracebacks)
197
- // are STRIPPED so text stays readable; a remaining lone escape byte and other
198
- // control chars are shown as Unicode control pictures so nothing is silently
199
- // eaten. Tabs expand to 4 spaces; CR becomes ␍.
183
+ // --- strip ANSI SGR/CSI and escape control chars for a readable TUI; tabs expand, CR becomes ␍ ---
200
184
  return text
201
185
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
202
186
  .replace(/\x1b/g, "␛")
@@ -245,7 +229,7 @@ function renderCode(state: ExecuteRenderState, lines: string[], width: number, d
245
229
  const highlighted = highlightLines(code, deps);
246
230
  for (const [index, rawLine] of code.split("\n").entries()) {
247
231
  const prefix = index === 0 ? deps.fg("dim", "› ") : deps.fg("dim", " ");
248
- // Code is already syntax-highlighted; don't strip its ANSI.
232
+ // --- code is already highlighted; don't strip its ANSI ---
249
233
  addWrapped(lines, prefix, highlighted[index] ?? rawLine, width, deps, { sanitize: false });
250
234
  }
251
235
  return true;
@@ -263,9 +247,7 @@ function renderOutput(
263
247
  const details = state.details;
264
248
  const output: string[] = [];
265
249
 
266
- // stdout/stderr/result are color-coded and labeled so you can tell which
267
- // stream a line came from at a glance. Sanitize the raw text before
268
- // applying the section color, or our own ANSI gets escaped as user output.
250
+ // --- stdout/stderr/result are color-coded; sanitize before section color so our ANSI isn't escaped ---
269
251
  const sections: Array<{ text: string | undefined; color: string; label: string }> = [
270
252
  { text: details?.stdout, color: "toolOutput", label: "stdout" },
271
253
  { text: details?.stderr, color: "warning", label: "stderr" },
@@ -337,8 +319,6 @@ export function renderExecuteBody(state: ExecuteRenderState, width: number, deps
337
319
  const lines: string[] = [];
338
320
  const hasCode = renderCode(state, lines, safeWidth, deps);
339
321
  renderOutput(state, lines, safeWidth, hasCode, deps);
340
- // A thin bottom border separates the expanded cell from whatever follows.
341
- if (lines.length > 0) lines.push(` ${deps.fg("dim", "─".repeat(Math.max(1, safeWidth - 1)))}`);
342
322
  const kind = statusKind(state);
343
323
  return lines.map((line) => paintBackground(line, safeWidth, kind, deps));
344
324
  }
@@ -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,58 +1,15 @@
1
- /**
2
- * The `execute` tool's prompt surface.
3
- *
4
- * pi's default system prompt is used as-is; all REPL knowledge rides on the
5
- * tool via these fields, so index.ts stays thin.
6
- *
7
- * - description — working summary (schema card).
8
- * - promptSnippet — one line in the default `Available tools`.
9
- * - promptGuidelines — the function doctrine + tokens + safety.
10
- *
11
- * The function map is derived from the toolbox source via buildToolboxMap()
12
- * (function_description docstring + def-signature regex), so it always matches
13
- * what the kernel loads.
14
- */
1
+ // --- tool-meta: thin surface assembling the execute tool's prompt from pure modules ---
2
+ // --- the model contract lives in prompt.ts; only the helpers wiring stays here ---
15
3
 
16
- import { buildToolboxMap } from "./toolbox.js";
4
+ import { buildHelpersMap } from "./helpers.js";
5
+ import { buildPromptGuidelines, executePromptSnippet, executeToolDescription } from "./prompt.js";
17
6
 
18
- export const EXECUTE_DESCRIPTION =
19
- "Execute Python to a persistent evaluator: the session's working memory. " +
20
- "Variables, imports, functions, and data survive across calls. There are no " +
21
- "separate file or shell tools; read, write, edit, bash, and anything you build " +
22
- "are Python functions you call inside a cell. A cell returns its final " +
23
- "expression; anything else is printed. Build one reusable function per routine " +
24
- "and call it by arguments, since a new def overwrites the previous one; don't " +
25
- "narrate that machinery to the user. Runs in a project-local venv, so a command " +
26
- "that starts python or pip must target that venv.";
7
+ export const EXECUTE_DESCRIPTION = executeToolDescription;
8
+ export const EXECUTE_PROMPT_SNIPPET = executePromptSnippet;
27
9
 
28
- export const EXECUTE_PROMPT_SNIPPET =
29
- "Execute Python in a persistent evaluator whose variables, imports, and functions " +
30
- "survive across calls; preloaded functions plus any you define and reuse as " +
31
- "callable tools; ls() lists them, help(name) shows usage";
32
-
33
- /**
34
- * promptGuidelines for the execute tool. `toolboxDir` is optional; it defaults to
35
- * the shipped toolbox.
36
- */
37
- export function buildExecutePromptGuidelines(toolboxDir?: string): string[] {
38
- const map = buildToolboxMap(toolboxDir);
39
- const preloaded = map.length > 0 ? map : ["(none preloaded: define your own)"];
40
- return [
41
- "Preloaded functions available in every kernel:",
42
- ...preloaded,
43
- "ls() prints what is loaded; help(name) shows a function's signature and notes. Use them instead of guessing.",
44
- "Functions you define are reusable tools: one parameterized helper per task, call it by arguments. Never write a routine twice and never fork a duplicate; extend the existing `def` (a new `def` of the same name overwrites).",
45
- "Before a multi-line cell, ask whether you will run that shape again with different inputs. If yes, define the function now so each later request is one call.",
46
- "Good, defined once then called by arguments only:",
47
- "def fetch_news(query, hl='en', gl='US', ceid='US:en', limit=15):\n <fetch + parse to a list>\nfetch_news('Turkey')\nfetch_news('Nigeria', hl='en-NG')",
48
- "Don't build a near-copy (avoid fetch_news and fetch_news_region); add the varying bits to the original `def` and let it supersede the old.",
49
- "Other reusable shapes build the same way:",
50
- "def find_files(pred, root='.'):\n <walk root, filter by pred>\nfind_files('*.csv')\nfind_files('*.py', root='src')\ndef count_lines(paths): ... # compose: count_lines(find_files('*.csv'))",
51
- "Use functions proportionally: build one when it will be reused, otherwise run it in a plain cell. Don't wrap a one-off and don't over-engineer.",
52
- "Never narrate your mechanism to the user (don't say 'I defined a function' or 'I built a tool'). Do the job, then answer with the result.",
53
- "Be token efficient: everything a cell prints is context for the rest of the turn. When reading or searching, print slices, matches, or counts rather than whole files, and keep large values in variables.",
54
- "For whole-filesystem or large-dir scans, use the kernel's tools via bash, not a Python walk: find, du, fd, grep. Chain them (find -xdev -type f -size +100M | sort -rn | head; du -x | sort -h | tail) and prune descent by skipping node_modules, .git, caches, venvs. A Python os.walk + lstat loop pays a slow syscall per file and runs minutes to 10+ min on a big tree; reserve Python for analysing the results, not for enumerating the disk.",
55
- "If the output starts with <rlm_engine_reset>, the kernel was rebuilt: only data is restored, your functions are gone. Recreate any helper you need and re-verify a variable before trusting it.",
56
- "Don't install packages into the evaluator; the standard library is available. Run out-of-tree projects through their own environment.",
57
- ];
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
+ return buildPromptGuidelines(preloaded);
58
15
  }