@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 +122 -174
- package/package.json +56 -54
- package/src/bridge/model.ts +1 -1
- package/src/commands/rlm-config.ts +9 -0
- package/src/config/settings.ts +16 -6
- package/src/index.ts +5 -12
- package/src/mode/rlm-mode.ts +6 -1
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
- package/src/sandbox/sandbox-manager.ts +21 -3
- package/src/ui/model-picker.ts +6 -2
package/README.md
CHANGED
|
@@ -1,73 +1,58 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
+
## Why pi-rlm?
|
|
26
27
|
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
</div>
|
|
37
|
+
## See it in action
|
|
32
38
|
|
|
33
39
|
<div align="center">
|
|
34
40
|
|
|
35
|
-
<
|
|
36
|
-
**English** · <a href="README.zh-CN.md">中文</a> · <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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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) │ ────────►
|
|
80
|
-
│ drives a Python REPL │ ◄────────
|
|
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
|
-
-
|
|
89
|
-
-
|
|
90
|
-
-
|
|
91
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
+
Drop ANY of these into `add_context()` — they auto-convert to Markdown and land in `context`:
|
|
107
81
|
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
102
|
+
## RECURSION — the core idea
|
|
125
103
|
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 |
|
|
150
|
-
|
|
151
|
-
| Smart model | Pi's active
|
|
152
|
-
| Worker model | cheapest available |
|
|
153
|
-
| Max recursion depth | `4` |
|
|
154
|
-
| Max iterations | `30` |
|
|
155
|
-
| REPL
|
|
156
|
-
| Max concurrent
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
142
|
+
**Key insight:** children see `Recursion depth: N` and calibrate ambition —
|
|
143
|
+
delegating only when their task genuinely decomposes further.
|
|
186
144
|
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
##
|
|
170
|
+
## Uninstall
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
pi uninstall npm:@hicaru/pi-rlm
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## License
|
|
217
177
|
|
|
218
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
}
|
package/src/bridge/model.ts
CHANGED
|
@@ -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();
|
package/src/config/settings.ts
CHANGED
|
@@ -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
|
-
|
|
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`
|
|
37
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
166
|
-
|
|
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");
|
package/src/mode/rlm-mode.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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;
|
package/src/ui/model-picker.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|