pi-repl-py 0.1.0 → 0.1.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.
package/README.md CHANGED
@@ -33,7 +33,7 @@ A plain `pi` session is untouched; the extension is dormant until `--repl` is pa
33
33
  ## Installing as a pi package
34
34
 
35
35
  `npm install` runs a `postinstall` that creates the Python venv the evaluator needs, at a stable
36
- per-user path (`~/.pi/agent/pi-repl-venv`). If `python3` or the network is missing, it prints a
36
+ per-user path (`~/.pi/agent/pi-repl/venv`). If `python3` or the network is missing, it prints a
37
37
  clear notice. How the interpreter is resolved is in [docs/philosophy.md](docs/philosophy.md).
38
38
 
39
39
  ## What you get
@@ -52,7 +52,19 @@ A small set of Python functions is preloaded into every kernel and surfaced to t
52
52
  model through the `execute` tool's prompt guidance (their signatures + one-line
53
53
  summaries are listed there, and `ls()`/`help()` discover them at runtime), so the
54
54
  model can call `read`, `write`, `edit`, and `bash` without reimplementing them.
55
- Set `toolboxDir` to point at your own folder.
55
+ Set `toolboxDir` to point at your own folder; its functions are loaded **in addition to** the built-ins, and a file there with the **same name** as a built-in overrides it.
56
+
57
+ Everything the extension keeps lives under one folder in your home directory:
58
+
59
+ ```
60
+ ~/.pi/agent/pi-repl/
61
+ config.json settings (toolboxDir, pythonPath, timeoutMs)
62
+ venv/ the Python interpreter + ipykernel
63
+ functions/ your custom toolbox functions, if any
64
+ state/ per-session namespace snapshots
65
+ ```
66
+
67
+ These functions are the evaluator's standard file-and-shell surface; the model reaches for them as its builtins and composes its own reusable tools on top.
56
68
 
57
69
  The function list shown to the model is built when the `execute` tool is
58
70
  registered, so changing the toolbox (adding/removing a file, renaming one with a
@@ -63,13 +75,13 @@ the kernel also only loads the toolbox at boot.
63
75
 
64
76
  ## Configuration
65
77
 
66
- `~/.pi/agent/pi-repl.json` (or `$PI_REPL_CONFIG`) sets `toolboxDir`, `pythonPath`, and timeouts.
67
- Full keys and path rules: [ARCHITECTURE.md](ARCHITECTURE.md).
78
+ `~/.pi/agent/pi-repl/config.json` (or `$PI_REPL_CONFIG`) sets `toolboxDir`, `pythonPath`, and timeouts.
79
+ Full keys and path rules: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
68
80
 
69
81
  ## More
70
82
 
71
83
  - Why this design: [docs/philosophy.md](docs/philosophy.md)
72
- - How it works, the venv, config reference: [ARCHITECTURE.md](ARCHITECTURE.md)
84
+ - How it works, the venv, config reference: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
73
85
 
74
86
  ## It is not
75
87
 
@@ -26,7 +26,7 @@ When installed as a pi package, `npm install` runs `postinstall`
26
26
  (`scripts/setup-venv.mjs`), which creates a stable per-user venv:
27
27
 
28
28
  ```
29
- ~/.pi/agent/pi-repl-venv/bin/python3
29
+ ~/.pi/agent/pi-repl/venv/bin/python3
30
30
  ```
31
31
 
32
32
  That path is stable across updates because it sits outside the ephemeral
@@ -38,7 +38,7 @@ At spawn, `resolvePythonPath` chooses the interpreter in order:
38
38
 
39
39
  1. the repo's own `.venv` (development)
40
40
  2. a cwd-local `.venv` (project)
41
- 3. `~/.pi/agent/pi-repl-venv` (package install)
41
+ 3. `~/.pi/agent/pi-repl/venv` (package install)
42
42
  4. `$PYTHON` or `python3`
43
43
 
44
44
  The first existing one wins. The model is told (via `help()`) that it runs in a
@@ -94,7 +94,8 @@ bare kernel still lets the model discover what is loaded.
94
94
  After each successful cell the host schedules a debounced snapshot: it asks
95
95
  the guest to pickle the kernel's globals (entry-by-entry so one bad value
96
96
  costs only itself), and stores that as `namespace.snapshot` keyed to the
97
- session file. On a fresh engine it restores, and whatever cannot be pickled
97
+ session file under `~/.pi/agent/pi-repl/state/<session>/`. On a fresh engine it
98
+ restores, and whatever cannot be pickled
98
99
  (live handles, some objects) is reported by name.
