pi-repl-py 0.1.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,66 @@
1
+ // --- trust: fd3 is protocol-only (user output stays on stdout), and a cell can't forge frames (minted nonce) ---
2
+
3
+ interface HostToGuest {
4
+ run: { type: "run"; cellId: string; code: string };
5
+ abort: { type: "abort"; cellId: string };
6
+ ping: { type: "ping"; id: string };
7
+ snapshot: { type: "snapshot"; id: string };
8
+ restore: { type: "restore"; id: string; vars: Record<string, string> };
9
+ list_names: { type: "list_names"; id: string };
10
+ }
11
+
12
+ export type HostToGuestMessage = HostToGuest[keyof HostToGuest];
13
+
14
+ interface GuestToHost {
15
+ ready: { type: "ready" };
16
+ stream: { type: "stream"; cellId: string; name: "stdout" | "stderr"; chunk: string };
17
+ done: {
18
+ type: "done";
19
+ cellId: string;
20
+ status: "ok" | "error" | "aborted";
21
+ result?: string;
22
+ error?: { name: string; message: string; stack: string[] };
23
+ };
24
+ pong: { type: "pong"; id: string };
25
+ snapshot_result: {
26
+ type: "snapshot_result";
27
+ id: string;
28
+ vars: Record<string, string>;
29
+ failed: { name: string; reason: string }[];
30
+ /** False means the kernel didn't finish serializing; keep the last good file. */
31
+ complete?: boolean;
32
+ };
33
+ restore_result: {
34
+ type: "restore_result";
35
+ id: string;
36
+ restored: string[];
37
+ failed: { name: string; reason: string }[];
38
+ };
39
+ names_result: { type: "names_result"; id: string; names: string[] };
40
+ }
41
+
42
+ export type GuestToHostMessage = GuestToHost[keyof GuestToHost];
43
+
44
+ const ENVELOPE_KEY = "__rlm";
45
+ /** Env var carrying the per-process nonce to the guest. */
46
+ export const NONCE_ENV = "PI_RLM_NONCE";
47
+ /** Protocol pipe: guest → host. */
48
+ export const PROTOCOL_FD = 3;
49
+
50
+ export function encodeMessage(message: HostToGuestMessage | GuestToHostMessage, nonce?: string): string {
51
+ const envelope: Record<string, unknown> = { [ENVELOPE_KEY]: 1, ...message };
52
+ if (nonce) envelope.n = nonce;
53
+ return `${JSON.stringify(envelope)}\n`;
54
+ }
55
+
56
+ export function decodeMessage<T>(line: string, nonce?: string): T | null {
57
+ if (!line.trim()) return null;
58
+ try {
59
+ const parsed = JSON.parse(line);
60
+ if (parsed?.[ENVELOPE_KEY] !== 1 || typeof parsed.type !== "string") return null;
61
+ if (nonce && parsed.n !== nonce) return null;
62
+ return parsed as T;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
@@ -0,0 +1,72 @@
1
+ function_description = """Run a shell command in a fresh subshell and return its result."""
2
+
3
+ import os as _os
4
+ import signal as _sig
5
+ import subprocess as _sp
6
+
7
+
8
+ def _kill_group(proc):
9
+ """Kill the whole process group of a child (its shell AND any grandchildren).
10
+
11
+ The shell's children inherit the fresh session's id, so they are the only
12
+ ones a timeout must reap; without this a `find | sort` that outlives the
13
+ call would keep chewing CPU long after bash() returned.
14
+ """
15
+ try:
16
+ _os.killpg(_os.getpgid(proc.pid), _sig.SIGKILL)
17
+ except Exception:
18
+ pass
19
+
20
+
21
+ def bash(command, cwd=None, env=None, input=None, timeout=None):
22
+ """Run a shell command and return a CompletedProcess.
23
+
24
+ Argument notes:
25
+ command - the shell command string to run.
26
+ cwd - optional directory to run it in; uses the evaluator's cwd if omitted.
27
+ env - optional dict of environment variables merged into the current env.
28
+ input - optional string to feed as stdin.
29
+ timeout - optional timeout in seconds; raises TimeoutExpired (after killing
30
+ the command's whole process group) if exceeded.
31
+
32
+ Result:
33
+ Returns subprocess.CompletedProcess. Read .stdout, .stderr, .returncode.
34
+ Example: out = bash("git log --oneline"); print(out.returncode, out.stdout)
35
+
36
+ Behaviour:
37
+ - Runs via the shell, so pipes/&&/etc. work. Each call runs a FRESH
38
+ subshell: cd, export, and shell variables do NOT carry across calls.
39
+ Hold state in Python variables instead.
40
+ - The shell runs in its own process group, so a timeout kills the group —
41
+ no orphaned children keep running afterwards.
42
+ - `env` is merged into the current environment, not a replacement.
43
+
44
+ Environment:
45
+ This evaluator runs in a project-local Python venv, not the system
46
+ interpreter. A command that starts python/pip should target the same venv.
47
+ """
48
+ merged_env = dict(_os.environ)
49
+ if env:
50
+ merged_env.update(env)
51
+ with _sp.Popen(
52
+ command,
53
+ shell=True,
54
+ stdin=_sp.PIPE,
55
+ stdout=_sp.PIPE,
56
+ stderr=_sp.PIPE,
57
+ text=True,
58
+ cwd=cwd,
59
+ env=merged_env,
60
+ # --- new session => shell+children form one process group a timeout kills ---
61
+ start_new_session=_os.name == "posix",
62
+ ) as proc:
63
+ try:
64
+ stdout, stderr = proc.communicate(input=input, timeout=timeout)
65
+ except _sp.TimeoutExpired:
66
+ _kill_group(proc)
67
+ try:
68
+ proc.communicate() # --- drain so no zombie is left ---
69
+ except Exception:
70
+ pass
71
+ raise
72
+ return _sp.CompletedProcess(args=command, returncode=proc.returncode, stdout=stdout, stderr=stderr)
@@ -0,0 +1,37 @@
1
+ function_description = """Replace old_text with new_text in a file; fails if old_text is not found exactly once."""
2
+
3
+
4
+ def edit(path, old_text, new_text):
5
+ """Perform a targeted single replacement in a file.
6
+
7
+ Argument notes:
8
+ old_text - exact, unique text already in the file to replace.
9
+ new_text - text to substitute for it.
10
+
11
+ Behaviour:
12
+ - Requires old_text to appear EXACTLY once in the file. Zero or multiple
13
+ matches raise an error instead of guessing, so it can never silently
14
+ mangle a file it wasn't sure about.
15
+ - If it fails, the file is left untouched.
16
+
17
+ Environment:
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-venv; for a
20
+ repo checkout it is .venv/. The file edited is a real file on disk.
21
+ """
22
+ with open(path, encoding="utf-8") as f:
23
+ content = f.read()
24
+ count = content.count(old_text)
25
+ if count == 0:
26
+ raise ValueError(
27
+ f"edit: could not find the given old_text in {path} — it may have already been "
28
+ "applied or the file changed. Re-read the file and retry."
29
+ )
30
+ if count > 1:
31
+ raise ValueError(
32
+ f"edit: old_text occurs {count} times in {path} — make it more specific so it matches exactly once."
33
+ )
34
+ content = content.replace(old_text, new_text, 1)
35
+ with open(path, "w", encoding="utf-8") as f:
36
+ f.write(content)
37
+ return f"edited {path}"
@@ -0,0 +1,26 @@
1
+ function_description = """Return the text of a file, optionally a slice of its lines."""
2
+
3
+
4
+ def read(path, offset=1, limit=None):
5
+ """Read a file's UTF-8 text and return it, optionally a slice of its lines.
6
+
7
+ Argument notes:
8
+ offset - 1-based first line to return (default 1).
9
+ limit - maximum number of lines to return (default: all of them).
10
+
11
+ Behaviour:
12
+ - Decoding errors are replaced instead of raising, so a binary-adjacent
13
+ file still returns most of its text.
14
+ - Keeps only what you ask for so a huge file can't flood context.
15
+
16
+ Environment:
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-venv; for a
19
+ repo checkout it is .venv/. python / pip on PATH may point elsewhere, so
20
+ do not assume the system python is what's running.
21
+ """
22
+ with open(path, encoding="utf-8", errors="replace") as f:
23
+ lines = f.readlines()
24
+ start = max(0, (offset or 1) - 1)
25
+ end = None if limit is None else start + limit
26
+ return "".join(lines[start:end])
@@ -0,0 +1,23 @@
1
+ function_description = """Write content to a file, creating it or overwriting its entire contents."""
2
+
3
+
4
+ def write(path, content):
5
+ """Write a file wholesale, creating it if missing or replacing its contents.
6
+
7
+ Argument notes:
8
+ content - string (or anything stringifiable) to write in full.
9
+
10
+ Behaviour:
11
+ - Unconditional: overwrites whatever is there. There is no size cap.
12
+ - Use edit() for a targeted change inside an existing file; write() is for
13
+ a new file or a full rewrite.
14
+
15
+ Environment:
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-venv; for a
18
+ repo checkout it is .venv/. Files you write are real files on disk in the
19
+ working directory, visible to the host and other processes.
20
+ """
21
+ with open(path, "w", encoding="utf-8") as f:
22
+ f.write(content if isinstance(content, str) else str(content))
23
+ return f"wrote {path} ({len(str(content))} chars)"
@@ -0,0 +1,65 @@
1
+ /**
2
+ * config.ts — reads the user's pi-repl config.
3
+ *
4
+ * Where the venv python path, the toolbox directory, and timeouts are
5
+ * configured. First-found-wins, never throws on a missing/malformed file.
6
+ *
7
+ * $PI_REPL_CONFIG explicit env override
8
+ * ~/.pi/agent/pi-repl.json user-global (same dir as pi's settings.json)
9
+ *
10
+ * The loadable function set is the toolbox directory (see engine/toolbox/);
11
+ * there is no separate helpers list. The extension ships with the four default
12
+ * functions (read/write/edit/bash) and a user points toolboxDir at their own
13
+ * folder to replace them.
14
+ */
15
+
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ export interface ReplConfig {
21
+ /** Python interpreter used to spawn the guest. Defaults to the venv / PATH. */
22
+ pythonPath?: string;
23
+ /** Directory of toolbox function files, exec'd into every kernel. */
24
+ toolboxDir?: string;
25
+ /** Stall watchdog, ms: 0 = no cap, nonzero = no-output-for-N-ms trips it. */
26
+ timeoutMs: number;
27
+ /** Debounce for the auto-snapshot after an ok cell, ms. Default 1500. */
28
+ snapshotDebounceMs: number;
29
+ }
30
+
31
+ const DEFAULT_CONFIG: ReplConfig = {
32
+ // 0 = no stall provecap: cells run until they finish. A nonzero value is a
33
+ // SILENCE watchdog (no output for N ms), not a wall-clock deadline.
34
+ timeoutMs: 0,
35
+ snapshotDebounceMs: 1500,
36
+ };
37
+
38
+ function num(v: unknown, dflt: number): number {
39
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : dflt;
40
+ }
41
+
42
+ function configCandidates(): string[] {
43
+ const env = process.env.PI_REPL_CONFIG;
44
+ const user = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".pi", "agent"), "pi-repl.json");
45
+ return [env, user].filter((p): p is string => !!p && p.length > 0);
46
+ }
47
+
48
+ /** Load + validate; return defaults on any failure. */
49
+ export function loadConfig(): ReplConfig {
50
+ for (const file of configCandidates()) {
51
+ try {
52
+ if (!existsSync(file)) continue;
53
+ const raw: unknown = JSON.parse(readFileSync(file, "utf8"));
54
+ if (typeof raw !== "object" || raw === null) return { ...DEFAULT_CONFIG };
55
+ const r = raw as Record<string, unknown>;
56
+ return {
57
+ pythonPath: typeof r.pythonPath === "string" && r.pythonPath.length > 0 ? r.pythonPath : undefined,
58
+ toolboxDir: typeof r.toolboxDir === "string" && r.toolboxDir.length > 0 ? r.toolboxDir : undefined,
59
+ timeoutMs: num(r.timeoutMs, DEFAULT_CONFIG.timeoutMs),
60
+ snapshotDebounceMs: num(r.snapshotDebounceMs, DEFAULT_CONFIG.snapshotDebounceMs),
61
+ };
62
+ } catch {}
63
+ }
64
+ return { ...DEFAULT_CONFIG };
65
+ }