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.
package/README.md CHANGED
@@ -21,7 +21,7 @@ the kernel stayed alive.
21
21
 
22
22
  ```bash
23
23
  # from a clone, one-time setup
24
- just setup # npm install + a project-local .venv with the guest deps
24
+ just setup # npm install + a project-local .venv with ipykernel
25
25
 
26
26
  # run a session
27
27
  pi --repl
@@ -34,49 +34,49 @@ A plain `pi` session is untouched; the extension is dormant until `--repl` is pa
34
34
 
35
35
  `npm install` runs a `postinstall` that creates the Python venv the evaluator needs, at a stable
36
36
  per-user path (`~/.pi/agent/pi-repl/venv`). If `python3` or the network is missing, it prints a
37
- clear notice. How the interpreter is resolved is in [docs/philosophy.md](docs/philosophy.md).
37
+ clear notice. How the interpreter is resolved is in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
38
38
 
39
39
  ## What you get
40
40
 
41
41
  - **A persistent namespace.** Variables, functions, imports, and data survive across cells and
42
42
  turns; snapshots preserve them across a best-effort restart.
43
43
  - **A real `ipython` kernel**, not a hand-rolled `exec` loop.
44
- - **Shell as values.** `bash("git log --oneline")` returns a `CompletedProcess` you read
45
- `.stdout`/`.stderr`/`.returncode` on.
44
+ - **Shell and file IO as plain Python.** `!cmd` and `%%bash` run shell fire-and-forget,
45
+ `subprocess.run(...)` brings the result back into a variable, and `open()` / `pathlib`
46
+ read and write files — no wrapper API to learn, and nothing extra to describe to the model.
46
47
  - **Error survival.** A cell that throws reports the traceback and the kernel keeps going.
47
48
  - **An honest evaluator.** If it restarts, it names what state it could revive and what it lost, so you don't trust memory that's gone.
48
49
 
49
- ## The toolbox
50
+ ## Helpers
50
51
 
51
- A small set of Python functions is preloaded into every kernel and surfaced to the
52
- model through the `execute` tool's prompt guidance (their signatures + one-line
53
- summaries are listed there, and `ls()`/`help()` discover them at runtime), so the
54
- model can call `read`, `write`, `edit`, and `bash` without reimplementing them.
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.
52
+ A **helper** is a `.py` file that gets exec'd into every kernel, so whatever it defines —
53
+ functions, classes, constants, imports, or a module that manages a tricky piece of
54
+ complexity is available in the workspace. Drop a file in the one helpers directory and
55
+ restart the session; e.g. `helpers/double.py` defining `def double(x)` becomes callable as
56
+ `double(...)`. It ships **empty** (shell and file IO are already plain Python), so a fresh
57
+ install preloads nothing until you add one. Each helper's `helper_description` is shown to
58
+ the model verbatim; the full contract lives in [docs/helpers.md](docs/helpers.md).
56
59
 
57
60
  Everything the extension keeps lives under one folder in your home directory:
58
61
 
59
62
  ```
60
63
  ~/.pi/agent/pi-repl/
61
- config.json settings (toolboxDir, pythonPath, timeoutMs)
62
64
  venv/ the Python interpreter + ipykernel
63
- functions/ your custom toolbox functions, if any
65
+ helpers/ your helpers (created empty on install; every *.py loads)
64
66
  state/ per-session namespace snapshots
65
67
  ```
66
68
 
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.
69
+ The helpers directory is fixed at `~/.pi/agent/pi-repl/helpers` no config file.
68
70
 
69
- The function list shown to the model is built when the `execute` tool is
70
- registered, so changing the toolbox (adding/removing a file, renaming one with a
71
- `_` prefix) needs a **session restart / `/reload`** for the prompt to reflect it —
72
- the kernel also only loads the toolbox at boot.
73
-
74
- - Adding a function (the file contract, docstrings, disabling): [docs/how-to-functions.md](docs/how-to-functions.md)
71
+ Changing a helper (adding/removing a file, renaming one with a `_` prefix) needs a
72
+ **session restart / `/reload`**: the prompt list is built when `execute` is registered and
73
+ the kernel execs helpers only at boot.
75
74
 
76
75
  ## Configuration
77
76
 
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).
77
+ There is deliberately no configuration file. Everything is arranged under
78
+ `~/.pi/agent/pi-repl/`: the venv, the fixed helpers dir, and the session state.
79
+ The Python interpreter is auto-resolved (the venv, else `$PYTHON`/`python3`).
80
80
 
81
81
  ## More
82
82
 
@@ -86,7 +86,7 @@ Full keys and path rules: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
86
86
  ## It is not
87
87
 
88
88
  - A sandbox. The kernel runs with your permissions; the toolbox trusts you.
89
- - A subagent framework. There are no `rlm.run` subagents; spawn a process with `bash()`.
89
+ - A subagent framework. There is no `repl.run` API; spawn a process with `subprocess.run`.
90
90
  - A pi tool-rack. It is one `execute` tool with functions inside.
91
91
 
92
92
  ## License
@@ -1,142 +1,182 @@
1
1
  # Architecture
2
2
 
3
- Two processes: the host lives inside pi, the guest owns the Python workspace.
3
+ pi-repl runs in **two processes**: the host lives inside pi and talks the standard Jupyter
4
+ protocol directly to a real `ipykernel` subprocess. There is no middleman and no invented
5
+ framing between them.
4
6
 
5
7
  ```