99
100
 
100
101
  If the evaluator restarts, the result is prefixed with a `<rlm_engine_reset>`
@@ -125,12 +126,12 @@ Gate: `just check` = biome + bun test (host) + pytest (guest).
125
126
 
126
127
  ## Configuration reference
127
128
 
128
- Loaded from `~/.pi/agent/pi-repl.json` (or `$PI_REPL_CONFIG`), first-found-wins, never
129
+ Loaded from `~/.pi/agent/pi-repl/config.json` (or `$PI_REPL_CONFIG`), first-found-wins, never
129
130
  throws on a missing/malformed file.
130
131
 
131
132
  | Key | Type / default | Meaning |
132
133
  | --- | --- | --- |
133
- | `toolboxDir` | string, optional | Directory of one-function-per-`.py` files that replaces the shipped `src/engine/toolbox`. `~` is expanded; a bare relative path resolves from the process cwd (not reliable) — prefer an absolute path. |
134
+ | `toolboxDir` | string, optional | Directory of one-function-per-`.py` files that ADDS to the shipped `src/engine/toolbox` and, when a name collides, overrides that built-in. `~` is expanded; a bare relative path resolves from the process cwd (not reliable) — prefer an absolute path. |
134
135
  | `pythonPath` | string, optional | The interpreter used to spawn the guest. Omit to use `resolvePythonPath` (see venv). |
135
136
  | `timeoutMs` | number, 60000 | Per-cell execution timeout in ms. |
136
137
  | `snapshotDebounceMs` | number, 1500 | Debounce after an ok cell before snapshot, in ms. |
@@ -17,15 +17,16 @@ By default the extension ships four (`read`, `write`, `edit`, `bash`) in
17
17
  config:
18
18
 
19
19
  ```jsonc
20
- // ~/.pi/agent/pi-repl.json
21
- { "toolboxDir": "~/.pi/agent/pi-repl-functions" }
20
+ // ~/.pi/agent/pi-repl/config.json
21
+ { "toolboxDir": "~/.pi/agent/pi-repl/functions" }
22
22
  ```
23
23
 
24
24
  Use an absolute path or a `~`-prefixed one (`~` expands to your home). A bare relative
25
25
  path resolves from the process working directory, which is not reliable, so prefer
26
26
  an absolute path for a stable per-user folder. Point `toolboxDir` at a directory and
27
- every `*.py` there is loaded. Note: it **replaces** the shipped defaults; you do not
28
- get built-ins plus yours, unless you copy the built-ins into your folder too.
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.
29
30
 
30
31
  ## The file contract
31
32
 
@@ -38,7 +39,7 @@ Every toolbox file must:
38
39
  A minimal, valid file:
39
40
 
