pi-pignon 0.1.1 → 0.1.2
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 +100 -411
- package/docs/CONFIGURATION.md +234 -0
- package/docs/DESIGN.md +49 -0
- package/docs/PLAN-command-output.md +161 -0
- package/docs/PLAN-deciders.md +327 -0
- package/docs/assets/pignon.svg +14 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# <img src="docs/assets/pignon.svg" width="40" height="40" alt="" align="top"> pignon
|
|
2
2
|
|
|
3
|
+
[](https://github.com/earendil-works/pi-coding-agent)
|
|
3
4
|
[](https://github.com/siiick/pi-pignon/actions/workflows/ci.yml)
|
|
4
5
|
[](LICENSE)
|
|
5
6
|
|
|
@@ -7,508 +8,196 @@ Pi agent extension that shifts to the right LLM for each prompt, the way a
|
|
|
7
8
|
bike changes sprocket (*pignon*): a small **decision model** judges how hard
|
|
8
9
|
the prompt is, and pignon looks the answer up in **your routing table**.
|
|
9
10
|
|
|
10
|
-
- Decisions come from a **local Laya System-1 model
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
- Decisions come from a **local Laya System-1 model** (served by `laya-serve`,
|
|
12
|
+
~75 ms on Apple Silicon) or from **TypeSafe's hosted Jev** (~70–500 ms, needs
|
|
13
|
+
an API key)
|
|
13
14
|
- You choose the models and write your own difficulty tiers
|
|
14
15
|
- Strongly typed TypeScript, config checked against a published JSON Schema
|
|
15
16
|
|
|
16
17
|
## How it works
|
|
17
18
|
|
|
18
|
-
On every prompt the decider
|
|
19
|
+
On every prompt the decider answers two questions:
|
|
19
20
|
|
|
20
|
-
1. **How hard is it?** It picks one of your tiers.
|
|
21
|
+
1. **How hard is it?** It picks one of your tiers.
|
|
21
22
|
- `trivial` → mechanical edits, renames, single lookup
|
|
22
23
|
- `standard` → localized change across a few files
|
|
23
24
|
- `hard` → multi-step investigation, debugging, cross-cutting design
|
|
24
25
|
|
|
25
|
-
2. **Does the agent need to explore the codebase first?** `yes` / `no`,
|
|
26
|
-
picks the **direct** (reasoner) or **exploration** (agent) model of
|
|
26
|
+
2. **Does the agent need to explore the codebase first?** `yes` / `no`,
|
|
27
|
+
which picks the **direct** (reasoner) or **exploration** (agent) model of
|
|
28
|
+
the tier.
|
|
27
29
|
|
|
28
|
-
The answers are fed into a **pure policy function** that decides
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
- Whether a switch is **worth losing the prompt cache** (see [Switch cost](#switch-cost))
|
|
30
|
+
The answers are fed into a **pure policy function** that decides whether to
|
|
31
|
+
upgrade, downgrade, or keep the current tier, and whether switching models is
|
|
32
|
+
worth losing the prompt cache.
|
|
32
33
|
|
|
33
34
|
## Installation
|
|
34
35
|
|
|
35
|
-
### 1. Install the extension
|
|
36
|
-
|
|
37
36
|
```bash
|
|
38
37
|
pi install npm:pi-pignon
|
|
39
38
|
```
|
|
40
39
|
|
|
41
|
-
`pi update --extensions`
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
To run unreleased changes, install from GitHub instead:
|
|
46
|
-
`pi install git:github.com/siiick/pi-pignon` (follows `main`).
|
|
40
|
+
`pi update --extensions` keeps it up to date. To pin a version:
|
|
41
|
+
`pi install npm:pi-pignon@0.1.1`. To try unreleased changes:
|
|
42
|
+
`pi install git:github.com/siiick/pi-pignon`.
|
|
47
43
|
|
|
48
|
-
###
|
|
44
|
+
### 1. Give it a decision model
|
|
49
45
|
|
|
50
|
-
pignon needs a local Laya server
|
|
51
|
-
(step 4) finds what is available and writes it into your config.
|
|
46
|
+
pignon needs a local **Laya server**, a **Jev API key**, or both.
|
|
52
47
|
|
|
53
|
-
**Local: Laya
|
|
54
|
-
that speaks the same API as Jev, so pignon talks to it like to Jev, on your
|
|
55
|
-
machine. Install it with [uv](https://docs.astral.sh/uv/) (`brew install uv`)
|
|
56
|
-
or pipx, then start it:
|
|
48
|
+
**Local: Laya with `laya-serve`**
|
|
57
49
|
|
|
58
50
|
```bash
|
|
59
|
-
uv tool install "laya[serve]"
|
|
60
|
-
LAYA_HOST=127.0.0.1 laya-serve
|
|
51
|
+
uv tool install "laya[serve]" # or pipx install "laya[serve]"
|
|
52
|
+
LAYA_HOST=127.0.0.1 laya-serve # http://127.0.0.1:8000
|
|
61
53
|
```
|
|
62
54
|
|
|
63
|
-
- Always set `LAYA_HOST=127.0.0.1
|
|
64
|
-
|
|
65
|
-
-
|
|
66
|
-
-
|
|
67
|
-
while; later starts take 2–3 s. By default it loads every checkpoint;
|
|
68
|
-
`LAYA_MODELS=english` loads only the English one (add `multilingual` if you
|
|
69
|
-
write prompts in other languages).
|
|
70
|
-
- pignon never waits for it: while the server is down or loading, prompts
|
|
71
|
-
are not routed and keep the current model (the failure takes a few
|
|
72
|
-
milliseconds).
|
|
55
|
+
- Always set `LAYA_HOST=127.0.0.1` (default listens on all interfaces)
|
|
56
|
+
- Loads the best available device (NVIDIA GPU → Apple Silicon → CPU)
|
|
57
|
+
- First start downloads checkpoints and may take a while; later starts take 2–3 s
|
|
58
|
+
- While loading or down, prompts are **not routed** — they keep the current model
|
|
73
59
|
|
|
74
|
-
|
|
75
|
-
For another address, port or key, see [Deciders](#deciders).
|
|
60
|
+
**Remote: Jev**
|
|
76
61
|
|
|
77
|
-
|
|
78
|
-
where Pi runs:
|
|
62
|
+
Get a key from [TypeSafe](https://typesafe.ai) and export it:
|
|
79
63
|
|
|
80
64
|
```bash
|
|
81
65
|
export TYPESAFE_API_KEY="sk-..."
|
|
82
66
|
```
|
|
83
67
|
|
|
84
|
-
To
|
|
85
|
-
[Deciders](#deciders). Jev receives the first 4 000 characters of each routed
|
|
86
|
-
prompt; see [Privacy](#privacy).
|
|
68
|
+
To use OpenRouter instead, see the [configuration reference](docs/CONFIGURATION.md#deciders).
|
|
87
69
|
|
|
88
|
-
###
|
|
70
|
+
### 2. Restart Pi
|
|
89
71
|
|
|
90
72
|
```bash
|
|
91
|
-
pi
|
|
92
|
-
# or /reload if already running
|
|
73
|
+
pi # or /reload
|
|
93
74
|
```
|
|
94
75
|
|
|
95
|
-
###
|
|
76
|
+
### 3. Initialize and go live
|
|
96
77
|
|
|
97
|
-
```
|
|
98
|
-
/pignon init # writes ~/.pi/agent/pignon.json
|
|
99
|
-
/
|
|
100
|
-
/pignon
|
|
101
|
-
/pignon live # start routing (pignon starts in shadow mode)
|
|
78
|
+
```bash
|
|
79
|
+
/pignon init # writes ~/.pi/agent/pignon.json
|
|
80
|
+
/pignon doctor # checks config, deciders and models
|
|
81
|
+
/pignon live # start routing (default is shadow mode)
|
|
102
82
|
```
|
|
103
83
|
|
|
104
|
-
`/pignon init anthropic` (or `openai`, `openrouter`) picks a preset
|
|
105
|
-
|
|
84
|
+
`/pignon init anthropic` (or `openai`, `openrouter`) picks a preset explicitly.
|
|
85
|
+
`init` never overwrites an existing file.
|
|
106
86
|
|
|
107
87
|
## Commands
|
|
108
88
|
|
|
109
89
|
| Command | Description |
|
|
110
90
|
|---------|-------------|
|
|
111
91
|
| `/pignon` | Show current mode and config file |
|
|
112
|
-
| `/pignon shadow` | Observe-only
|
|
113
|
-
| `/pignon live` | Apply routing decisions
|
|
92
|
+
| `/pignon shadow` | Observe-only — logs decisions without applying them |
|
|
93
|
+
| `/pignon live` | Apply routing decisions |
|
|
114
94
|
| `/pignon off` | Disable routing |
|
|
115
95
|
| `/pignon unpin` | Re-enable routing after manual model selection |
|
|
116
|
-
| `/pignon log` | Show recent decider output
|
|
96
|
+
| `/pignon log` | Show recent decider output |
|
|
117
97
|
| `/pignon config` | Show the routing table and settings in use |
|
|
118
|
-
| `/pignon config migrate` | Convert a laya-router config
|
|
119
|
-
| `/pignon init [preset]` | Write a starter `pignon.json
|
|
120
|
-
| `/pignon doctor` | Check
|
|
121
|
-
| `/pignon-stats` | Show tier × form × confidence histogram
|
|
122
|
-
| `/pignon-stats compare` | Compare two deciders
|
|
123
|
-
| `/pignon-stats export [path]` |
|
|
124
|
-
|
|
125
|
-
`log`, `config`, `doctor` and the stats reports open in a scrollable overlay:
|
|
126
|
-
↑↓, PgUp/PgDn, Home/End to scroll, Esc (or `q`, Enter) to close. Nothing stays
|
|
127
|
-
above the editor afterwards. The older `clear` subcommands still work and remove
|
|
128
|
-
a widget left by a previous version.
|
|
98
|
+
| `/pignon config migrate` | Convert a laya-router config to pignon format |
|
|
99
|
+
| `/pignon init [preset]` | Write a starter `pignon.json` |
|
|
100
|
+
| `/pignon doctor` | Check config, deciders and models |
|
|
101
|
+
| `/pignon-stats` | Show tier × form × confidence histogram |
|
|
102
|
+
| `/pignon-stats compare` | Compare two deciders side-by-side |
|
|
103
|
+
| `/pignon-stats export [path]` | Export decisions as JSON lines |
|
|
129
104
|
|
|
130
|
-
|
|
131
|
-
|
|
105
|
+
`log`, `config`, `doctor` and the stats reports open in a scrollable overlay
|
|
106
|
+
(↑↓, PgUp/PgDn, Home/End, Esc or `q` to close).
|
|
132
107
|
|
|
133
108
|
## What you see
|
|
134
109
|
|
|
135
|
-
- **While
|
|
110
|
+
- **While deciding**: a spinner above the editor (`pignon is choosing a model…`).
|
|
136
111
|
- **After each routed prompt**: a decision card below your message, e.g.
|
|
137
112
|
|
|
138
|
-
```
|
|
113
|
+
```text
|
|
139
114
|
pignon laya-serve hard/exploration p=0.92 · 75 ms ⚡ switched to openrouter/tencent/hy4-preview · thinking low
|
|
140
115
|
upgrade
|
|
141
116
|
```
|
|
142
117
|
|
|
143
|
-
The
|
|
144
|
-
|
|
145
|
-
|
|
118
|
+
The card shows the decider used, tier/form, confidence, latency, and the
|
|
119
|
+
action taken (`⚡ switched`, `👁 would switch` in shadow mode, or `· kept`).
|
|
120
|
+
Expand tool output (`Ctrl+O`) to see confidence bars, context size, and the
|
|
121
|
+
full decision trace.
|
|
146
122
|
- **Footer status**: the latest verdict at a glance.
|
|
147
123
|
|
|
148
|
-
## Environment variables
|
|
149
|
-
|
|
150
|
-
| Variable | Default | Description |
|
|
151
|
-
|----------|---------|-------------|
|
|
152
|
-
| `PIGNON_CONFIG` | `<Pi config dir>/pignon.json` | Path of the optional [config file](#configuration) |
|
|
153
|
-
| `PI_CODING_AGENT_DIR` | `~/.pi/agent` | Pi's config directory; pignon keeps its config and exports there |
|
|
154
|
-
| `TYPESAFE_API_KEY` | *(unset)* | Jev API key (another variable can be named with `apiKeyEnv`) |
|
|
155
|
-
| `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` | Jev API root, when `baseURL` is not set |
|
|
156
|
-
| `TYPESAFE_DEFAULT_MODEL` | `jev-latest` | Jev model, when `model` is not set |
|
|
157
|
-
| `LAYA_ROUTER_CONFIG` | `~/.pi/agent/laya-router.json` | Config of laya-router, read only when there is no pignon config |
|
|
158
|
-
|
|
159
|
-
laya-serve reads its own `LAYA_*` variables (`LAYA_HOST`, `LAYA_PORT`,
|
|
160
|
-
`LAYA_MODELS`, `LAYA_API_KEY`, …) when it starts; see
|
|
161
|
-
[Laya's documentation](https://pypi.org/project/laya/). The variables of the
|
|
162
|
-
[experimental worker](#experimental-pignons-mlx-worker) are listed in its section.
|
|
163
|
-
|
|
164
124
|
## Configuration
|
|
165
125
|
|
|
166
|
-
Everything is optional: with no file, pignon uses
|
|
167
|
-
Create `~/.pi/agent/pignon.json` (or point `PIGNON_CONFIG` at another file) and
|
|
168
|
-
set only what you want to change. Each section is checked on its own; an
|
|
169
|
-
invalid section is reported when the session starts and falls back to its
|
|
170
|
-
default. `/pignon config` shows what is in use.
|
|
171
|
-
|
|
172
|
-
Add the `$schema` line to get autocompletion and inline errors in your editor.
|
|
173
|
-
|
|
174
|
-
### Deciders
|
|
175
|
-
|
|
176
|
-
`deciders` picks the decision model. `/pignon init` writes it for you. Without
|
|
177
|
-
it, pignon uses the [experimental worker](#experimental-pignons-mlx-worker) when
|
|
178
|
-
installed, else Jev when `TYPESAFE_API_KEY` is set; it does not look for
|
|
179
|
-
laya-serve on its own.
|
|
180
|
-
|
|
181
|
-
```json
|
|
182
|
-
{
|
|
183
|
-
"deciders": [
|
|
184
|
-
{ "type": "laya-serve" }
|
|
185
|
-
]
|
|
186
|
-
}
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
| Type | Key | Default | Meaning |
|
|
190
|
-
|------|-----|---------|---------|
|
|
191
|
-
| `laya-serve` | `url` | `http://127.0.0.1:8000` | Where laya-serve listens. On another machine, prompts leave yours and cards show ☁ |
|
|
192
|
-
| | `model` | *(server's choice)* | Laya checkpoint: `english`, `multilingual` or `typed-decisions`. By default the server picks one from the prompt's language |
|
|
193
|
-
| | `apiKeyEnv` | *(none)* | Environment variable holding the server's key, when you started it with `LAYA_API_KEY`. Your TypeSafe key is never sent to laya-serve |
|
|
194
|
-
| | `timeoutMs` | `1500` | Timeout for one decision |
|
|
195
|
-
| `laya-local` | | | [Experimental worker](#experimental-pignons-mlx-worker), see its section |
|
|
196
|
-
| `jev` | `model` | `jev-latest` | Jev version to pin. Confidences are calibrated per version, so pinning keeps your thresholds valid |
|
|
197
|
-
| | `apiKeyEnv` | `TYPESAFE_API_KEY` | Environment variable holding the key. Keys are never read from the config file |
|
|
198
|
-
| | `baseURL` | TypeSafe | `https://openrouter.ai/api` to go through OpenRouter (with `"apiKeyEnv": "OPENROUTER_API_KEY"`) |
|
|
199
|
-
| | `timeoutMs` | `1500` | Timeout for one decision |
|
|
200
|
-
| | `maxRetries` | `0` | Retries after a failed call; each gets the full timeout |
|
|
201
|
-
|
|
202
|
-
### Using several deciders
|
|
203
|
-
|
|
204
|
-
List more than one and `strategy` says how they work together:
|
|
205
|
-
|
|
206
|
-
```json
|
|
207
|
-
{
|
|
208
|
-
"deciders": [{ "type": "laya-serve" }, { "type": "jev" }],
|
|
209
|
-
"strategy": { "mode": "sequential", "escalateBelow": 0.75 }
|
|
210
|
-
}
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
| Key | Default | Meaning |
|
|
214
|
-
|-----|---------|---------|
|
|
215
|
-
| `mode` | `sequential` | `sequential`: ask the deciders in order; the next one is asked only when the previous one is not ready, fails (e.g. laya-serve not running), or is less confident than `escalateBelow`. Remote deciders are only called when needed. `parallel`: ask all of them at once |
|
|
216
|
-
| `escalateBelow` | `0.75` | Sequential: tier confidence under which the next decider is asked. The most confident answer wins |
|
|
217
|
-
| `pick` | `most-confident` | Parallel: route on the most confident answer, or `first`: on the first decider in the list that answered, the others being only recorded |
|
|
218
|
-
| `budgetMs` | `3000` | Wall-time limit for one decision, all deciders included |
|
|
126
|
+
Everything is optional: with no file, pignon uses a built-in table.
|
|
219
127
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
```json
|
|
224
|
-
{
|
|
225
|
-
"deciders": [{ "type": "laya-serve" }, { "type": "jev" }],
|
|
226
|
-
"strategy": { "mode": "parallel", "pick": "first" }
|
|
227
|
-
}
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
`/pignon-stats compare` shows tier and exploration agreement, a confusion
|
|
231
|
-
matrix, mean confidence, latency, failures and cost per decider.
|
|
232
|
-
`/pignon-stats export` writes every decision (with each decider's answer) as
|
|
233
|
-
JSON lines for your own analysis. In parallel mode, every routed prompt is sent
|
|
234
|
-
to Jev.
|
|
235
|
-
|
|
236
|
-
Laya's confidence is low (0.05–0.27 on typical prompts: the checkpoint's
|
|
237
|
-
temperatures are uncalibrated), so with the default `escalateBelow` of 0.75,
|
|
238
|
-
sequential mode asks Jev on almost every prompt. Lower `escalateBelow`, or set
|
|
239
|
-
`"confidenceSource": "top-probability"` (see [Other settings](#other-settings)).
|
|
240
|
-
|
|
241
|
-
### Start laya-serve at login (macOS)
|
|
242
|
-
|
|
243
|
-
A launchd agent keeps laya-serve running in the background and restarts it if
|
|
244
|
-
it stops. Save this as `~/Library/LaunchAgents/local.laya-serve.plist`,
|
|
245
|
-
replacing `/Users/you/.local/bin/laya-serve` with the output of
|
|
246
|
-
`which laya-serve`:
|
|
247
|
-
|
|
248
|
-
```xml
|
|
249
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
250
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
251
|
-
<plist version="1.0">
|
|
252
|
-
<dict>
|
|
253
|
-
<key>Label</key><string>local.laya-serve</string>
|
|
254
|
-
<key>ProgramArguments</key>
|
|
255
|
-
<array><string>/Users/you/.local/bin/laya-serve</string></array>
|
|
256
|
-
<key>EnvironmentVariables</key>
|
|
257
|
-
<dict>
|
|
258
|
-
<key>LAYA_HOST</key><string>127.0.0.1</string>
|
|
259
|
-
<key>LAYA_MODELS</key><string>english,multilingual</string>
|
|
260
|
-
</dict>
|
|
261
|
-
<key>RunAtLoad</key><true/>
|
|
262
|
-
<key>KeepAlive</key><true/>
|
|
263
|
-
<key>StandardErrorPath</key><string>/tmp/laya-serve.log</string>
|
|
264
|
-
</dict>
|
|
265
|
-
</plist>
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
```bash
|
|
269
|
-
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.laya-serve.plist # start now and at login
|
|
270
|
-
launchctl bootout gui/$(id -u)/local.laya-serve # stop and disable
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
### Experimental: pignon's MLX worker
|
|
274
|
-
|
|
275
|
-
pignon also has its own Laya worker (`worker/` in this repository), built on
|
|
276
|
-
[laya-mlx](https://github.com/mizorewww/laya-mlx). It is a little faster than
|
|
277
|
-
laya-serve on Apple Silicon (~61 ms against ~75 ms per decision, with the same
|
|
278
|
-
answers), smaller to install, and needs no server: pignon starts it with the
|
|
279
|
-
session and stops it afterwards. It is **experimental and not published**, and
|
|
280
|
-
its interface may change; prefer laya-serve.
|
|
281
|
-
|
|
282
|
-
It needs an Apple Silicon Mac and a clone of this repository:
|
|
283
|
-
|
|
284
|
-
```bash
|
|
285
|
-
git clone https://github.com/siiick/pi-pignon
|
|
286
|
-
uv tool install ./pi-pignon/worker # puts pignon-laya on PATH
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
then `"deciders": [{ "type": "laya-local" }]`. pignon starts the worker with
|
|
290
|
-
the first of:
|
|
291
|
-
|
|
292
|
-
1. `command` in the `laya-local` decider, e.g. `["uv", "run", "--project", "/path/to/pi-pignon/worker", "pignon-laya"]`;
|
|
293
|
-
2. `LAYA_PYTHON`, running `laya_worker.py` from `LAYA_WORKER_DIR`;
|
|
294
|
-
3. a source checkout's `worker/.venv` (after `uv sync`), when pignon itself runs from that checkout;
|
|
295
|
-
4. `pignon-laya` on `PATH`.
|
|
296
|
-
|
|
297
|
-
`/pignon doctor` says which one is used. The worker reports its protocol
|
|
298
|
-
version when it starts; pignon refuses a worker it cannot talk to and says
|
|
299
|
-
which side to update. Its settings:
|
|
300
|
-
|
|
301
|
-
| Key or variable | Default | Meaning |
|
|
302
|
-
|-----------------|---------|---------|
|
|
303
|
-
| `timeoutMs` (decider) | `thresholds.layaTimeoutMs` | Timeout for one decision |
|
|
304
|
-
| `command` (decider) | *(found automatically)* | Command that starts the worker |
|
|
305
|
-
| `LAYA_MODEL` | `aac6fef/laya-mlx` | Checkpoint (e.g. `aac6fef/laya-multilingual-mlx`); fixed for the life of the worker |
|
|
306
|
-
| `LAYA_MODEL_REVISION` | pinned commit for the default model, latest for others | Hugging Face revision to load; empty string means latest |
|
|
307
|
-
| `LAYA_PYTHON` | *(unset)* | Run `laya_worker.py` with this interpreter |
|
|
308
|
-
| `LAYA_WORKER_DIR` | `<extension>/worker` | Directory containing `laya_worker.py` |
|
|
309
|
-
| `LAYA_WORKER_SCRIPT` | `<worker dir>/laya_worker.py` | Explicit worker script path |
|
|
310
|
-
| `LAYA_DTYPE` | `float16` | Model dtype (`float16` / `float32`) |
|
|
311
|
-
| `LAYA_DEVICE` | *(auto)* | Device (`gpu` / `cpu` / empty) |
|
|
312
|
-
| `LAYA_BATCH_SIZE` | `16` | Questions per forward pass |
|
|
313
|
-
|
|
314
|
-
### Swap a model
|
|
315
|
-
|
|
316
|
-
Tiers refer to models by name. The built-in names are `fast`, `balanced`,
|
|
317
|
-
`reasoner` and `agent`; redefine one to change every tier that uses it:
|
|
128
|
+
Create `~/.pi/agent/pignon.json` (or set `PIGNON_CONFIG`) and change only what
|
|
129
|
+
you need. Add the `$schema` line for autocompletion:
|
|
318
130
|
|
|
319
131
|
```json
|
|
320
132
|
{
|
|
321
133
|
"$schema": "https://raw.githubusercontent.com/siiick/pi-pignon/main/schema/config.schema.json",
|
|
322
134
|
"version": 2,
|
|
135
|
+
"extends": "openrouter",
|
|
323
136
|
"models": {
|
|
324
137
|
"reasoner": { "provider": "anthropic", "modelId": "claude-opus-5-5", "thinking": "high" }
|
|
325
138
|
}
|
|
326
139
|
}
|
|
327
140
|
```
|
|
328
141
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
142
|
+
- **Deciders** — local `laya-serve`, remote `jev`, experimental `laya-local`,
|
|
143
|
+
or several with a `strategy`. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#deciders).
|
|
144
|
+
- **Models & Presets** — name models once, reference them in tiers.
|
|
145
|
+
Presets: `openrouter` (default), `anthropic`, `openai`.
|
|
146
|
+
- **Tiers** — define 2–8 difficulty levels with custom criteria.
|
|
147
|
+
See [`examples/pignon.json`](examples/pignon.json) and the
|
|
148
|
+
[configuration reference](docs/CONFIGURATION.md).
|
|
333
149
|
|
|
334
|
-
|
|
335
|
-
`extends`, and override any name under `models`:
|
|
150
|
+
Run `/pignon config` to see the resolved table currently in use.
|
|
336
151
|
|
|
337
|
-
|
|
338
|
-
{
|
|
339
|
-
"extends": "anthropic",
|
|
340
|
-
"models": { "fast": { "provider": "anthropic", "modelId": "claude-haiku-4-5-20251001", "thinking": "off" } }
|
|
341
|
-
}
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
| Preset | `fast` | `balanced` | `reasoner` | `agent` |
|
|
345
|
-
|--------|--------|------------|------------|---------|
|
|
346
|
-
| `openrouter` (default) | deepseek-v4-flash-0731 · off | deepseek-v4.1-flash · low | glm-5.3 · high | hy4-preview · low |
|
|
347
|
-
| `anthropic` | claude-haiku-4-5 · off | claude-sonnet-5 · low | claude-opus-5-5 · high | claude-sonnet-5 · medium |
|
|
348
|
-
| `openai` | gpt-6-luna · off | gpt-5.6-terra · low | gpt-6-sol · high | gpt-5.3-codex · medium |
|
|
349
|
-
|
|
350
|
-
Presets are starting points, not recommendations: check prices and quality on
|
|
351
|
-
your own work (`/pignon-stats`, shadow mode).
|
|
352
|
-
|
|
353
|
-
### Write your own tiers
|
|
354
|
-
|
|
355
|
-
`tiers` replaces the built-in list as a whole: 2 to 8 tiers, **easiest first**
|
|
356
|
-
(position is rank, so moving down the list is a downgrade). Each tier has:
|
|
357
|
-
|
|
358
|
-
| Key | Meaning |
|
|
359
|
-
|-----|---------|
|
|
360
|
-
| `id` | Tier name shown on decision cards (lowercase, digits, `-`, `_`) |
|
|
361
|
-
| `criterion` | How to recognize a task of this tier. **This is the text the decision model reads**, so write it as a description of the task |
|
|
362
|
-
| `model` | Model for every task of the tier… |
|
|
363
|
-
| `direct` / `exploration` | …or one model for each form |
|
|
364
|
-
| `explorationAllowed` | `false` sends tasks that need exploration to the next tier up (default `true`) |
|
|
365
|
-
|
|
366
|
-
A model is a name from `models` or an inline `{ provider, modelId, thinking }`.
|
|
367
|
-
See [`examples/pignon.json`](examples/pignon.json) for a four-tier table.
|
|
368
|
-
|
|
369
|
-
When you change the wording of criteria or questions, also change
|
|
370
|
-
`questions.version`: it is stored with each decision, so you can tell which
|
|
371
|
-
wording your thresholds were calibrated on.
|
|
372
|
-
|
|
373
|
-
### Built-in table
|
|
374
|
-
|
|
375
|
-
| Tier | Direct | Exploration |
|
|
376
|
-
|------|--------|-------------|
|
|
377
|
-
| trivial | `fast`: `openrouter/deepseek/deepseek-v4-flash-0731` · off | → standard (`explorationAllowed: false`) |
|
|
378
|
-
| standard | `balanced`: `openrouter/deepseek/deepseek-v4.1-flash` · low | same |
|
|
379
|
-
| hard | `reasoner`: `openrouter/z-ai/glm-5.3` · high | `agent`: `openrouter/tencent/hy4-preview` · low |
|
|
380
|
-
|
|
381
|
-
### Other settings
|
|
382
|
-
|
|
383
|
-
| Key | Default | Meaning |
|
|
384
|
-
|-----|---------|---------|
|
|
385
|
-
| `questions.version` | `q1` | Label stored with each decision |
|
|
386
|
-
| `questions.tierInstructions` | *How much reasoning does solving this request demand…* | The tier question |
|
|
387
|
-
| `questions.explorationInstructions` | *Does answering require exploring the codebase…* | The exploration question |
|
|
388
|
-
| `questions.explorationCriteria` | `{ yes, no }` | What `yes` and `no` mean |
|
|
389
|
-
| `confidenceSource` | `reported` | `top-probability` routes on the chosen answer's probability instead of the model's confidence, for checkpoints whose confidence is uncalibrated |
|
|
390
|
-
|
|
391
|
-
| Threshold | Default | Meaning |
|
|
392
|
-
|-----------|---------|---------|
|
|
393
|
-
| `minConfidenceDowngrade` | `0.85` | Tier confidence needed to downgrade, or to move in from a model outside the table |
|
|
394
|
-
| `minConfidenceUpgrade` | `0.5` | Tier confidence needed to upgrade |
|
|
395
|
-
| `minConfidenceForm` | `0.6` | Confidence needed to call a task `direct` rather than `exploration` |
|
|
396
|
-
| `minPromptsBetweenSwitches` | `2` | Prompts to wait after a switch before the next downgrade or lateral switch |
|
|
397
|
-
| `maxPaybackRequests` | `3` | A downgrade must recoup its cache-miss cost within this many LLM requests |
|
|
398
|
-
| `assumedOutputTokensPerRequest` | `1000` | Output per request assumed when estimating what a downgrade saves |
|
|
399
|
-
| `cacheGuardTokens` | `60000` | Context size above which lateral switches (and downgrades, when prices are unknown) are refused |
|
|
400
|
-
| `layaTimeoutMs` | `2500` | Timeout for one decision of the experimental worker |
|
|
401
|
-
|
|
402
|
-
### Coming from laya-router
|
|
403
|
-
|
|
404
|
-
A `~/.pi/agent/laya-router.json` is still read when there is no `pignon.json`,
|
|
405
|
-
including its old `tiers: { hard: { direct: … } }` format. pignon warns at
|
|
406
|
-
session start; run `/pignon config migrate` to write the equivalent
|
|
407
|
-
`pignon.json` (a `pignon.json` in the old format is backed up to
|
|
408
|
-
`pignon.json.bak` first), then `/reload`.
|
|
409
|
-
|
|
410
|
-
## Project structure
|
|
152
|
+
## Environment variables
|
|
411
153
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
│ ├── compare.ts # /pignon-stats compare and export
|
|
436
|
-
│ ├── onboarding.ts # /pignon init and /pignon doctor
|
|
437
|
-
│ ├── ui.ts # Spinner and decision cards
|
|
438
|
-
│ └── extension.ts # Pi ExtensionAPI wiring
|
|
439
|
-
├── worker/ # Experimental MLX worker, not published
|
|
440
|
-
│ ├── laya_worker.py # Long-lived laya-mlx process (JSON-lines on stdio)
|
|
441
|
-
│ ├── test_laya_worker.py # stdlib unittest tests for the worker
|
|
442
|
-
│ ├── pyproject.toml # Python package pignon-laya (command: pignon-laya)
|
|
443
|
-
│ └── README.md # Worker protocol and manual smoke test
|
|
444
|
-
├── tests/ # Vitest suites, one per module
|
|
445
|
-
│ ├── decider-contract.test.ts # What every decider must do, run against each
|
|
446
|
-
│ └── live/ # Real API calls, only with npm run test:live
|
|
447
|
-
├── schema/config.schema.json # Generated JSON Schema (npm run schema)
|
|
448
|
-
├── examples/pignon.json # A four-tier config, loaded by the tests
|
|
449
|
-
├── docs/PLAN-deciders.md # Roadmap: Jev decider, strategies, publishing
|
|
450
|
-
├── CHANGELOG.md
|
|
451
|
-
├── package.json
|
|
452
|
-
├── tsconfig.json
|
|
453
|
-
└── vitest.config.ts
|
|
454
|
-
```
|
|
154
|
+
| Variable | Default | Description |
|
|
155
|
+
|----------|---------|-------------|
|
|
156
|
+
| `PIGNON_CONFIG` | `<Pi config dir>/pignon.json` | Config file path |
|
|
157
|
+
| `TYPESAFE_API_KEY` | *(unset)* | Jev API key |
|
|
158
|
+
| `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` | Jev API root |
|
|
159
|
+
| `LAYA_HOST`, `LAYA_PORT`, `LAYA_MODELS` | *(varies)* | `laya-serve` startup options |
|
|
160
|
+
|
|
161
|
+
Full list: [docs/CONFIGURATION.md](docs/CONFIGURATION.md#environment-variables).
|
|
162
|
+
|
|
163
|
+
## Privacy & reliability
|
|
164
|
+
|
|
165
|
+
- **Local prompts stay local.** With `laya-serve` on this machine, prompts never
|
|
166
|
+
leave it. Jev (or remote laya-serve) receives the first 4 000 characters;
|
|
167
|
+
cards are marked `☁`.
|
|
168
|
+
- **Fail-open.** If a decider is unreachable or fails, the prompt is not routed
|
|
169
|
+
and keeps the current model (a few milliseconds of delay).
|
|
170
|
+
- **Switch cost.** Changing models discards the prompt cache. Downgrades must
|
|
171
|
+
recoup that cost within a few requests; lateral switches use a flat token
|
|
172
|
+
limit. See [docs/DESIGN.md](docs/DESIGN.md) for the full policy.
|
|
173
|
+
- **Hysteresis.** After switching, the router waits a few prompts before the
|
|
174
|
+
next downgrade or lateral switch. Upgrades are never delayed.
|
|
175
|
+
- **Prompt privacy.** Decision logs store a SHA-256 prefix and prompt length,
|
|
176
|
+
never the text.
|
|
455
177
|
|
|
456
178
|
## Development
|
|
457
179
|
|
|
458
|
-
Work from a clone, and point Pi at it instead of a release:
|
|
459
|
-
|
|
460
180
|
```bash
|
|
461
181
|
git clone https://github.com/siiick/pi-pignon && cd pi-pignon
|
|
462
182
|
npm install
|
|
463
|
-
|
|
464
|
-
pi install ./ # loads the clone in place, no copy
|
|
183
|
+
pi install ./ # load the clone in place
|
|
465
184
|
```
|
|
466
185
|
|
|
467
186
|
```bash
|
|
468
|
-
#
|
|
469
|
-
npm
|
|
470
|
-
|
|
471
|
-
#
|
|
472
|
-
npm run
|
|
473
|
-
|
|
474
|
-
# Run tests
|
|
475
|
-
npm test
|
|
476
|
-
|
|
477
|
-
# Run the Python worker tests
|
|
478
|
-
npm run test:worker
|
|
479
|
-
|
|
480
|
-
# Real calls: Jev (needs TYPESAFE_API_KEY; a fraction of a cent) and the experimental worker
|
|
481
|
-
npm run test:live
|
|
482
|
-
|
|
483
|
-
# Regenerate schema/config.schema.json after changing src/config/schema.ts
|
|
484
|
-
npm run schema
|
|
485
|
-
|
|
486
|
-
# Type check + all tests
|
|
487
|
-
npm run check
|
|
488
|
-
|
|
489
|
-
# Watch mode
|
|
490
|
-
npm run test:watch
|
|
187
|
+
npm run typecheck # Type check
|
|
188
|
+
npm test # Unit tests
|
|
189
|
+
npm run test:worker # Python worker tests
|
|
190
|
+
npm run test:live # Real decider calls (needs API key)
|
|
191
|
+
npm run schema # Regenerate JSON Schema
|
|
192
|
+
npm run check # typecheck + tests + worker tests
|
|
491
193
|
```
|
|
492
194
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
- <a id="privacy"></a>**Privacy**: With `laya-serve` on this machine (`127.0.0.1` or `localhost`) or `laya-local`, prompts never leave it. With `jev`, or a laya-serve on another host, the first 4 000 characters of each routed prompt are sent over the network, and decision cards are marked ☁. The SDK's own logging is capped at `warn` and kept in `/pignon log`, so prompts are never logged, even with `TYPESAFE_LOG_LEVEL=debug`.
|
|
496
|
-
- **Fail-open**: If a decider cannot be reached or a decision fails, the decision is `null` and the extension keeps the current model. A laya-serve that is down refuses the connection at once, so the prompt waits a few milliseconds, not a timeout. The experimental worker loads its model in the background from `session_start`; prompts sent before it is ready are not routed (status shows `model loading — prompt not routed`) rather than held. It stays warm for the session, is reloaded in the background if it crashes, and is stopped on `session_shutdown`. A worker that is not ready within 5 minutes is killed.
|
|
497
|
-
- <a id="switch-cost"></a>**Switch cost**: Switching models throws away the prompt cache: the first request on the new model reads the whole context at the uncached (or cache-write) price. Upgrades are quality-driven and only gated by confidence. A downgrade, or a move in from a model outside the table, must pay that premium back within `maxPaybackRequests` LLM requests out of what it saves per request (cheaper cache reads on the context plus cheaper output). Prices come from Pi's model registry; when either model has no price, the flat `cacheGuardTokens` limit applies instead. Lateral switches (direct ↔ exploration) are about fit rather than price and use the flat limit.
|
|
498
|
-
- **Hysteresis**: After the router switches, it waits `minPromptsBetweenSwitches` prompts before the next downgrade or lateral switch, so it does not flap between models. Upgrades are never delayed.
|
|
499
|
-
- **Manual pin**: If the user explicitly selects a model via `/model` or `Ctrl+P`, the extension steps back (`manualPin`) until `/pignon unpin`. The router's own switches also emit `model_select` (`source: "set"`) and are ignored.
|
|
500
|
-
- **Unrouted models**: If the current model is not in the routing table (matched on provider and model id), the router switches into the table only when tier confidence meets the downgrade threshold and the switch-cost check passes.
|
|
501
|
-
- **Shared models**: Cells mapped to the same provider, model and thinking level count as one; the router never re-selects the model already in use.
|
|
502
|
-
- **Worker isolation**: The worker gets an allowlisted environment (`PATH`, `HOME`, locale, proxies, CA bundles, `LAYA_*`, `HF_*`, `HUGGINGFACE_*`, `MLX_*`), not Pi's full environment with provider API keys. Its stderr is kept in memory (last 200 lines, see `/pignon log`) instead of being written over the TUI.
|
|
503
|
-
- **Pinned model**: The default checkpoint is pinned to the Hugging Face commit the router was calibrated on, so changes pushed to the Hub repo do not silently change routing. Bump `PINNED_REVISION` in `worker/laya_worker.py` deliberately, after re-checking decisions in shadow mode.
|
|
504
|
-
- **Prompt privacy**: Session log entries (`pignon-decision`) record a 16-hex-digit SHA-256 prefix and the length of each prompt, never its text.
|
|
505
|
-
- **Bounded worker load**: Only the first 4 000 characters of a prompt are sent (Laya reads about 320 tokens from the start anyway). Each request carries a deadline; the worker skips requests that expired while queued, so slow requests cannot pile up behind each other.
|
|
506
|
-
- **Shadow mode default**: New installs run in shadow mode so you can calibrate confidence thresholds on your own prompts before going live.
|
|
195
|
+
The experimental MLX worker lives in `worker/`; see
|
|
196
|
+
[`worker/README.md`](worker/README.md).
|
|
507
197
|
|
|
508
198
|
## License
|
|
509
199
|
|
|
510
200
|
[MIT](LICENSE) © 2026 Nicolas Chaintron
|
|
511
201
|
|
|
512
|
-
Using pignon in your own project
|
|
513
|
-
|
|
514
|
-
not required, but it helps me see what to improve.
|
|
202
|
+
Using pignon in your own project? I'd love to hear about it —
|
|
203
|
+
open an issue or discussion and tell me what you made.
|