6
8
  pi
7
- └─ extension (index.ts) registers `execute`, dormant until --repl
8
- └─ EngineManager (src/engine/index.ts) spawn host: snapshots, teardown
9
- stdin ──▶ protocol commands (run / snapshot / restore / ping)
10
- fd 3 ◀── stream, done, snapshot_result, ...
11
- └─ guest.py jupyter_client ▶ a real ipython kernel (subprocess)
9
+ └─ extension (index.ts) registers `execute`; dormant until --repl
10
+ └─ EngineManager (src/engine/index.ts) venv resolution, lazy spawn,
11
+ the call queue, snapshots,
12
+ abort grace, teardown
13
+ └─ KernelClient (src/engine/kernel.ts) one ipykernel subprocess
14
+ ├─ ZMTP 3.0 (src/engine/zmtp.ts) the wire protocol, by hand
15
+ ├─ Jupyter session (src/engine/session.ts) framing + HMAC + JSON
16
+ └─ python -m ipykernel -f <connection-file> the evaluator
12
17
  ```
13
18
 
14
- The host is TypeScript; the evaluator is Python in its own process. Splitting
15
- them is what makes a bad cell survivable: a cell can raise, leak memory, or
16
- wedge the guest without taking pi down, and the host, being not the thing
17
- that failed, always gets to report what happened.
19
+ The host is TypeScript; the evaluator is Python in its own process. That split is what makes
20
+ a bad cell survivable: a cell can raise, leak memory, or wedge the kernel without taking pi
21
+ down and the host, being not the thing that failed, always gets to report what happened.
22
+
23
+ ## Why the host speaks ZMTP itself
24
+
25
+ The obvious way for a TypeScript host to drive an `ipykernel` is to load a ZMQ client
26
+ library. That does not work here: libzmq's native Node bindings crash `bun`. So an earlier
27
+ design put a Python middleman (`guest.py`) between the host and the kernel, translating a
28
+ private JSON protocol over a file descriptor into the real Jupyter protocol.
29
+
30
+ The current design removes the middleman. Instead of working around the missing library, the
31
+ host implements the small slice of ZMTP 3.0 a Jupyter client actually needs — a DEALER
32
+ socket for the shell and control channels, a SUB socket for iopub (`src/engine/zmtp.ts`).
33
+ The payoff:
34
+
35
+ - **one process boundary** instead of two;
36
+ - **one standard protocol** (Jupyter) instead of a private one on top of it;
37
+ - **no invented framing** to maintain;
38
+ - **HMAC signed and verified by the host itself.** The old design carried a nonce scheme to
39
+ stop a cell from forging its own completion signal; with the host signing every message
40
+ with the kernel's HMAC key, that concern is moot.
18
41
 
19
42
  ## The Python environment (the venv)
20
43
 
21
- The evaluator is a real `ipython` kernel, so it needs a Python environment with
22
- `ipykernel` + `jupyter_client`. You cannot fake that with a script; it is a
23
- hard runtime dependency.
44
+ The evaluator is a real `ipykernel` kernel, so it needs a Python environment with
45
+ `ipykernel` installed. That is a hard runtime dependency — you cannot fake it with a script.
46
+ (`jupyter_client` is *not* needed: the host is the client.)
24
47
 
25
48
  When installed as a pi package, `npm install` runs `postinstall`
26
- (`scripts/setup-venv.mjs`), which creates a stable per-user venv:
49
+ (`scripts/setup-venv.mjs`), which builds a stable per-user venv:
27
50
 
28
51
  ```
