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,320 +0,0 @@
1
- """
2
- guest.py — the real IPython kernel guest evaluator for pi-repl.
3
-
4
- The host spawns this once. It starts a local ipykernel subprocess via
5
- jupyter_client, keeps it for the session, and bridges the wire protocol to it
6
- (stdin = commands, fd 3 = results). State survives because the kernel process
7
- does. Frames carry a nonce the host mints and the guest erases, so agent code
8
- cannot forge protocol traffic.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import json
14
- import os
15
- import sys
16
- import time
17
-
18
- # --- protocol envelope ---
19
- ENVELOPE_KEY = "__rlm"
20
- NONCE_ENV = "PI_RLM_NONCE"
21
- PROTOCOL_FD = 3
22
-
23
- NONCE = os.environ.get(NONCE_ENV, "")
24
- os.environ.pop(NONCE_ENV, None)
25
-
26
- # --- per-cell timeout: 0 = no cap; else a silence watchdog (no output for N seconds) ---
27
- CELL_TIMEOUT_S = float(os.environ.get("PI_REPL_TIMEOUT_MS", "0") or "0") / 1000.0
28
-
29
- # --- snapshot/restore use a fixed window, not the cell silence timer ---
30
- SNAPSHOT_TIMEOUT_S = 90.0
31
-
32
- # --- cap buffered output so a runaway print can't grow guest memory or send one giant frame ---
33
- MAX_CELL_OUTPUT_CHARS = 1_000_000
34
-
35
- # --- fd 3 protocol writer; dup'd so we don't close the caller's fd 3 on exit ---
36
- _proto = os.fdopen(os.dup(PROTOCOL_FD), "w", buffering=1)
37
-
38
-
39
- def _send(msg):
40
- envelope = {ENVELOPE_KEY: 1, **msg}
41
- if NONCE:
42
- envelope["n"] = NONCE
43
- _proto.write(json.dumps(envelope) + "\n")
44
- _proto.flush()
45
-
46
-
47
- def _decode(line):
48
- if ENVELOPE_KEY not in line:
49
- return None
50
- try:
51
- obj = json.loads(line)
52
- except Exception:
53
- return None
54
- if obj.get(ENVELOPE_KEY) != 1 or not isinstance(obj.get("type"), str):
55
- return None
56
- if NONCE and obj.get("n") != NONCE:
57
- return None
58
- return obj
59
-
60
-
61
- # --- toolbox: one function per *.py, exec'd into every kernel (PI_TOOLBOX_DIR) ---
62
-
63
- def _toolbox_files(directory):
64
- """Return {function_name: source} for each *.py in `directory`."""
65
- if not directory:
66
- return {}
67
- d = os.path.expanduser(directory)
68
- if not os.path.isdir(d):
69
- return {}
70
- names = {}
71
- for entry in sorted(os.listdir(d)):
72
- if not entry.endswith(".py"):
73
- continue
74
- name = entry[:-3]
75
- if not name.isidentifier() or name.startswith("_"):
76
- continue
77
- try:
78
- with open(os.path.join(d, entry), encoding="utf-8") as f:
79
- names[name] = f.read()
80
- except OSError:
81
- continue
82
- return names
83
-
84
- DEFAULT_TOOLBOX_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "toolbox")
85
- TOOLBOX_DIR = os.environ.get("PI_TOOLBOX_DIR", "").strip()
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))
90
-
91
- # --- help/ls are part of the evaluator, not the toolbox ---
92
- INTRINSIC = """
93
- # --- ls() filters IPython-injected names out of the tool list ---
94
- _RPL_LS_NOISE = {'exit', 'quit', 'get_ipython', 'open', 'display'}
95
-
96
- def ls():
97
- return sorted(n for n in globals() if n not in _RPL_LS_NOISE and not n.startswith('_') and callable(globals()[n]))
98
-
99
- def help(name=None):
100
- if name is None:
101
- return ls()
102
- fn = globals().get(name)
103
- if fn is None or not callable(fn):
104
- return f"no such function: {name!r}"
105
- return fn.__doc__ or f"{name} (no docstring)"
106
- """
107
-
108
-
109
- from jupyter_client import KernelManager
110
-
111
-
112
- class Kernel:
113
- """A persistent subprocess ipykernel + blocking client."""
114
-
115
- def __init__(self):
116
- self.km = KernelManager(kernel_name="python3")
117
- self.km.start_kernel()
118
- self.kc = self.km.client()
119
- self.kc.start_channels()
120
- self.kc.wait_for_ready(timeout=30)
121
- self._preload()
122
-
123
- def _preload(self):
124
- """Exec every toolbox function + the intrinsic help/ls into the kernel ns."""
125
- code = INTRINSIC + "\n"
126
- for src in _TOOLBOX_SRC.values():
127
- code += src + "\n"
128
- if code.strip():
129
- self.kc.execute(code)
130
- self._drain()
131
-
132
- def _drain(self):
133
- try:
134
- while True:
135
- m = self.kc.get_iopub_msg(timeout=1)
136
- if m.get("msg_type") == "status" and m.get("content", {}).get("execution_state") == "idle":
137
- break
138
- except Exception:
139
- pass
140
-
141
- def _drain_execution(self, code, timeout):
142
- """Run `code`; return (stdout, stderr, error_text, result, timed_out).
143
-
144
- `timeout <= 0` means "no cap": a cell runs until it reports idle.
145
- `timeout > 0` is a SILENCE watchdog — it trips only once the cell has
146
- produced no message for `timeout` seconds. A silent-but-running command
147
- (e.g. `find ... | sort`) is allowed to complete; a stalled one (dead
148
- kernel, or nothing for the silence window) reports `timed_out=True` so
149
- the caller can surface a real hang instead of faking success.
150
- """
151
- msg_id = self.kc.execute(code)
152
- out, err, error, result = [], [], None, None
153
- out_len, err_len = 0, 0
154
- timed_out = False
155
- # --- silence clock only starts once the cell begins (grace for a fresh kernel) ---
156
- last_activity: float | None = None
157
- while True:
158
- if not self.km.is_alive():
159
- timed_out = True
160
- break
161
- if last_activity is not None and timeout and (time.monotonic() - last_activity) >= timeout:
162
- timed_out = True
163
- break
164
- wait = (timeout - (time.monotonic() - last_activity)) if (last_activity is not None and timeout) else 0.25
165
- try:
166
- m = self.kc.get_iopub_msg(timeout=max(0.01, min(0.25, wait)))
167
- except Exception:
168
- continue
169
- if m.get("parent_header", {}).get("msg_id") != msg_id:
170
- continue
171
- if last_activity is None:
172
- last_activity = time.monotonic()
173
- else:
174
- last_activity = time.monotonic()
175
- mt = m.get("msg_type")
176
- c = m.get("content", {})
177
- if mt == "stream":
178
- is_out = c.get("name") == "stdout"
179
- text = c.get("text", "") or ""
180
- if is_out:
181
- if out_len < MAX_CELL_OUTPUT_CHARS:
182
- take = text[: MAX_CELL_OUTPUT_CHARS - out_len]
183
- out.append(take)
184
- out_len += len(take)
185
- else:
186
- if err_len < MAX_CELL_OUTPUT_CHARS:
187
- take = text[: MAX_CELL_OUTPUT_CHARS - err_len]
188
- err.append(take)
189
- err_len += len(take)
190
- elif mt == "execute_result":
191
- result = c.get("data", {}).get("text/plain")
192
- elif mt == "error":
193
- error = "\n".join(c.get("traceback", ["(no traceback)"]))
194
- elif mt == "status" and c.get("execution_state") == "idle":
195
- break
196
- if timed_out:
197
- # --- best-effort cancel so the NEXT cell doesn't queue behind this one ---
198
- try:
199
- self.kc.interrupt_kernel()
200
- except Exception:
201
- pass
202
- return "".join(out), "".join(err), error, result, timed_out
203
-
204
- def execute(self, code):
205
- """Idle-sync path used by snapshot/restore; not a user cell."""
206
- return self._drain_execution(code, SNAPSHOT_TIMEOUT_S)[:4]
207
-
208
- def run_cell(self, code):
209
- """Run a user cell under the per-cell timeout, so the model learns
210
- when work did not finish."""
211
- return self._drain_execution(code, CELL_TIMEOUT_S)
212
-
213
- def snapshot_globals(self):
214
- # --- snapshot only user state (skip toolbox/intrinsic functions and _ names) ---
215
- tool_names = sorted(set(_TOOLBOX_SRC) | {"ls", "help", "function_description"})
216
- skip_names = json.dumps(tool_names)
217
- out, _, _, _ = self.execute(
218
- "import pickle as _pk, base64 as _b64, json as _js\n"
219
- "__rlm_skip = set(" + skip_names + ") | {'In','Out','get_ipython','exit','quit','open'}\n"
220
- "__rlm_v = {}\n__rlm_f = []\n"
221
- "for _k, _v in list(globals().items()):\n"
222
- " # skip IPython bookkeeping and names with a leading underscore\n"
223
- " if _k.startswith('__') or _k.startswith('_') or _k in __rlm_skip:\n"
224
- " continue\n"
225
- " try:\n"
226
- " __rlm_v[_k] = _b64.b64encode(_pk.dumps(_v)).decode()\n"
227
- " except Exception as _e:\n"
228
- " __rlm_f.append({'name': _k, 'reason': str(_e)})\n"
229
- "print('__RLC_SNAPSHOT__' + _js.dumps({'vars': __rlm_v, 'failed': __rlm_f}))\n"
230
- )
231
- marker = "__RLC_SNAPSHOT__"
232
- if marker not in out:
233
- # --- marker never printed: serialization stalled; report incomplete so the host keeps the last good file ---
234
- return {}, [], False
235
- try:
236
- o = json.loads(out.split(marker)[-1])
237
- return o.get("vars", {}), o.get("failed", []), True
238
- except Exception:
239
- return {}, [], False
240
-
241
- def restore_globals(self, vars_):
242
- if not vars_:
243
- return [], []
244
- # --- restore each variable in one atomic kernel call so one failure reports itself ---
245
- code2 = (
246
- "import pickle as _pk, base64 as _b64, json as _js\n"
247
- "__rl_r = {'restored': [], 'failed': []}\n"
248
- + "\n".join(
249
- "try:\n"
250
- f" globals()[{name!r}] = _pk.loads(_b64.b64decode({b64!r}))\n"
251
- f" __rl_r['restored'].append({name!r})\n"
252
- "except Exception as _e:\n"
253
- f" __rl_r['failed'].append({{'name': {name!r}, 'reason': str(_e)}})\n"
254
- for name, b64 in vars_.items()
255
- )
256
- + "\nprint('__RLC_RESTORE__' + _js.dumps(__rl_r))"
257
- )
258
- out, _, _, _ = self.execute(code2)
259
- marker = "__RLC_RESTORE__"
260
- if marker not in out:
261
- return [], []
262
- try:
263
- obj = json.loads(out.split(marker)[-1])
264
- return obj.get("restored", []), obj.get("failed", [])
265
- except Exception:
266
- return [], []
267
-
268
-
269
- def _line_error(text):
270
- lines = text.split("\n")
271
- return {"name": "", "message": text, "stack": lines[:12]}
272
-
273
-
274
- def main():
275
- kernel = Kernel()
276
- _send({"type": "ready"})
277
-
278
- for line in sys.stdin:
279
- msg = _decode(line)
280
- if not msg:
281
- continue
282
- t = msg["type"]
283
- if t == "ping":
284
- _send({"type": "pong", "id": msg["id"]})
285
- elif t == "snapshot":
286
- vars_, failed, complete = kernel.snapshot_globals()
287
- _send({"type": "snapshot_result", "id": msg["id"], "vars": vars_, "failed": failed, "complete": complete})
288
- elif t == "restore":
289
- restored, failed = kernel.restore_globals(msg.get("vars", {}))
290
- _send({"type": "restore_result", "id": msg["id"], "restored": restored, "failed": failed})
291
- elif t == "list_names":
292
- names = list(kernel.snapshot_globals()[0].keys())
293
- _send({"type": "names_result", "id": msg["id"], "names": names})
294
- elif t == "run":
295
- cell_id = msg.get("cellId")
296
- stdout, stderr, error, result, timed_out = kernel.run_cell(msg.get("code", ""))
297
- if stdout:
298
- _send({"type": "stream", "cellId": cell_id, "name": "stdout", "chunk": stdout})
299
- if stderr:
300
- _send({"type": "stream", "cellId": cell_id, "name": "stderr", "chunk": stderr})
301
- if timed_out:
302
- tmsg = {
303
- "name": "Timeout",
304
- "message": f"cell did not finish within {CELL_TIMEOUT_S:g}s and may still be running",
305
- "stack": ["[cell timed out]"],
306
- }
307
- _send({"type": "done", "cellId": cell_id, "status": "error", "error": tmsg})
308
- elif error:
309
- _send({"type": "done", "cellId": cell_id, "status": "error", "error": _line_error(error)})
310
- else:
311
- _send({"type": "done", "cellId": cell_id, "status": "ok", "result": result})
312
- # --- a single-threaded guest can't read 'abort' mid-cell; the host discards+rebuilds ---
313
-
314
-
315
- if __name__ == "__main__":
316
- try:
317
- main()
318
- except Exception as e:
319
- _send({"type": "done", "cellId": "", "status": "error", "error": _line_error(str(e))})
320
- sys.exit(1)
@@ -1,66 +0,0 @@
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
- }
@@ -1,72 +0,0 @@
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)
@@ -1,37 +0,0 @@
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}"
@@ -1,26 +0,0 @@
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])
@@ -1,23 +0,0 @@
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)"
@@ -1,64 +0,0 @@
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/config.json user-global
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
- // --- timeoutMs: 0 = no cap; nonzero = silence watchdog (no output for N ms) ---
33
- timeoutMs: 0,
34
- snapshotDebounceMs: 1500,
35
- };
36
-
37
- function num(v: unknown, dflt: number): number {
38
- return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : dflt;
39
- }
40
-
41
- function configCandidates(): string[] {
42
- const env = process.env.PI_REPL_CONFIG;
43
- const user = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".pi", "agent", "pi-repl"), "config.json");
44
- return [env, user].filter((p): p is string => !!p && p.length > 0);
45
- }
46
-
47
- /** Load + validate; return defaults on any failure. */
48
- export function loadConfig(): ReplConfig {
49
- for (const file of configCandidates()) {
50
- try {
51
- if (!existsSync(file)) continue;
52
- const raw: unknown = JSON.parse(readFileSync(file, "utf8"));
53
- if (typeof raw !== "object" || raw === null) return { ...DEFAULT_CONFIG };
54
- const r = raw as Record<string, unknown>;
55
- return {
56
- pythonPath: typeof r.pythonPath === "string" && r.pythonPath.length > 0 ? r.pythonPath : undefined,
57
- toolboxDir: typeof r.toolboxDir === "string" && r.toolboxDir.length > 0 ? r.toolboxDir : undefined,
58
- timeoutMs: num(r.timeoutMs, DEFAULT_CONFIG.timeoutMs),
59
- snapshotDebounceMs: num(r.snapshotDebounceMs, DEFAULT_CONFIG.snapshotDebounceMs),
60
- };
61
- } catch {}
62
- }
63
- return { ...DEFAULT_CONFIG };
64
- }
@@ -1,2 +0,0 @@
1
- // --- thin aggregator: the preview surface kept at this path so callers and tests stay put ---
2
- export { type CellPreview, descriptor, previewCell, previewShellCommand } from "./preview/index.js";