@hicaru/pi-rlm 0.3.2 → 0.3.4

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
@@ -1,73 +1,58 @@
1
- # pi-rlm — Save 99% tokens, Recursive Language Model (RLM) for the Pi
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/openzebra/rlm.pi/main/assets/plugin-cover.png" width="100%" alt="pi-rlm — Recursive Language Model plugin for Pi">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/@hicaru/pi-rlm"><img src="https://img.shields.io/npm/v/@hicaru/pi-rlm?color=cb3837&logo=npm" alt="npm version"></a>
7
+ <a href="https://github.com/openzebra/rlm.pi/blob/master/pi-plugin/rlm/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
8
+ <a href="https://github.com/earendil-works/pi"><img src="https://img.shields.io/badge/for-Pi-7c3aed" alt="Built for Pi"></a>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://arxiv.org/abs/2512.24601">📄 RLM Paper</a> ·
13
+ <a href="https://github.com/openzebra/rlm.pi">💻 Source</a> ·
14
+ <a href="https://www.npmjs.com/package/@hicaru/pi-rlm">📦 npm</a>
15
+ </p>
2
16
 
3
- <div align="center">
4
-
5
- **Recursive Language Models (RLMs)**, implemented natively as a Pi extension —
6
- FULLY LOCAL.
7
-
8
- </div>
9
-
10
- ## Install
11
-
12
- ```bash
13
- pi install npm:@hicaru/pi-rlm
14
- ```
15
-
16
- To remove it later:
17
+ ---
17
18
 
18
- ```bash
19
- pi uninstall npm:@hicaru/pi-rlm
20
- ```
19
+ **The ONLY Recursive Language Model plugin for Pi.** No new agent to learn, no
20
+ separate CLI, no YAML workflows — just `/rlm` and your existing Pi session becomes a
21
+ recursive orchestration engine that saves **99% tokens** by delegating work to cheap
22
+ worker models.
21
23
 
22
- Then run `/reload` or restart Pi. Verify with `pi list` that the package appears in
23
- `settings.packages`, and check that `/rlm`, `/rlm-config`, and `/rlm-stop` appear under **[Extensions]**.
24
+ > **One install. One toggle. Infinite context.**
24
25
 
25
- <div align="center">
26
+ ## Why pi-rlm?
26
27
 
27
- <a href="https://arxiv.org/abs/2512.24601"><img src="https://github.com/openzebra/rlm.pi/blob/master/assets/hero.png?raw=true" alt="pi-rlm"></a>
28
+ | Advantage | What it means |
29
+ |-----------|---------------|
30
+ | 🔌 **Plugin, not a new agent** | Stays inside Pi. You keep your keybindings, your theme, your tools, your muscle memory. |
31
+ | 📄 **Reads ANY document** | `.pdf` `.docx` `.pptx` `.xlsx` `.epub` `.rtf` `.odt` `.csv` `.html` `.xml` — drop them in, they become Markdown in `context`. |
32
+ | 🪶 **Unix-style — tiny & composable** | Does ONE thing (RLM orchestration). Pair it with any other Pi plugin. No lock-in. |
33
+ | 🧠 **Smartest model orchestrates, cheapest model researches** | Root uses your best model; workers auto-pick the cheapest. Recursive children inherit the full `context` for free. |
34
+ | ⏳ **Long-running with goals** | Toggle `/rlm` on, set a goal, let it loop. Runs survive across chat turns — go make coffee. |
35
+ | 🔒 **100% local, 100% private** | No servers. Your API keys never leave your machine. One `python3` subprocess — that's it. |
28
36
 
29
- <sub>Modeled on the method in the RLM paper, reimplemented natively for Pi.</sub>
30
-
31
- </div>
37
+ ## See it in action
32
38
 
33
39
  <div align="center">
34
40
 
35
- <sub>
36
- **English** &nbsp;·&nbsp; <a href="README.zh-CN.md">中文</a> &nbsp;·&nbsp; <a href="README.ru.md">Русский</a>
37
- </sub>
41
+ <video src="https://github.com/openzebra/rlm.pi/raw/refs/heads/master/animation/rlm_pi_explainer.mp4" controls width="854" poster="https://raw.githubusercontent.com/openzebra/rlm.pi/main/assets/hero.png"></video>
38
42
 
39
43
  </div>
40
44
 