40
41
  ```python
41
- # pi-repl-functions/summarize.py
42
+ # pi-repl/functions/summarize.py
42
43
  function_description = """Return a first-sentence summary of a text."""
43
44
 
44
45
  __all__ = ["summarize"]
@@ -57,7 +57,7 @@ Because the evaluator is real Python, it needs a real Python environment with
57
57
  `ipykernel` + `jupyter_client`. You cannot conjure that from nothing.
58
58
 
59
59
  The package's `postinstall` creates it once, at a stable user path
60
- (`~/.pi/agent/pi-repl-venv`), so a `pi install` ends with a working evaluator
60
+ (`~/.pi/agent/pi-repl/venv`), so a `pi install` ends with a working evaluator
61
61
  and updates do not lose it (the venv is outside the ephemeral package dir
62
62
  where it would vanish). At runtime the host resolves the interpreter in a
63
63
  short deterministic order (repo venv, cwd venv, the install venv, then
package/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // --- pi-repl: one execute tool over Python; everything else runs as functions inside it ---
2
2
 
3
3
  import { basename, join } from "node:path";
4
+ import { homedir } from "node:os";
4
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
6
  import { Type } from "typebox";
6
7
  import { EngineBusyError, EngineManager } from "./src/engine/index.js";
@@ -62,7 +63,8 @@ export default function (pi: ExtensionAPI) {
62
63
  create() {
63
64
  const { cwd, sessionFile } = location;
64
65
  const sessionKey = sessionFile ? basename(sessionFile).replace(/\.jsonl$/, "") : undefined;
65
- const stateDir = join(cwd, ".pi-repl", sessionKey ?? "ephemeral");
66
+ // --- kernel namespace state lives under ~/.pi/agent/pi-repl, keyed by session, so it never clutters the project ---
67
+ const stateDir = join(homedir(), ".pi", "agent", "pi-repl", "state", sessionKey ?? "ephemeral");
66
68
  return new EngineManager({
67
69
  cwd,
68
70
  pythonPath: CFG.pythonPath,
@@ -81,11 +83,7 @@ export default function (pi: ExtensionAPI) {
81
83
  },
82
84
  });
83
85
 
84
- // --- no custom prompt: pi's default prompt stands. session_start collapses
85
- // the active set to just `execute`, so the default prompt's built-in
86
- // read/bash/edit/write never appear. All REPL knowledge (description,
87
- // promptSnippet, promptGuidelines) lives on the tool itself, not in a
88
- // prompt builder. ---
86
+ // --- no custom prompt: pi's default prompt stands; active tools collapse to execute, so REPL knowledge lives on the tool ---
89
87
 
90
88
  pi.on("session_start", async (_event, ctx) => {
91
89
  if (!active()) {
@@ -192,10 +190,7 @@ export default function (pi: ExtensionAPI) {
192
190
  throw new Error(text || "(no output)");
193
191
  }
194
192
  if (r.status === "aborted") {
195
- // A cancelled cell's kernel may still be executing work the guest
196
- // single-threaded loop can't interrupt. Discard the engine so the
197
- // NEXT run gets a fresh kernel instead of queuing behind the
198
- // still-busy one (same class as the stalled-timeout recovery).
193
+ // --- a busy kernel can't be interrupted mid-cell; discard+rebuild so the next run doesn't queue ---
199
194
  await lifecycle.discard();
200
195
  }
201
196
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-repl-py",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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": [
@@ -11,6 +11,14 @@
11
11
  "repl"
12
12
  ],
13
13
  "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/k3-2o/pi-repl-py.git"
17
+ },
18
+ "homepage": "https://github.com/k3-2o/pi-repl-py",
19
+ "bugs": {
20
+ "url": "https://github.com/k3-2o/pi-repl-py/issues"
21
+ },
14
22
  "pi": {
15
23
  "extensions": [
16
24
  "./index.ts"
@@ -22,7 +30,6 @@
22
30
  "docs",
23
31
  "index.ts",
24
32
  "README.md",
25
- "ARCHITECTURE.md",
26
33
  "LICENSE"
27
34
  ],
28
35
  "engines": {
@@ -8,7 +8,7 @@
8
8
  * and excluded from the npm tarball), so we create one at a stable path that
9
9
  * the engine also knows about:
10
10
  *
11
- * ~/.pi/agent/pi-repl-venv/bin/python3
11
+ * ~/.pi/agent/pi-repl/venv/bin/python3
12
12
  *
13
13
  * Failures are non-fatal: if there's no system python3 or no network, we print
14
14
  * a clear notice and let the engine fall back to '$PYTHON' or 'python3' at
@@ -20,7 +20,7 @@ import { existsSync, mkdirSync } from "node:fs";
20
20
  import { homedir } from "node:os";
21
21
  import { join } from "node:path";
22
22
 
23
- const VENV_DIR = join(homedir(), ".pi", "agent", "pi-repl-venv");
23
+ const VENV_DIR = join(homedir(), ".pi", "agent", "pi-repl", "venv");
24
24
  const PY = join(VENV_DIR, "bin", "python3");
25
25
  const DEPS = ["ipykernel", "jupyter_client"];
26
26
 
@@ -83,7 +83,10 @@ def _toolbox_files(directory):
83
83
 
84
84
  DEFAULT_TOOLBOX_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "toolbox")
85
85
  TOOLBOX_DIR = os.environ.get("PI_TOOLBOX_DIR", "").strip()
86
- _TOOLBOX_SRC = _toolbox_files(TOOLBOX_DIR or DEFAULT_TOOLBOX_DIR)
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))
87
90
 
88
91
  # --- help/ls are part of the evaluator, not the toolbox ---
89
92
  INTRINSIC = """
@@ -20,7 +20,7 @@ const GUEST_PATH = fileURLToPath(new URL("./guest.py", import.meta.url));
20
20
 
21
21
  // --- venv created by the package postinstall ---
22
22
  function installVenvPython(): string {
23
- return join(homedir(), ".pi", "agent", "pi-repl-venv", "bin", "python3");
23
+ return join(homedir(), ".pi", "agent", "pi-repl", "venv", "bin", "python3");
24
24
  }
25
25
 
26
26
  // --- prefer a venv with ipykernel; else PYTHON or python3 ---
@@ -134,8 +134,7 @@ interface ActiveExecution {
134
134
  reject(error: Error): void;
135
135
  }
136
136
 
137
- // ── process-wide cleanup ─────────────────────────────────────────────────────
138
- // Guests are killed on host exit; the guest also self-exits on stdin EOF.
137
+ // --- process-wide cleanup: guests killed on exit; the guest self-exits on stdin EOF ---
139
138
 
140
139
  const liveEngines = new Set<EngineManager>();
141
140
  let cleanupHandlersInstalled = false;
@@ -190,7 +189,7 @@ export class EngineManager {
190
189
  return this.state === "running";
191
190
  }
192
191
 
193
- // ── lifecycle ──────────────────────────────────────────────────────────────
192
+ //--- lifecycle ---
194
193
 
195
194
  async start(): Promise<void> {
196
195
  if (this.state === "shutdown") throw new Error("Engine has been shut down");
@@ -220,7 +219,7 @@ export class EngineManager {
220
219
  PI_REPL_TIMEOUT_MS: String(this.timeoutMs),
221
220
  PI_TOOLBOX_DIR: this.toolboxDir ?? "",
222
221
  },
223
- // fd 3 carries protocol; stdout/stderr stay user output.
222
+ // --- fd 3 is the protocol pipe; stdout/stderr stay user output ---
224
223
  stdio: ["pipe", "pipe", "pipe", "pipe"],
225
224
  });
226
225
  this.child = child;
@@ -247,8 +246,7 @@ export class EngineManager {
247
246
  }
248
247
  this.protocolReader = createInterface({ input: protocolStream });
249
248
  this.protocolReader.on("line", (line) => this.handleGuestLine(line));
250
- // Anything the guest writes to the real stdout/stderr fds is subprocess
251
- // output (Bun.$ without .quiet()); attribute it to the running cell.
249
+ // --- guest stdout/stderr are subprocess output; attach to the running cell ---
252
250
  child.stdout!.on("data", (buffer: Buffer) => this.appendActiveOutput("stdout", buffer.toString()));
253
251
  child.stderr!.on("data", (buffer: Buffer) => {
254
252
  const text = buffer.toString();
@@ -262,7 +260,7 @@ export class EngineManager {
262
260
  (error as NodeJS.ErrnoException).code === "ENOENT"
263
261
  ? "Engine process failed: '" +
264
262
  pythonPath +
265
- "' was not found on PATH. pi-repl runs its evaluator in Python; ensure it is installed and on your PATH, or set the pythonPath in ~/.pi/agent/pi-repl.json."
263
+ "' was not found on PATH. pi-repl runs its evaluator in Python; ensure it is installed and on your PATH, or set the pythonPath in ~/.pi/agent/pi-repl/config.json."
266
264
  : `Engine process failed: ${error.message}`;
267
265
  this.failAllPending(new Error(message));
268
266
  this.transitionToShutdown(message);
@@ -280,8 +278,7 @@ export class EngineManager {
280
278
  }
281
279
  });
282
280
 
283
- // On a boot timeout the child must be torn down and the state reset to
284
- // idle, or a retried start() orphans the previous child and its fd3 pipe.
281
+ // --- on a boot timeout tear down the child and reset state, or a retried start orphans it ---
285
282
  await ready.catch((error) => {
286
283
  if (this.child === child) this.child = undefined;
287
284
  this.protocolReader?.close();
@@ -348,7 +345,7 @@ export class EngineManager {
348
345
  await this.kill();
349
346
  }
350
347
 
351
- // ── guest messaging ────────────────────────────────────────────────────────
348
+ //--- guest messaging ---
352
349
 
353
350
  private sendToGuest(message: HostToGuestMessage): void {
354
351
  // --- a write into a dying child can throw; callers learn via the exit path ---
@@ -373,7 +370,7 @@ export class EngineManager {
373
370
  }
374
371
 
375
372
  private handleGuestLine(line: string): void {
376
- // fd 3 is protocol-only; a line that fails to decode is discarded.
373
+ // --- fd 3 is protocol-only; undecodable lines are discarded ---
377
374
  const message = decodeMessage<GuestToHostMessage>(line, this.nonce);
378
375
  if (!message) return;
379
376
  switch (message.type) {
@@ -427,7 +424,7 @@ export class EngineManager {
427
424
  pending.resolve(message);
428
425
  }
429
426
 
430
- // ── output accumulation ────────────────────────────────────────────────────
427
+ //--- output accumulation ---
431
428
 
432
429
  private appendActiveOutput(name: "stdout" | "stderr", text: string): void {
433
430
  const active = this.activeExecution;
@@ -448,15 +445,14 @@ export class EngineManager {
448
445
  } else {
449
446
  active[truncatedKey] = true;
450
447
  }
451
- // --- cap the live stream feed too, so index.ts's accumulated partial
452
- // content cannot grow past the same budget the final output is capped at ---
448
+ // --- cap the live stream feed, so partial content can't grow past the output budget ---
453
449
  const room = active.maxChars - active.streamedChars;
454
450
  const forward = Math.min(text.length, Math.max(0, room));
455
451
  if (forward > 0) active.opts.onStream?.(text.slice(0, forward), name);
456
452
  active.streamedChars += forward;
457
453
  }
458
454
 
459
- // ── execute ────────────────────────────────────────────────────────────────
455
+ //--- execute ---
460
456
 
461
457
  async execute(code: string, opts: ExecuteOptions = {}): Promise<ExecuteResult> {
462
458
  // --- claim the queue slot synchronously so order == submission order ---
@@ -565,8 +561,7 @@ export class EngineManager {
565
561
  active.settled = true;
566
562
  if (this.activeExecution === active) this.activeExecution = undefined;
567
563
 
568
- // A cancelled cell reports "aborted" even if it finished first:
569
- // the caller withdrew interest, so the value is not theirs to consume.
564
+ // --- a cancelled cell reports "aborted" even if it finished first (caller withdrew) ---
570
565
  let status = active.status;
571
566
  if (active.opts.signal?.aborted) status = "aborted";
572
567
  if (status !== "aborted") this.maybeWedged = false;
@@ -588,7 +583,7 @@ export class EngineManager {
588
583
  });
589
584
  }
590
585
 
591
- // ── snapshot / restore / names ─────────────────────────────────────────────
586
+ //--- snapshot / restore / names ---
592
587
 
593
588
  async snapshotState(): Promise<SnapshotResult | null> {
594
589
  const config = this.options.snapshot;
@@ -596,9 +591,7 @@ export class EngineManager {
596
591
  try {
597
592
  const reply = await this.request({ type: "snapshot", id: randomUUID() }, SNAPSHOT_REQUEST_TIMEOUT_MS);
598
593
  if (reply.type !== "snapshot_result") return null;
599
- // An incomplete snapshot (the guest stalled mid-serialization) must
600
- // NOT overwrite the last good file — a failed snapshot should cost a
601
- // throwaway run, never the durable memory.
594
+ // --- an incomplete snapshot must not overwrite the last good file ---
602
595
  if (reply.complete === false) return null;
603
596
  mkdirSync(dirname(config.path), { recursive: true });
604
597
  writeFileSync(config.path, JSON.stringify({ version: 1, vars: reply.vars, failed: reply.failed }));
@@ -16,7 +16,7 @@ def edit(path, old_text, new_text):
16
16
 
17
17
  Environment:
18
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
19
+ interpreter. For a package install that is ~/.pi/agent/pi-repl/venv; for a
20
20
  repo checkout it is .venv/. The file edited is a real file on disk.
21
21
  """
22
22
  with open(path, encoding="utf-8") as f:
@@ -15,7 +15,7 @@ def read(path, offset=1, limit=None):
15
15
 
16
16
  Environment:
17
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
18
+ interpreter. For a package install that is ~/.pi/agent/pi-repl/venv; for a
19
19
  repo checkout it is .venv/. python / pip on PATH may point elsewhere, so
20
20
  do not assume the system python is what's running.
21
21
  """
@@ -14,7 +14,7 @@ def write(path, content):
14
14
 
15
15
  Environment:
16
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
17
+ interpreter. For a package install that is ~/.pi/agent/pi-repl/venv; for a
18
18
  repo checkout it is .venv/. Files you write are real files on disk in the
19
19
  working directory, visible to the host and other processes.
20
20
  """
@@ -5,7 +5,7 @@
5
5
  * configured. First-found-wins, never throws on a missing/malformed file.
6
6
  *
7
7
  * $PI_REPL_CONFIG explicit env override
8
- * ~/.pi/agent/pi-repl.json user-global (same dir as pi's settings.json)
8
+ * ~/.pi/agent/pi-repl/config.json user-global
9
9
  *
10
10
  * The loadable function set is the toolbox directory (see engine/toolbox/);
11
11
  * there is no separate helpers list. The extension ships with the four default
@@ -29,8 +29,7 @@ export interface ReplConfig {
29
29
  }
30
30
 
31
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.
32
+ // --- timeoutMs: 0 = no cap; nonzero = silence watchdog (no output for N ms) ---
34
33
  timeoutMs: 0,
35
34
  snapshotDebounceMs: 1500,
36
35
  };
@@ -41,7 +40,7 @@ function num(v: unknown, dflt: number): number {
41
40
 
42
41
  function configCandidates(): string[] {
43
42
  const env = process.env.PI_REPL_CONFIG;
44
- const user = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".pi", "agent"), "pi-repl.json");
43
+ const user = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".pi", "agent", "pi-repl"), "config.json");
45
44
  return [env, user].filter((p): p is string => !!p && p.length > 0);
46
45
  }
47
46
 
@@ -0,0 +1,205 @@
1
+ // --- candidates: the five detectors that name a cell's intent, plus generic scoring ---
2
+
3
+ import { descriptor } from "./descriptor.js";
4
+ import { maskSpan, scanTemplate, substituteVars } from "./scan.js";
5
+ import { previewShellCommand, previewShellCommandScored, SHELL_SETUP_WORDS, shellWords } from "./shell.js";
6
+ import { BACKTICK, type Candidate } from "./types.js";
7
+
8
+ const SHELL_OPEN_PATTERN = new RegExp("Bun\\s*\\.\\s*\\$\\s*(?:\\([^)]*\\)\\s*)?" + BACKTICK, "g");
9
+
10
+ export function shellCandidates(
11
+ source: string,
12
+ vars: ReadonlyMap<string, string>,
13
+ ): { candidates: Candidate[]; masked: string } {
14
+ const candidates: Candidate[] = [];
15
+ let masked = source;
16
+ SHELL_OPEN_PATTERN.lastIndex = 0;
17
+ let match = SHELL_OPEN_PATTERN.exec(masked);
18
+ while (match) {
19
+ const span = scanTemplate(masked, match.index + match[0].length - 1);
20
+ const command = previewShellCommandScored(substituteVars(span.body, vars));
21
+ // --- the command's own strength breaks ties; setup-only drops lower ---
22
+ if (command.text) {
23
+ const setupOnly = SHELL_SETUP_WORDS.has(shellWords(command.text)[0] ?? "");
24
+ const score = setupOnly ? 72 : 90 + Math.min(command.strength, 200) / 25;
25
+ candidates.push({ kind: "shell", text: command.text, score });
26
+ }
27
+ masked = maskSpan(masked, span);
28
+ SHELL_OPEN_PATTERN.lastIndex = span.end;
29
+ match = SHELL_OPEN_PATTERN.exec(masked);
30
+ }
31
+ return { candidates, masked };
32
+ }
33
+
34
+ const STRING_ARG_PATTERN = /^\s*(?:"([^"]*)"|'([^']*)')/;
35
+
36
+ export function agentCandidates(
37
+ source: string,
38
+ vars: ReadonlyMap<string, string>,
39
+ ): { candidates: Candidate[]; masked: string } {
40
+ const tasks: string[] = [];
41
+ let masked = source;
42
+ const pattern = /rlm\s*\.\s*run\s*\(/g;
43
+ let match = pattern.exec(masked);
44
+ while (match) {
45
+ const argsStart = match.index + match[0].length;
46
+ let task: string | undefined;
47
+ const rest = masked.slice(argsStart);
48
+ const literal = rest.match(STRING_ARG_PATTERN);
49
+ if (literal) {
50
+ task = literal[1] ?? literal[2];
51
+ } else if (rest.trimStart().startsWith(BACKTICK)) {
52
+ const tickIndex = argsStart + rest.indexOf(BACKTICK);
53
+ const span = scanTemplate(masked, tickIndex);
54
+ task = substituteVars(span.body, vars);
55
+ masked = maskSpan(masked, span);
56
+ } else {
57
+ const identifier = rest.match(/^\s*([A-Za-z_$][\w$]*)/)?.[1];
58
+ task = identifier ? (vars.get(identifier) ?? identifier) : undefined;
59
+ }
60
+ // --- a chosen child name is identity; lead with it ---
61
+ const name = masked.slice(argsStart).match(/name\s*:\s*(?:"([^"]*)"|'([^']*)')/);
62
+ const label = name?.[1] ?? name?.[2];
63
+ tasks.push(label && task ? label + ": " + task : (label ?? task ?? "subagent"));
64
+ pattern.lastIndex = argsStart;
65
+ match = pattern.exec(masked);
66
+ }
67
+ const candidates: Candidate[] =
68
+ tasks.length === 0
69
+ ? []
70
+ : [
71
+ {
72
+ kind: "agent",
73
+ text: descriptor(tasks.length === 1 ? (tasks[0] ?? "") : tasks[0] + " (+" + (tasks.length - 1) + " more)"),
74
+ score: 100,
75
+ },
76
+ ];
77
+ return { candidates, masked };
78
+ }
79
+
80
+ const FILE_EFFECT_PATTERN =
81
+ /(?:Bun\.write|\b(?:fs|fsp|promises)\.(?:writeFileSync|writeFile|appendFileSync|appendFile|mkdirSync|mkdir|rmSync|rmdirSync|unlinkSync|unlink|renameSync|rename|copyFileSync|copyFile|cpSync|cp)|\b(?:writeFileSync|writeFile|appendFileSync|mkdirSync|rmSync|unlinkSync|renameSync|copyFileSync))\s*\(\s*([^,)\n]+)/g;
82
+
83
+ const FILE_EFFECT_VERBS: ReadonlyArray<[string, string]> = [
84
+ ["Bun.write", "write"],
85
+ ["writeFileSync", "write"],
86
+ ["writeFile", "write"],
87
+ ["appendFileSync", "append"],
88
+ ["appendFile", "append"],
89
+ ["mkdirSync", "mkdir"],
90
+ ["mkdir", "mkdir"],
91
+ ["rmdirSync", "delete"],
92
+ ["rmSync", "delete"],
93
+ ["rm", "delete"],
94
+ ["unlinkSync", "delete"],
95
+ ["unlink", "delete"],
96
+ ["renameSync", "rename"],
97
+ ["rename", "rename"],
98
+ ["copyFileSync", "copy"],
99
+ ["copyFile", "copy"],
100
+ ["cpSync", "copy"],
101
+ ["cp", "copy"],
102
+ ];
103
+
104
+ // --- resolve a quoted literal, a known const, or an interpolated template into a plain string ---
105
+ function resolveArgText(arg: string, vars: ReadonlyMap<string, string>): string | undefined {
106
+ const trimmed = arg.trim();
107
+ const literalPattern = new RegExp("^[\"'" + BACKTICK + "]([^\"'" + BACKTICK + "]*)[\"'" + BACKTICK + "]$");
108
+ const literal = trimmed.match(literalPattern);
109
+ if (literal?.[1]) return literal[1];
110
+ if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) return vars.get(trimmed);
111
+ if (trimmed.startsWith(BACKTICK)) return substituteVars(trimmed.slice(1, -1), vars);
112
+ return undefined;
113
+ }
114
+
115
+ const FILE_READ_PATTERN = /Bun\.file\s*\(\s*([^,)\n]+?)\s*\)\s*\.\s*(?:text|json|arrayBuffer|bytes|stream)\s*\(/g;
116
+
117
+ export function fileCandidates(source: string, vars: ReadonlyMap<string, string>): Candidate[] {
118
+ const candidates: Candidate[] = [];
119
+ for (const match of source.matchAll(FILE_EFFECT_PATTERN)) {
120
+ const call = match[0];
121
+ const verb = FILE_EFFECT_VERBS.find(([name]) => call.includes(name))?.[1];
122
+ if (!verb) continue;
123
+ const path = resolveArgText(match[1] ?? "", vars);
124
+ if (path) candidates.push({ kind: "ts", text: descriptor(verb + " " + path), score: 95 });
125
+ }
126
+ for (const match of source.matchAll(FILE_READ_PATTERN)) {
127
+ const path = resolveArgText(match[1] ?? "", vars);
128
+ if (path) candidates.push({ kind: "ts", text: descriptor("read " + path), score: 70 });
129
+ }
130
+ for (const match of source.matchAll(/\bfetch\s*\(\s*([^,)\n]+)/g)) {
131
+ const url = resolveArgText(match[1] ?? "", vars);
132
+ if (url) candidates.push({ kind: "ts", text: descriptor("fetch " + url), score: 75 });
133
+ }
134
+ return candidates;
135
+ }
136
+
137
+ // --- per-tool: which arg names the target, the verb shown, and its scoring band ---
138
+ const BRIDGED_TOOLS: Record<string, { arg: string; verb: string; score: number }> = {
139
+ read: { arg: "path", verb: "read", score: 70 },
140
+ bash: { arg: "command", verb: "", score: 88 },
141
+ edit: { arg: "path", verb: "edit", score: 95 },
142
+ write: { arg: "path", verb: "write", score: 95 },
143
+ grep: { arg: "pattern", verb: "grep", score: 68 },
144
+ find: { arg: "pattern", verb: "find", score: 68 },
145
+ ls: { arg: "path", verb: "ls", score: 68 },
146
+ };
147
+
148
+ export function bridgedToolCandidates(source: string, vars: ReadonlyMap<string, string>): Candidate[] {
149
+ const candidates: Candidate[] = [];
150
+ for (const match of source.matchAll(/\btools\.(\w+)\s*\(\s*\{([^}]*)\}/g)) {
151
+ const spec = BRIDGED_TOOLS[match[1] ?? ""];
152
+ if (!spec) continue;
153
+ const props = match[2] ?? "";
154
+ const argMatch = props.match(new RegExp(spec.arg + "\\s*:\\s*([^,}]+)"));
155
+ const target = argMatch ? resolveArgText(argMatch[1] ?? "", vars) : undefined;
156
+ if (!target) continue;
157
+ // --- a bridged bash call is a command like any other ---
158
+ const text = spec.verb ? spec.verb + " " + target : previewShellCommand(target) || target;
159
+ candidates.push({ kind: "ts", text: descriptor(text), score: spec.score });
160
+ }
161
+ return candidates;
162
+ }
163
+
164
+ const SKIP_LINE_PATTERN = /^(?:$|\/\/|\/\*|\*|import\s|export\s+(?:type\s|\{)|[})\];,]+$)/;
165
+ const DEFINITION_PATTERN = /^(?:export\s+)?(?:async\s+)?(?:function\s|class\s|interface\s|type\s+\w+\s*=)/;
166
+ const ARROW_DEFINITION_PATTERN = /^(?:const|let)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?\(?[^)=]*\)?\s*=>/;
167
+ const CONTROL_PATTERN = /^(?:if|for|while|switch|try|do)\b/;
168
+ const CALL_STATEMENT_PATTERN = /^(?:await\s+)?[A-Za-z_$][\w$.]*\s*\(/;
169
+ const ASSIGNMENT_CALL_PATTERN = /^(?:const|let|var)\s+[^=]{1,60}=\s*(?:await\s+)?(?:new\s+)?[A-Za-z_$][\w$.]*\s*\(/;
170
+ const LOW_SIGNAL_CALL_PATTERN =
171
+ /^(?:await\s+)?(?:console\.\w+|String|Number|Boolean|JSON\.stringify|JSON\.parse|structuredClone)\s*\(/;
172
+ const LOW_SIGNAL_ASSIGNMENT_PATTERN =
173
+ /=\s*(?:await\s+)?(?:JSON\.parse|JSON\.stringify|String|Number|Boolean|Object\.keys|Object\.entries)\s*\(/;
174
+
175
+ function consoleInnerCall(line: string): string | undefined {
176
+ const inner = line.match(/^console\.\w+\(\s*(.+)\)\s*;?\s*$/)?.[1]?.trim();
177
+ return inner && CALL_STATEMENT_PATTERN.test(inner) && !LOW_SIGNAL_CALL_PATTERN.test(inner) ? inner : undefined;
178
+ }
179
+
180
+ function genericLineScore(line: string): number {
181
+ if (SKIP_LINE_PATTERN.test(line)) return -1;
182
+ if (LOW_SIGNAL_ASSIGNMENT_PATTERN.test(line)) return 25;
183
+ if (consoleInnerCall(line)) return 55;
184
+ if (LOW_SIGNAL_CALL_PATTERN.test(line)) return 15;
185
+ if (DEFINITION_PATTERN.test(line) || ARROW_DEFINITION_PATTERN.test(line)) return 50;
186
+ if (CONTROL_PATTERN.test(line)) return 20;
187
+ if (/^(?:return|throw)\b/.test(line)) return 45;
188
+ if (ASSIGNMENT_CALL_PATTERN.test(line)) return 60;
189
+ if (CALL_STATEMENT_PATTERN.test(line)) return 65;
190
+ if (/^(?:const|let|var)\s/.test(line)) return 22;
191
+ return 30;
192
+ }
193
+
194
+ export function genericCandidates(masked: string): Candidate[] {
195
+ const candidates: Candidate[] = [];
196
+ for (const [index, rawLine] of masked.split("\n").entries()) {
197
+ const line = rawLine.trim();
198
+ const score = genericLineScore(line);
199
+ if (score < 0) continue;
200
+ const text = consoleInnerCall(line) ?? line;
201
+ // --- later lines win ties: cells read as setup-then-act, and the act is the story ---
202
+ candidates.push({ kind: "ts", text: descriptor(text), score: score + Math.min(index, 90) / 100 });
203
+ }
204
+ return candidates;
205
+ }