29
52
  ~/.pi/agent/pi-repl/venv/bin/python3
30
53
  ```
31
54
 
32
- That path is stable across updates because it sits outside the ephemeral
33
- package dir under `~/.pi/agent/npm`. If `python3` or the network is missing at
34
- install time, postinstall prints a clear notice and the host falls back at
35
- runtime.
55
+ That path is stable across updates because it sits outside the package's own directory,
56
+ which npm replaces on each update. If `python3` or the network is missing at install time,
57
+ `postinstall` prints a clear notice and the host falls back at runtime.
36
58
 
37
- At spawn, `resolvePythonPath` chooses the interpreter in order:
59
+ At spawn, `resolvePythonPath` picks the interpreter in this order:
38
60
 
39
61
  1. the repo's own `.venv` (development)
40
- 2. a cwd-local `.venv` (project)
62
+ 2. a venv in the current directory (per-project)
41
63
  3. `~/.pi/agent/pi-repl/venv` (package install)
42
- 4. `$PYTHON` or `python3`
64
+ 4. `$PYTHON`, then `python3` (the fallback)
65
+
66
+ The first one that exists wins. The tool's prompt tells the model it runs in a project-local
67
+ venv, not the system interpreter, so it does not leak the wrong assumption into commands.
68
+
69
+ ## The kernel client
43
70
 
44
- The first existing one wins. The model is told (via `help()`) that it runs in a
45
- project-local venv, not the system interpreter, so it does not leak the wrong
46
- assumption into commands.
71
+ `KernelClient.start` spawns `python -m ipykernel -f <connection-file>` with a per-run
72
+ connection file in the temp directory, connects the three channels as ZMTP sockets, and
73
+ waits for a `kernel_info_reply` before declaring the kernel ready. Cells run as standard
74
+ Jupyter `execute_request`s, routed by `msg_id`:
47
75
 
48
- ## The guest
76
+ - **iopub** carries the output — `stream`, `execute_result`, `display_data`, and `error`
77
+ messages, plus private-MIME payloads that carry snapshot, restore, and namespace data.
78
+ - **shell** carries the authoritative `execute_reply` (status, ename, evalue).
79
+ - **control** carries interrupts (`interrupt_request`) and shutdown.
49
80
 
50
- `src/engine/guest.py` uses `jupyter_client.KernelManager` to start a real
51
- `ipykernel` subprocess (`python -m ipykernel`), keeps a blocking client
52
- attached, and stays alive for the whole session. Cells run in that kernel via
53
- `kc.execute(code)`, so state persists because the kernel process does.
81
+ Two wire subtleties are load-bearing, and both are pinned by the contract tests.
54
82
 
55
- The wire protocol rides two channels, both load-bearing:
83
+ **A cell is not done until two things arrive.** The shell reply and the iopub output stream
84
+ travel on different connections, so a tiny reply can arrive before a large output has
85
+ finished draining on iopub. A cell settles only when **both** the `execute_reply` and the
86
+ matching iopub `status idle` (published after every byte of output) have arrived. Settling
87
+ on the reply alone would drop output that was still in flight.
56
88
 
57
- *Separation.* Protocol traffic uses a dedicated pipe (fd 3). The guest's real
58
- stdout/stderr carry only user output, so a cell printing JSON cannot be parsed
59
- as a protocol message.
89
+ **Output is capped per channel.** Each channel accumulates output against a character budget
90
+ (`maxOutputChars`). Overflow is flagged per message a single 10 MB print trips the cap
91
+ within that one message, not only once a later message exhausts the budget — and the host
92
+ appends an explicit truncation marker so the model knows output was cut.
60
93
 
61
- *Authentication.* Every frame carries a nonce the host mints at spawn and the
62
- guest erases from its environment before any cell runs. Code inside a cell
63
- cannot recover it. Without this, a cell could announce its own completion and
64
- claim success while failing an agent that cannot trust its own results has
65
- nothing.
94
+ **Cancellation is real.** An abort sends an `interrupt_request` on the control channel,
95
+ which raises a genuine `KeyboardInterrupt` in the running cell; the namespace survives. As a
96
+ backstop for cells wedged in C code (which ignore interrupts), the engine's abort grace then
97
+ kills the kernel after 500 ms, and the next call rebuilds it from the last snapshot.
66
98
 
67
- ## Toolbox loading
99
+ ## Helpers loading
68
100
 
69
- At boot the guest and the host both read the toolbox directory (default
70
- `src/engine/toolbox`, overridden by config `toolboxDir` env `PI_TOOLBOX_DIR`).
101
+ At boot, the kernel and the host both read **one** helpers directory — the fixed
102
+ `~/.pi/agent/pi-repl/helpers`, created empty on install with nothing seeded into it. There is
103
+ no shipped toolbox that merges in.
71
104
 
72
- - **guest** execs each `*.py` into the kernel namespace, making functions
105
+ - **The kernel** execs each `*.py` into its namespace, so the file's functions become
73
106
  callable.
74
- - **host** reads the same files to build the functions list on the `execute`
75
- tool's `promptGuidelines` (and the tool `description`), so the model sees the
76
- real signatures and one-line summaries.
107
+ - **The host** reads the same files to build the helper list shown in the `execute` tool's
108
+ prompt, so the model sees each `helper_description` verbatim.
77
109
 
78
- The loader reads each file's `def (...)`: signature (authoritative) and its
79
- `function_description = """..."""` (one-line summary, optional). Since both
80
- sides read the same directory, a function in the prompt also exists in the
81
- kernel. A file renamed with a `_` prefix is skipped by both, so a disabled
82
- function is never advertised where it does not load. See
83
- `docs/how-to-functions.md`.
110
+ Because both sides read the same directory, anything the model is told about is also
111
+ callable, and a file renamed with a `_` prefix is skipped by both. The
112
+ `promptGuidelines` are built once, when the `execute` tool is registered, so a helpers
113
+ change needs a **session restart or `/reload`** to reach the prompt and the kernel loads
114
+ helpers only at boot anyway.
84
115
 
85
- The `promptGuidelines` are built once, when the `execute` tool is registered
86
- (module load). A toolbox change therefore needs a **session restart / `/reload`**
87
- to be reflected in the prompt — the kernel also loads the toolbox only at boot.
116
+ There are no custom discovery intrinsics (`ls()` / `help()`) injected into a bare kernel.
117
+ The model discovers what is loaded by listing the namespace with ordinary Python:
88
118
 
89
- `ls()` and `help(name)` are built into the kernel (not toolbox files), so a
90
- bare kernel still lets the model discover what is loaded.
119
+ ```python
120
+ [k for k in globals() if not k.startswith('_')]
121
+ ```
122
+
123
+ For the full helper contract — the description, the docstring, disabling — see
124
+ [helpers.md](helpers.md).
91
125
 
92
126
  ## Snapshots & honest resets
93
127
 
94
- After each successful cell the host schedules a debounced snapshot: it asks
95
- the guest to pickle the kernel's globals (entry-by-entry so one bad value
96
- costs only itself), and stores that as `namespace.snapshot` keyed to the
97
- session file under `~/.pi/agent/pi-repl/state/<session>/`. On a fresh engine it
98
- restores, and whatever cannot be pickled
99
- (live handles, some objects) is reported by name.
128
+ After each successful cell, the host schedules a debounced snapshot. A private kernel cell
129
+ pickles the kernel's `globals` (entry by entry, so one value that cannot be pickled costs
130
+ only itself) and publishes the result back over a private MIME payload. The host stores it as
131
+ `namespace.snapshot`, keyed to the session file under
132
+ `~/.pi/agent/pi-repl/state/<session>/`.
100
133
 
101
- If the evaluator restarts, the result is prefixed with a `<rlm_engine_reset>`
102
- block naming what was revived and what was lost, so the model re-verifies
103
- before reuse rather than trusting state that is gone.
134
+ When a fresh engine is built, it restores that snapshot. Whatever could not be pickled —
135
+ live handles, some objects is reported by name. If the evaluator was rebuilt mid-session,
136
+ the result is prefixed with a `<repl_engine_reset>` block that names what was revived and
137
+ what was lost, so the model re-verifies before reusing state that may be gone.
104
138
 
105
139
  ## Failure modes
106
140
 
107
141
  | Failure | Behaviour |
108
142
  | --- | --- |
109
- | Cell throws | `done { status: "error" }` with traceback; kernel namespace intact |
110
- | Kernel wedged | timeout kill kernel subprocess spawn fresh restore snapshot |
111
- | Guest process dies | pending calls settle; engine reports itself down; later calls reject |
112
- | Host exits | guest is killed; on abrupt death it self-exits on stdin EOF |
143
+ | Cell throws | `error` status with traceback; kernel namespace intact |
144
+ | Cell silent or wedged | the watchdog sends an `interrupt_request`; a caller abort then kills the kernel after a 500 ms grace |
145
+ | Kernel dies | the running cell settles with an error; the next call builds a fresh kernel and restores the last snapshot |
146
+ | Host exits | `process.on("exit")` SIGKILLs live kernels (a child does not die with its parent) |
113
147
  | Output flood | capped per channel, truncation announced |
114
148
 
115
149
  ## Testing
116
150
 
117
- - **Host (bun):** `test/units.test.ts` (protocol, render, config) +
118
- `test/preview-core.test.ts`.
119
- - **Evaluator (pytest):** `test/guest_contract.py` drives a real guest and
120
- asserts persistence, error-survival, output attribution, snapshots, ls/help.
121
- - **Integration (slow):** `test/engine.integration.test.ts` boots a real
122
- engine + guest and proves a variable survives an engine restart.
151
+ - **Host (fast):** `test/units.test.ts` covers engine orchestration, rendering, and config;
152
+ `test/preview-core.test.ts` covers the preview logic.
153
+ - **Contract (slow):** `test/engine.integration.test.ts` boots a real kernel per engine and
154
+ proves the guarantees the old Python contract pinned: persistence across cells,
155
+ error-survival, output attribution, helpers loading, snapshot/restore round-trips, output
156
+ caps, silence timeout, abort, and rebuild-from-snapshot after a dead kernel.
157
+
158
+ The gate is `just check` — biome (format + lint) plus the host tests. `just integration`
159
+ adds the real-kernel suite.
123
160
 
124
- Gate: `just check` = biome + bun test (host) + pytest (guest).
125
- `just integration` adds the real-host seam.
161
+ ## The fixed layout
126
162
 
127
- ## Configuration reference
163
+ There is no configuration file and no knobs. Everything lives under one directory in the
164
+ user's home, and the default is the only option:
128
165
 
129
- Loaded from `~/.pi/agent/pi-repl/config.json` (or `$PI_REPL_CONFIG`), first-found-wins, never
130
- throws on a missing/malformed file.
166
+ ```
167
+ ~/.pi/agent/pi-repl/
168
+ venv/ the Python interpreter + ipykernel
169
+ helpers/ the helpers directory — every *.py loads into every kernel
170
+ state/ per-session namespace snapshots
171
+ ```
131
172
 
132
- | Key | Type / default | Meaning |
133
- | --- | --- | --- |
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. |
135
- | `pythonPath` | string, optional | The interpreter used to spawn the guest. Omit to use `resolvePythonPath` (see venv). |
136
- | `timeoutMs` | number, 60000 | Per-cell execution timeout in ms. |
137
- | `snapshotDebounceMs` | number, 1500 | Debounce after an ok cell before snapshot, in ms. |
173
+ The helpers directory is fixed at `~/.pi/agent/pi-repl/helpers` (matching the kernel's
174
+ `readHelperSources` default), so both sides are guaranteed to read the same directory. The
175
+ venv is built automatically and the interpreter resolved by the order aboveno setting
176
+ needed. The per-cell silence watchdog is off by default (`PI_REPL_TIMEOUT_MS=0`: a silent
177
+ but working cell may run on).
138
178
 
139
179
  ## Reference documentation
140
180
 
141
- - Philosophy and design rationale: [docs/philosophy.md](docs/philosophy.md)
142
- - Adding a toolbox function: [docs/how-to-functions.md](docs/how-to-functions.md)
181
+ - Philosophy and design rationale: [philosophy.md](philosophy.md)
182
+ - Adding a helper: [helpers.md](helpers.md)
@@ -0,0 +1,110 @@
1
+ # How to write a helper
2
+
3
+ A **helper** is a `.py` file that gets exec'd into every kernel, so anything it defines —
4
+ functions, classes, constants, imports, or a module that owns a fragile or opaque piece of
5
+ work — becomes part of the agent's workspace; you drop the file into one folder, restart
6
+ the session, and it's available. Like a bookmark for code the agent keeps reaching for.
7
+
8
+ This guide shows the smallest callable helper that works, then explains the parts of a
9
+ helper file and the habits that make a helper useful.
10
+
11
+ ## Prerequisites
12
+
13
+ - A working `pi --repl` session (the extension is installed — see the README).
14
+ - The helpers folder, `~/.pi/agent/pi-repl/helpers/`. It is created empty on install.
15
+
16
+ ## The smallest helper
17
+
18
+ Create this file:
19
+
20
+ ```python
21
+ # ~/.pi/agent/pi-repl/helpers/double.py
22
+ helper_description = """double(x) — multiply a value by two."""
23
+
24
+ def double(x):
25
+ """Return x * 2. Works on ints, floats, and lists."""
26
+ return x * 2
27
+ ```
28
+
29
+ Restart the session (`/reload`, or relaunch `pi --repl`), then check it loaded:
30
+
31
+ ```python
32
+ print([k for k in globals() if not k.startswith('_')])
33
+ # ['double', ...]
34
+
35
+ print(double(21))
36
+ # 42
37
+ ```
38
+
39
+ If `double` appears in the namespace and runs, it's loaded. That is the whole loop: write
40
+ the file, restart, use it.
41
+
42
+ ## How it actually works
43
+
44
+ Two things happen with the file you wrote:
45
+
46
+ 1. **The kernel execs it at boot.** The file's source runs in the kernel before the first
47
+ cell, so `def double` defines a callable `double` in the workspace. Separately, the
48
+ filename sets the label the prompt uses to advertise it — `double.py` is listed as
49
+ `double`. Keep the two identical (see *Common mistakes*).
50
+
51
+ 2. **The model sees the description.** The `execute` tool's prompt lists each helper's
52
+ `helper_description`, rendered **verbatim**. Nothing is parsed: the text you put between
53
+ the triple-quotes is exactly what the model reads.
54
+
55
+ Shell and file IO are not helpers — they are already ordinary Python
56
+ (`subprocess.run`, `!cmd`, `%%bash`, `open`, `pathlib`). A helper is only worth writing for
57
+ the fragile or opaque part the model can't reliably reconstruct on its own: a `web_search`
58
+ with provider failover, a client wrapper, a conversion it keeps getting wrong.
59
+
60
+ ## The three parts of a helper file
61
+
62
+ | Part | Required? | What it does | Who sees it |
63
+ |---|---|---|---|
64
+ | `def name(...)` | yes | the implementation | runs in the kernel |
65
+ | `helper_description` | no | one line the model reads first | the prompt, verbatim |
66
+ | a docstring | no | the full detail | `print(name.__doc__)` on demand |
67
+
68
+ **The function** is the implementation. Its public name must match the filename: `double.py`
69
+ exposes `double`. Keep the `def` name identical so callers and the namespace agree.
70
+
71
+ **The description** is the model's first impression. It is the only part that reaches the
72
+ prompt, and it is billed into context every turn, so it wants to be short. Two habits help
73
+ (not requirements — the loader parses nothing):
74
+
75
+ - Start with the call shape: `double(x) — multiply a value by two.`
76
+ - Add an "Instead of:" line naming the hand-rolled code it replaces, so the model reaches
77
+ for the helper rather than rewriting the raw call. *Good:* `Instead of: subprocess.run
78
+ with a hand-rolled kill-on-timeout.` *Weak:* `Instead of: doing it manually.`
79
+
80
+ **The docstring** holds the depth — argument types and defaults, return value, error
81
+ behaviour, environment facts ("this runs in a project-local venv, not the system Python").
82
+ It never enters the prompt. The model reads it on demand with `print(name.__doc__)` or
83
+ `help(name)` when the description is not enough.
84
+
85
+ ## Common mistakes
86
+
87
+ - **Description buried in detail.** Move anything beyond the call shape and one consequence
88
+ into the docstring. A long description bills into every turn.
89
+ - **The helper decides too much.** A helper should own the murky part (the call, the
90
+ parsing), not the decision. If it picks the command, the routing, or the judgement, the
91
+ model stops reasoning.
92
+ - **No docstring.** Without one, the model sees only a signature and the gotchas vanish.
93
+ - **Top-level side effects.** The file runs at boot in every kernel. Keep module-level code
94
+ to definitions — no prints, no network, no slow imports at module scope.
95
+ - **Filename and `def` name differ.** `helpers/foo.py` exposing `def bar` confuses callers
96
+ and the prompt. Keep them identical.
97
+
98
+ ## Disable a file without deleting it
99
+
100
+ Rename it with a leading underscore (`_scratch.py`). The loader skips any file whose name
101
+ starts with `_`, so it never reaches the kernel or the prompt. Handy for scratch work.
102
+
103
+ ## Checklist
104
+
105
+ - [ ] Filename matches the public `def` name
106
+ - [ ] `helper_description` is short and starts with the call shape
107
+ - [ ] Depth lives in the docstring, not the description
108
+ - [ ] No top-level side effects
109
+ - [ ] Session restarted or `/reload`-ed
110
+ - [ ] Name appears in `globals()` and the function runs