41
- ---
42
-
43
- A **Recursive Language Model (RLM)** is a task-agnostic inference paradigm where a
44
- root language model orchestrates over near-infinite context by *programmatically*
45
- examining, decomposing, and **recursively calling itself** over its input. RLMs
46
- replace the canonical `llm.completion(prompt, model)` call with an
47
- `rlm.completion(prompt, model)` call: the prompt/context is offloaded as a variable
48
- in a REPL environment that the model interacts with, and the model can launch
49
- sub-LLM and sub-RLM calls as ordinary functions in code.
45
+ ## Install (30 seconds)
50
46
 
51
- This is a bet on a [CodeAct](https://arxiv.org/abs/2402.01030)-style harness — every
52
- language model gets access to a code environment, sub-(R)LM calls are functions, and
53
- context/prompts are objects in code — moving away from the JSON tool-calling standard.
54
- A system built this way is *itself* a language model that relies on recursive
55
- sub-LLM calls, hence the name.
56
-
57
- `pi-rlm` brings that paradigm **natively into Pi**:
47
+ ```bash
48
+ pi install npm:@hicaru/pi-rlm
49
+ ```
58
50
 
59
- - A **root orchestrator** model drives a **persistent Python REPL** turn-by-turn.
60
- - Long-context work is **delegated** to cheap worker models via `llm_query` / `llm_query_batched`.
61
- - Hard sub-problems **recurse** into child RLMs via `rlm_query` (depth-capped). A child inherits
62
- its parent's `context` — every file loaded so far, including sources added with `add_context()` —
63
- so it runs the same retrieval primitives over the same paths. Inheritance costs no extra tokens:
64
- the content lives in the sandbox, and only a size line reaches the model.
65
- - Everything runs **in-process** — the only external process is one local `python3` worker.
51
+ Run `/reload` in Pi. Done. `/rlm`, `/rlm-config`, `/rlm-stop` appear under **[Extensions]**.
66
52
 
67
- > This is a Pi-plugin reimplementation of the RLM method (see the [RLM paper](https://arxiv.org/abs/2512.24601)).
68
- > It is **not** the Python library.
53
+ Toggle with `Ctrl+Shift+R` or `/rlm` plain prompts now route through the RLM engine.
69
54
 
70
- ## How it works
55
+ ## What you get
71
56
 
72
57
  ```
73
58
  ┌─────────────────────────┐
@@ -76,8 +61,8 @@ sub-LLM calls, hence the name.
76
61
  │ /rlm
77
62
 
78
63
  ┌─────────────────────────┐ spawns ┌────────────────────┐
79
- │ Smart model (root) │ ────────► │ Worker models │
80
- │ drives a Python REPL │ ◄──────── │ (cheap, fast) │
64
+ │ Smart model (root) │ ────────► │ Worker models │
65
+ │ drives a Python REPL │ ◄──────── │ (cheap, fast) │
81
66
  └────────────┬────────────┘ results └────────────────────┘
82
67
  │ recursion (depth-capped)
83
68
  └────► child RLMs ────► (same loop)
@@ -85,146 +70,109 @@ sub-LLM calls, hence the name.
85
70
  All local · one python3 process · no servers
86
71
  ```
87
72
 
88
- - The **smart model** thinks and writes Python in a REPL.
89
- - The **worker models** do the heavy lifting (read, summarize, classify).
90
- - Hard sub-problems **recurse** into child RLMs.
91
- - Everything runs **fully local** your API keys never leave Pi.
92
-
93
- ## Commands
94
-
95
- | Command | Shortcut | Description |
96
- |---|---|---|
97
- | `/rlm` | `Ctrl+Shift+R` | Toggle persistent RLM mode (route plain prompts through the RLM engine) |
98
- | `/rlm-stop` | | Abort an in-progress run |
99
- | `/rlm-config` | | Pick smart + worker models and tune run settings |
73
+ - **Smart model** thinks and writes Python in a persistent REPL.
74
+ - **Worker models** do the heavy lifting read, summarize, classify, search.
75
+ - **Child RLMs** recurse into hard sub-problems, inheriting the full `context` for free.
76
+ - **Live tree** shows every sub-call with model, cost, tokens, and duration.
100
77
 
101
- While a run is active, a **live tree** shows the root orchestrator and every sub-LLM /
102
- recursive child with status, model, cost, tokens, and duration. The final answer is posted
103
- to the chat as markdown. File changes use Pi's native `edit` / `write` tools (with their
104
- built-in diff preview).
78
+ ## Document format support
105
79
 
106
- ## Sandbox API
80
+ Drop ANY of these into `add_context()` — they auto-convert to Markdown and land in `context`:
107
81
 
108
- These functions are injected into the model's Python namespace inside the REPL:
82
+ | Category | Formats |
83
+ |----------|---------|
84
+ | **Word** | `.docx` |
85
+ | **PDF** | `.pdf` |
86
+ | **PowerPoint** | `.pptx` |
87
+ | **Excel** | `.xlsx` |
88
+ | **EPUB** | `.epub` |
89
+ | **Rich Text** | `.rtf` |
90
+ | **OpenDocument** | `.odt` |
91
+ | **CSV / TSV** | `.csv` `.tsv` |
92
+ | **HTML / XML** | `.html` `.htm` `.xml` `.rss` `.atom` |
93
+ | **+ Pandoc fallback** | `.doc` `.ppt` `.xls` `.pptm` `.xlsm` `.xlsb` `.ppsm` `.docm` `.odp` `.ods` |
109
94
 
110
- | Function | Signature | Description |
111
- |---|---|---|
112
- | `context` | `list[dict]` | Loaded files as `[{"path","content","tokens"}, …]` starts empty; cwd seeds on first `repl()` |
113
- | `llm_query` | `(prompt) -> Task` | Always spawn one sub-LLM; `await_task` → `str` |
114
- | `llm_batch` | `(prompts) -> Task` | Always spawn many sub-LLMs in parallel; `await_task` → `list[str]` |
115
- | `llm_query_chunked` | `(text, prompt) -> Task` | Always spawn; split large text into cap-sized chunks; `await_task` → `list[str]` |
116
- | `map_files` | `(files, prompt) -> Task` | Always spawn; ask `prompt` of many files; `await_task` → `dict[path, answer]` |
117
- | `rlm_query` | `(prompt, paths=None) -> Task` | Always spawn recursive child RLM; `await_task` → report `str` |
118
- | `rlm_batch` | `(prompts, paths=None) -> Task` | Always spawn many child RLMs; `await_task` → `list[str]` |
119
- | `await_task` | `(Task \| list[Task])` | Collect result(s) from always-spawn tools |
120
- | `add_context` | `(source) -> dict \| str` | Append a dir, file, document, or git URL into `context` under `ctx/<id>/` |
121
- | `SHOW_VARS` | `() -> str` | List currently defined variables & their types |
122
- | `answer` | `dict` | Set `answer["content"]=...; answer["ready"]=True` to finalize |
95
+ ```python
96
+ add_context("report.pdf") # → Markdown in context
97
+ add_context("data.xlsx") # Markdown in context
98
+ add_context("../some-lib") # entire directory packed
99
+ add_context("https://github.com/x/y.git") # shallow clone + pack
100
+ ```
123
101
 
124
- Fan-out posts run **detached** (BG / ↯bg): fire several Tasks, do free `search`/`grep`, then `await_task([...])`.
102
+ ## RECURSION the core idea
125
103
 
126
- ### Adding context
104
+ A **Recursive Language Model (RLM)** replaces `llm.completion(prompt)` with
105
+ `rlm.completion(prompt)`. The prompt becomes a variable in a REPL. The model can
106
+ launch sub-LLM and sub-RLM calls as ordinary Python functions — decomposing,
107
+ delegating, and synthesizing across a tree of models, not a single context window.
127
108
 
128
- `context` starts empty. The working directory seeds automatically on the first `repl()` call
129
- (un-prefixed paths so `search()` hits remain real paths for `edit`/`write`). For an **external
130
- tree, document, or git URL**, call `add_context(source)`:
109
+ **This is the only plugin that brings true RLM recursion to Pi.** Prime Agent and
110
+ the reference Python library are separate agents you must switch to. pi-rlm lives
111
+ inside Pi — same session, same tools, same everything.
131
112
 
132
- ```python
133
- info = add_context("../some-lib") # local directory → packed + appended
134
- info = add_context("docs/api.md") # single file → one entry in context
135
- info = add_context("report.pdf") # document → Markdown, then appended
136
- info = add_context("https://github.com/x/y.git") # shallow clone, then pack + append
137
- # Files land in the SAME `context` list under ctx/<source_id>/…
138
- # info == {"source_id", "path_prefix", "files", "chars", "context_len", "already_loaded", "converted", "skipped", …}
139
- lib = [f for f in context if f["path"].startswith(info["path_prefix"])]
140
- ```
113
+ ## Commands
141
114
 
142
- There is no `context_1` / `context_2` only `context`. Paths are namespaced so multiple
143
- sources do not collide. Toggle via `/rlm-config` → **Context loader** (`contextLoader`,
144
- default on) and **Auto-seed cwd** (`autoSeedCwd`, default on). A source loaded at any point is
145
- inherited by every child spawned afterwards.
115
+ | Command | Shortcut | What it does |
116
+ |---------|----------|--------------|
117
+ | `/rlm` | `Ctrl+Shift+R` | Toggle RLM mode on/off |
118
+ | `/rlm-stop` | | Abort current run |
119
+ | `/rlm-config` | | Pick models, tune limits |
146
120
 
147
121
  ## Settings (`/rlm-config`)
148
122
 
149
- | Setting | Default | Meaning |
150
- |---|---|---|
151
- | Smart model | Pi's active model | the root orchestrator |
152
- | Worker model | cheapest available | answers `llm_query` |
153
- | Max recursion depth | `4` | `rlm_query` past this degrades to plain `llm_query` |
154
- | Max iterations | `30` | root REPL turns before RLM asks for a final answer |
155
- | REPL block timeout (s) | `120` | wall-clock limit for one Python REPL block (SIGALRM) |
156
- | Max concurrent sub-calls | `16` | concurrency pool size for `*_batched` |
157
- | Max concurrent children | `6` | concurrent `rlm_query` child engines per depth |
158
- | Wall-clock ceiling (min) | none | total runtime cap for the whole recursive tree |
159
- | Token ceiling | none | total input+output token cap for the whole recursive tree |
160
- | Max consecutive errors | `5` | stop after N consecutive failing turns (none = off) |
161
- | Orchestrator addendum | on | divide-and-conquer guidance in the root system prompt |
162
- | Trajectory compaction | on (0.65) | summarize old turns when history nears the context window |
163
- | Root model output cap (tok) | `16384` | max output tokens per root-model turn |
164
- | Sandbox init timeout | `30000` ms | how long to wait for the Python worker to start |
165
- | Context loader | on | expose `add_context()` for external dirs/files/documents/git repos |
166
- | Auto-seed cwd | on | seed the working directory into `context` on the first `repl()` |
167
-
168
- > **Concurrency note:** each `rlm_query` child spawns its own `python3` worker (~50–150 ms
169
- > cold start). Children are bounded separately (`maxConcurrentChildren`, default 6) because
170
- > each holds a full Python process and its own copy of the inherited context. Error and
171
- > wall-clock caps (above) still bound a runaway tree.
123
+ | Setting | Default | Why you'd change it |
124
+ |---------|---------|---------------------|
125
+ | Smart model | Pi's active | Use your best model as orchestrator |
126
+ | Worker model | cheapest available | Free/cheap model for leaf `llm_query` calls |
127
+ | Max recursion depth | `4` | Deeper trees for harder problems |
128
+ | Max iterations | `30` | Longer runs for complex tasks |
129
+ | REPL timeout | `120`s | Bump for slow computations |
130
+ | Max concurrent subs | `16` | More parallelism (costs RAM) |
172
131
 
173
132
  ## Prompt Architecture
174
133
 
175
- The system prompt is structured around a **contract / routing / examples / rules** pattern
176
- (api_v5, modeled on the best-performing arm from the RLM paper bake-off):
134
+ The system prompt follows a **contract / routing / examples / rules** pattern
135
+ (api_v5), modeled on the best-performing arm from the RLM paper bake-off:
177
136
 
178
- | Section | Purpose |
179
- |---|---|
180
- | `<contract>` | Hard invariant: every heavy call returns a `Task`, never the answer. Only `await_task` returns content. |
181
- | `<routing>` | Decision tree: which tool for which job. Includes negative guidance ("NOT for") so the model knows when NOT to pick a tool. |
182
- | `<examples>` | Concrete E1–E7 patterns: good decompositions (rlm_batch for parallel studies, map_files for one-shot extracts) alongside anti-patterns with WHY each fails. |
183
- | `<rules>` | Standing orders: locate-then-delegate, memoize into `answers`, cap concurrent workers, author edits yourself. |
137
+ - `<contract>` every heavy call returns a `Task`, only `await_task` returns content
138
+ - `<routing>` — decision tree: which tool for which job
139
+ - `<examples>` concrete E1–E7 patterns with anti-patterns
140
+ - `<rules>` locate-then-delegate, memoize, cap workers, author edits yourself
184
141
 
185
- Key design decisions (v0.3.2):
142
+ **Key insight:** children see `Recursion depth: N` and calibrate ambition —
143
+ delegating only when their task genuinely decomposes further.
186
144
 
187
- - **Thinking rule:** the prompt tells the model *when* to plan out loud (complex decomposition,
188
- uncertain targets) vs. when to jump straight to `repl()` (known paths, cheap lookups).
189
- - **Depth visibility:** child RLMs see `Recursion depth: N` in their system prompt and
190
- calibrate ambition — they delegate only when their assigned task itself decomposes.
191
- - **Children are sandboxed:** the prompt explicitly states children cannot mutate the parent's
192
- `answers`, `plan`, or REPL variables. Inheritance is one-way (read-only context).
193
- - **Root tasks wrapped in `<task>` XML tags** so the model cleanly separates user intent from
194
- system instructions.
195
- - **`answer["ready"]` nudge:** runs that never finalize are wasted — the prompt reinforces
196
- the contract with an explicit "You MUST flip" directive.
145
+ ## Benchmarks
197
146
 
198
- ## Subagents and environment
147
+ Tested against `rlm-lab` prompt bake-off and full dual-mode RLM runtime benchmarks
148
+ on `poolside/laguna-xs-2.1:free` (a free ~32B model):
199
149
 
200
- RLM never confiscates native file tools (`read` / `grep` / bash readers) unless `repl` is in
201
- the **active** tool set — the paper's trade is all-or-nothing. Process-boundary subagents that
202
- spawn pi with a `--tools` allowlist without `repl` therefore keep ordinary file access.
150
+ | Benchmark | Mode | Result |
151
+ |-----------|------|--------|
152
+ | Main orchestrator (7 scenarios) | prompt bake-off | **0.958** mean score (v3 fewshot arm) |
153
+ | RLM worker (4 scenarios) | prompt bake-off | **0.94** mean score (v2 contract arm) |
154
+ | Needle-in-haystack (3 needles) | classic RLM | **recall 1.0** |
155
+ | CodeQA timeout | classic RLM | **correct** (~3.7k tokens) |
156
+ | Coding (retry fix) | orchestrator | **correct** (file edited) |
157
+ | Live smoke needle | classic RLM | **hit** (~5k tokens) |
203
158
 
204
- Optional env conventions (for packages that want an explicit full bypass):
159
+ > On a *free* model. Frontier models do even better. See `rlm_test/RESULTS_AGENT.md`
160
+ > and `rlm_test/RESULTS.md` for full methodology.
205
161
 
206
- | Env | Meaning |
207
- |---|---|
208
- | `PI_SUBAGENT_CHILD=1` | Full RLM bypass in this process (no tools / hooks / flags). |
209
- | `PI_RLM_FORCE_IN_SUBAGENT=1` | Experimental: opt a child back into RLM. **Consumed on activate** (not inherited after). Refused when `PI_RLM_DEPTH >= maxDepth`. |
210
- | `PI_RLM_DEPTH` | Cross-process depth counter (default `0`). Bumped when force-in activates. |
162
+ ## Security
211
163
 
212
- In-process recursion (`rlm_query`) still uses `maxDepth` from `/rlm-config` and is unrelated to
213
- these env vars. Set `RLM_TRACE_FILE` to a path for JSONL traces of bypass / force / block-skip
214
- decisions.
164
+ - **Key isolation** provider keys live in TypeScript only; sandbox receives prompts, returns text.
165
+ - **Environment sanitization** sensitive env vars stripped before worker spawns.
166
+ - **Restricted builtins** — no `eval`/`exec`/`compile`/`input` in the sandbox.
167
+ - **Per-block timeout** — SIGALRM + parent watchdog (SIGKILL on hang).
168
+ - **Trust** — project-local install requires Pi project trust.
215
169
 
216
- ## Security
170
+ ## Uninstall
171
+
172
+ ```bash
173
+ pi uninstall npm:@hicaru/pi-rlm
174
+ ```
175
+
176
+ ## License
217
177
 
218
- - **Key isolation**: provider keys live only in TypeScript (`AuthStorage`); the sandbox
219
- receives prompts and returns text — never keys.
220
- - **Environment sanitization**: sensitive env vars (API keys, tokens) are stripped before the
221
- worker spawns. The worker cannot read provider credentials from `os.environ`.
222
- - **NOT a security sandbox**: the Python worker exposes `__import__` and `open`. Model-authored
223
- code can import networking modules, read/write local files, and write protocol-shaped JSON to
224
- stdout. This tier trusts the root model's code; the stdio protocol isolates provider keys and
225
- process lifecycle, **not** adversarial code containment. A stronger sandbox (Docker, seccomp)
226
- can be added later behind a setting without protocol changes.
227
- - **Restricted builtins**: no `eval`/`exec`/`compile`/`input`/`globals`/`locals`; per-block
228
- SIGALRM timeout + parent watchdog (SIGKILL on hang); budget / token / timeout /
229
- consecutive-error caps.
230
- - **Trust**: project-local install requires Pi project trust.
178
+ MIT see [LICENSE](./LICENSE).
package/package.json CHANGED
@@ -1,56 +1,58 @@
1
1
  {
2
- "name": "@hicaru/pi-rlm",
3
- "version": "0.3.2",
4
- "author": "hicaru",
5
- "repository": {
6
- "type": "git",
7
- "url": "git+https://github.com/openzebra/rlm.pi.git"
8
- },
9
- "devDependencies": {
10
- "typescript": "^5.0.0"
11
- },
12
- "peerDependencies": {
13
- "@earendil-works/pi-ai": "*",
14
- "@earendil-works/pi-coding-agent": "*",
15
- "@earendil-works/pi-tui": "*",
16
- "typebox": "*"
17
- },
18
- "bugs": {
19
- "url": "https://github.com/openzebra/rlm.pi/issues"
20
- },
21
- "description": "Save 99% tokens, Recursive Language Model (RLM) for the Pi",
22
- "files": [
23
- "src/",
24
- "README.md",
25
- "LICENSE"
26
- ],
27
- "homepage": "https://github.com/openzebra/rlm.pi",
28
- "keywords": [
29
- "pi-package",
30
- "pi-extension",
31
- "rlm",
32
- "recursive",
33
- "ai-agent"
34
- ],
35
- "license": "MIT",
36
- "pi": {
37
- "extensions": [
38
- "./src/index.ts"
39
- ]
40
- },
41
- "publishConfig": {
42
- "access": "public"
43
- },
44
- "scripts": {
45
- "check": "tsc --noEmit",
46
- "test": "bun run test/smoke.ts",
47
- "prepublishOnly": "npm run check"
48
- },
49
- "type": "module",
50
- "engines": {
51
- "node": ">=20"
52
- },
53
- "dependencies": {
54
- "@firecrawl/anydoc": "^0.1.7"
55
- }
2
+ "name": "@hicaru/pi-rlm",
3
+ "version": "0.3.4",
4
+ "author": "hicaru",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/openzebra/rlm.pi.git"
8
+ },
9
+ "devDependencies": {
10
+ "typescript": "^5.0.0"
11
+ },
12
+ "peerDependencies": {
13
+ "@earendil-works/pi-ai": "^0.84.1",
14
+ "@earendil-works/pi-coding-agent": "^0.84.1",
15
+ "@earendil-works/pi-tui": "^0.84.1",
16
+ "typebox": "*"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/openzebra/rlm.pi/issues"
20
+ },
21
+ "description": "Save 99% tokens, Recursive Language Model (RLM) for the Pi",
22
+ "files": [
23
+ "src/",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "homepage": "https://github.com/openzebra/rlm.pi",
28
+ "keywords": [
29
+ "pi-package",
30
+ "pi-extension",
31
+ "rlm",
32
+ "recursive",
33
+ "ai-agent"
34
+ ],
35
+ "license": "MIT",
36
+ "pi": {
37
+ "extensions": [
38
+ "./src/index.ts"
39
+ ],
40
+ "image": "https://raw.githubusercontent.com/openzebra/rlm.pi/main/assets/plugin-cover.png"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "scripts": {
46
+ "check": "tsc --noEmit",
47
+ "test": "bun run test/smoke.ts",
48
+ "prepublishOnly": "npm run check"
49
+ },
50
+ "type": "module",
51
+ "engines": {
52
+ "node": ">=20"
53
+ },
54
+ "dependencies": {
55
+ "@earendil-works/pi-agent-core": "^0.84.1",
56
+ "@firecrawl/anydoc": "^0.1.7"
57
+ }
56
58
  }
@@ -6,7 +6,7 @@
6
6
  * Used both for `llm_query` (one user prompt) and for the headless RLM root (full history).
7
7
  */
8
8
 
9
- import { type Api, completeSimple, type Message, type Model, type ThinkingLevel, type Usage } from "@earendil-works/pi-ai";
9
+ import { type Api, completeSimple, type Message, type Model, type ThinkingLevel, type Usage } from "@earendil-works/pi-ai/compat";
10
10
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
11
11
 
12
12
  export type Role = "system" | "user" | "assistant";
@@ -33,10 +33,12 @@ export function applyLlmSelection(
33
33
  if (llm === null) {
34
34
  controller.llmModel = undefined;
35
35
  controller.savedLlmRef = undefined;
36
+ controller.explicitClearPin = true;
36
37
  return;
37
38
  }
38
39
  controller.llmModel = llm.model;
39
40
  controller.savedLlmRef = modelRef(llm.model);
41
+ controller.explicitClearPin = false;
40
42
  controller.setConfig(Object.freeze({
41
43
  ...controller.config,
42
44
  subSampling: Object.freeze({
@@ -68,6 +70,13 @@ export async function runRlmConfig(controller: RlmController, ctx: ExtensionCont
68
70
  // Choosing cheapest must NOT wipe subSampling.reasoning (null !== undefined used to).
69
71
  applyLlmSelection(controller, llm);
70
72
 
73
+ // Persist model choice immediately — if showConfigPanel throws or process exits before it
74
+ // returns, the pin survives (Root Cause #2, v0.3.2).
75
+ if (llm !== undefined) {
76
+ const saved = await controller.persist();
77
+ if (!saved) ctx.ui.notify("RLM: failed to save llm setting", "error");
78
+ }
79
+
71
80
  controller.setConfig(await showConfigPanel(ctx, controller.config));
72
81
 
73
82
  const persisted = await controller.persist();
@@ -9,8 +9,9 @@ import { DEFAULT_CONFIG } from "./defaults.ts";
9
9
 
10
10
  export interface PersistedSettings {
11
11
  readonly config: Partial<RlmConfig>;
12
- /** "provider/id" of the pinned sub-LLM, or undefined for "cheapest (auto)". */
13
- readonly llm?: string;
12
+ /** "provider/id" of the pinned sub-LLM, or undefined for "cheapest (auto)".
13
+ * `null` = explicit "cheapest" clear (omit key on disk). */
14
+ readonly llm?: string | null;
14
15
  }
15
16
 
16
17
  type MutablePartialRlmConfig = { -readonly [K in keyof RlmConfig]?: RlmConfig[K] };
@@ -33,11 +34,11 @@ function validateString(v: unknown): string | undefined {
33
34
 
34
35
  /**
35
36
  * Every value pi-ai accepts for `reasoning`. Keyed by the union so a new level added upstream
36
- * is a compile error here rather than a silently-rejected setting. Note `off` and `max` are
37
- * NOT ThinkingLevels — a hand-edited rlm.json carrying one is dropped, not forwarded.
37
+ * is a compile error here rather than a silently-rejected setting. Note `off` is NOT a
38
+ * ThinkingLevel — a hand-edited rlm.json carrying one is dropped, not forwarded.
38
39
  */
39
40
  const THINKING_LEVELS: Readonly<Record<ThinkingLevel, true>> = Object.freeze({
40
- minimal: true, low: true, medium: true, high: true, xhigh: true,
41
+ minimal: true, low: true, medium: true, high: true, xhigh: true, max: true,
41
42
  });
42
43
 
43
44
  function validateThinkingLevel(v: unknown): ThinkingLevel | undefined {
@@ -133,7 +134,16 @@ export async function saveSettings(s: PersistedSettings): Promise<boolean> {
133
134
  try {
134
135
  const p = settingsPath();
135
136
  await mkdir(dirname(p), { recursive: true });
136
- await writeFile(p, `${JSON.stringify(s, null, 2)}\n`);
137
+ const body: Record<string, unknown> = { config: s.config };
138
+ if (s.llm !== undefined) {
139
+ // Explicit: string → write pin, null → omit key (cheapest).
140
+ if (s.llm !== null) body.llm = s.llm;
141
+ } else {
142
+ // Merge: preserve existing disk pin so config-only saves never strip it.
143
+ const existing = await loadSettings();
144
+ if (existing.llm) body.llm = existing.llm;
145
+ }
146
+ await writeFile(p, `${JSON.stringify(body, null, 2)}\n`);
137
147
  return true;
138
148
  } catch {
139
149
  return false;
package/src/index.ts CHANGED
@@ -124,16 +124,6 @@ export default function rlmExtension(pi: ExtensionAPI): void {
124
124
  await seedPromise;
125
125
  };
126
126
 
127
- // Load persisted settings async — applied before session_start handler reads controller state
128
- const settingsReady = loadSettings()
129
- .then((persisted) => {
130
- controller.config = mergeConfig(persisted.config);
131
- controller.savedLlmRef = persisted.llm;
132
- })
133
- .catch((err) => {
134
- console.warn(`[rlm] settings load failed: ${errorMessage(err)}`);
135
- });
136
-
137
127
  // ── Message renderers ──
138
128
  // Markdown themes are derived from the injected `theme`, never pi's module-global
139
129
  // `getMarkdownTheme()` — under jiti that global can be undefined inside a plugin.
@@ -162,8 +152,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
162
152
  let guidePosted = false;
163
153
 
164
154
  pi.on("session_start", async (_event, ctx) => {
165
- // Wait for persisted settings before reading controller state
166
- await settingsReady;
155
+ // Re-read settings fresh from disk each session so a pin or config change
156
+ // made during a previous session takes effect.
157
+ const persisted = await loadSettings();
158
+ controller.config = mergeConfig(persisted.config);
159
+ controller.savedLlmRef = persisted.llm ?? undefined;
167
160
 
168
161
  // An explicit --rlm flag wins over the persisted setting for this session.
169
162
  const flag = pi.getFlag("rlm");
@@ -30,6 +30,8 @@ export interface StartInput {
30
30
  export class RlmController {
31
31
  llmModel: Model<Api> | undefined;
32
32
  savedLlmRef: string | undefined;
33
+ /** Set by applyLlmSelection when the user explicitly picks "cheapest (auto)". */
34
+ explicitClearPin = false;
33
35
  private active: AbortController | null = null;
34
36
 
35
37
  constructor(public config: RlmConfig) {}
@@ -58,7 +60,10 @@ export class RlmController {
58
60
  async persist(): Promise<boolean> {
59
61
  return await saveSettings({
60
62
  config: this.config,
61
- llm: modelRef(this.llmModel) ?? this.savedLlmRef,
63
+ // null explicit clear; undefined → merge from disk; string → set pin
64
+ llm: this.explicitClearPin
65
+ ? null
66
+ : (modelRef(this.llmModel) ?? this.savedLlmRef),
62
67
  });
63
68
  }
64
69
 
@@ -106,10 +106,23 @@ export class SandboxManager {
106
106
  awaitTimeoutS: this.config.awaitTimeoutS,
107
107
  handlers,
108
108
  }).then(async (s) => {
109
+ // ②A: a dispose racing an in-flight spawn must not leak the worker — kill it the
110
+ // instant it resolves. dispose() awaits initPromise so this always runs before it returns.
111
+ if (this.disposed) {
112
+ await s.dispose().catch(() => {});
113
+ throw new Error("SandboxManager disposed during spawn");
114
+ }
109
115
  // Load context on first creation if available (empty list is a valid starting value).
110
- if (this.contextPayload !== undefined) {
111
- await s.loadContext(this.contextPayload);
112
- this.contextLoaded = true;
116
+ try {
117
+ if (this.contextPayload !== undefined) {
118
+ await s.loadContext(this.contextPayload);
119
+ this.contextLoaded = true;
120
+ }
121
+ } catch (err) {
122
+ // ②B: never leak a spawned worker whose context load failed.
123
+ await s.dispose().catch(() => {});
124
+ this.initPromise = null;
125
+ throw err;
113
126
  }
114
127
  this.sandbox = s;
115
128
  this.initPromise = null;
@@ -196,6 +209,11 @@ export class SandboxManager {
196
209
  async dispose(): Promise<void> {
197
210
  if (this.disposed) return;
198
211
  this.disposed = true;
212
+ // ②A: a spawn in flight finishes after this.disposed was set — await it so the guard
213
+ // inside getOrCreate disposes its worker before dispose() returns (no leaked process).
214
+ if (this.initPromise) {
215
+ try { await this.initPromise; } catch { /* spawn failed or was disposed */ }
216
+ }
199
217
  await this.sandbox?.dispose();
200
218
  if (this.sandbox !== null) {
201
219
  this.sandbox = null;
@@ -12,7 +12,7 @@ export interface ModelSelection {
12
12
  readonly thinkingLevel?: ThinkingLevel;
13
13
  }
14
14
 
15
- const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
15
+ const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
16
16
  type SelectableThinkingLevel = (typeof LEVELS)[number];
17
17
 
18
18
  /** Sentinel SelectList value for "always use cheapest available". */
@@ -68,7 +68,11 @@ export function initialModelPickerIndex(
68
68
  const ref = current ? `${current.provider}/${current.id}` : currentRef;
69
69
  if (!ref) return 0;
70
70
  const idx = models.findIndex((m) => `${m.provider}/${m.id}` === ref);
71
- if (idx < 0) return 0;
71
+ // When a saved ref exists but the model is absent from the current catalog
72
+ // (e.g. provider not refreshed yet), pre-select the first real model — NOT
73
+ // "cheapest auto". Accidentally hitting Enter on cheapest would wipe the pin
74
+ // silently (Root Cause #4, v0.3.2).
75
+ if (idx < 0) return ref ? offset : 0;
72
76
  return idx + offset;
73
77
  }
74
78