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 CHANGED
@@ -1,5 +1,6 @@
1
1
  # <img src="docs/assets/pignon.svg" width="40" height="40" alt="" align="top"> pignon
2
2
 
3
+ [![pi-extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
3
4
  [![CI](https://github.com/siiick/pi-pignon/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/siiick/pi-pignon/actions/workflows/ci.yml)
4
5
  [![License: MIT](https://img.shields.io/github/license/siiick/pi-pignon)](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**, served on your machine
11
- by the official `laya-serve` (~75 ms per decision on Apple Silicon), or from
12
- **TypeSafe's hosted Jev model** (~70–500 ms, needs an API key)
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 is asked two questions:
19
+ On every prompt the decider answers two questions:
19
20
 
20
- 1. **How hard is it?** It picks one of your tiers. The defaults are:
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`, which
26
- picks the **direct** (reasoner) or **exploration** (agent) model of the tier.
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
- - Whether to **upgrade**, **downgrade**, or **keep** the current tier
30
- - Whether to switch between the **direct** and **exploration** models
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` then keeps it up to date. To stay on one version,
42
- pin it (`pi install npm:pi-pignon@0.1.1`); installing another version replaces
43
- it. Releases are listed on the [releases page](https://github.com/siiick/pi-pignon/releases).
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
- ### 2. Give it a decision model
44
+ ### 1. Give it a decision model
49
45
 
50
- pignon needs a local Laya server, Jev (an API key), or both. `/pignon init`
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, with `laya-serve`.** Laya's official package ships a server
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]" # or: pipx install "laya[serve]"
60
- LAYA_HOST=127.0.0.1 laya-serve # listens on http://127.0.0.1:8000
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`: by default laya-serve listens on every
64
- network interface, so other machines could use it.
65
- - It uses the best device it finds (NVIDIA GPU, Apple Silicon GPU, then CPU).
66
- - The first start downloads the checkpoints from Hugging Face and takes a
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
- To have it running whenever you use Pi, [start it at login](#start-laya-serve-at-login-macos).
75
- For another address, port or key, see [Deciders](#deciders).
60
+ **Remote: Jev**
76
61
 
77
- **Remote: Jev.** Get a key from [TypeSafe](https://typesafe.ai) and export it
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 go through OpenRouter instead, use an OpenRouter key and see
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
- ### 3. Restart Pi
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
- ### 4. Create a config and check it
76
+ ### 3. Initialize and go live
96
77
 
97
- ```
98
- /pignon init # writes ~/.pi/agent/pignon.json from the preset your Pi can use
99
- /reload
100
- /pignon doctor # checks the config, each decider (one test decision) and each model
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
- explicitly; see [Presets](#presets). `init` never replaces an existing file.
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 mode (default) — logs decisions without applying them |
113
- | `/pignon live` | Apply routing decisions to model selection |
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 (load progress, warnings, tracebacks) |
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 file to the pignon format |
119
- | `/pignon init [preset]` | Write a starter `pignon.json`: the preset's models (by default the one whose models Pi can use) and the deciders that can run here |
120
- | `/pignon doctor` | Check the config, each decider (one real test decision; for Jev that sends a fixed test prompt) and each model of the table (known to Pi, credentials set) |
121
- | `/pignon-stats` | Show tier × form × confidence histogram for the session |
122
- | `/pignon-stats compare` | Compare two deciders over the decisions both answered (see [Using several deciders](#using-several-deciders)) |
123
- | `/pignon-stats export [path]` | Write the session's decisions as JSON lines (default `~/.pi/agent/pignon-exports/`); prompts are stored as hashes, never text |
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
- `/laya` and `/laya-stats` still work as aliases of `/pignon` and `/pignon-stats`;
131
- they will be removed in a later release.
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 the decider works**: a spinner above the editor (`pignon is choosing a model…`). Pi's own working spinner only starts once the LLM turn begins, after routing.
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 name after `pignon` is the decider that answered; `☁` means the prompt left your machine (Jev, or a laya-serve on another host). With several deciders, a third line shows each one's answer and marks the one used, e.g. `laya-serve standard 0.05 · jev ☁ hard 0.93 ✓`.
144
-
145
- `👁 would switch to …` in shadow mode, `· kept current model` when the policy holds, `✗ …` on failure. Expand tool output (`Ctrl+O`) to see confidence bars for tier and exploration, the current model, context size, the decision model and the question wording version. Cards are session entries (`pignon-decision`; `laya-decision` in older sessions), so they reappear when a session is resumed and are never sent to the LLM.
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 the built-in table below.
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
- **Benchmark Laya against Jev** without changing how you route: keep Laya in
221
- charge and record Jev's answers next to it, then compare them.
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
- `thinking` is one of `off`, `low`, `medium`, `high`, `xhigh`; Pi clamps it to
330
- what the model supports. Check model ids with `pi --list-models`.
331
-
332
- ### Presets
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
- A preset fills the four built-in model names from one provider. Use it with
335
- `extends`, and override any name under `models`:
150
+ Run `/pignon config` to see the resolved table currently in use.
336
151
 
337
- ```json
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
- pignon/
414
- ├── src/
415
- │ ├── types.ts # Domain types and constants (zero dependencies)
416
- │ ├── config/
417
- │ │ ├── schema.ts # TypeBox schema of the config file (also the JSON Schema)
418
- │ │ ├── defaults.ts # Built-in models, tiers, wording and thresholds
419
- │ │ ├── presets.ts # Model presets (openrouter, anthropic, openai)
420
- │ │ ├── load.ts # Read, validate and resolve the config file
421
- │ │ ├── migrate.ts # /pignon config migrate (laya-router -> pignon)
422
- │ │ └── describe.ts # /pignon config report
423
- │ ├── deciders/
424
- │ │ ├── types.ts # Decider interface: the seam between router and classifier
425
- │ │ ├── questions.ts # The tier and exploration questions sent to every decider
426
- │ │ ├── parse.ts # Raw answers -> RoutingDecision (shared by all deciders)
427
- │ │ ├── laya-serve.ts # Laya through the official laya-serve (reuses the Jev client)
428
- │ │ ├── laya-local.ts # Experimental: supervises pignon's own stdio worker
429
- │ │ ├── jev.ts # Remote Jev decider, through @typesafe-ai/sdk
430
- │ │ ├── strategy.ts # Several deciders behind one: sequential or parallel
431
- │ │ └── create.ts # Config -> decider, with the automatic choice
432
- │ ├── policy.ts # Pure routing policy (the core logic)
433
- │ ├── router.ts # Per-prompt routing over a Pi-free RouterHost
434
- │ ├── stats.ts # /pignon-stats histogram
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
- (cd worker && uv sync) # only for the experimental worker
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
- # Install dependencies
469
- npm install
470
-
471
- # Type check
472
- npm run typecheck
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
- ## Design notes
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, or built something on top of it? I'd love to
513
- hear about it: open an issue or a discussion and tell me what you made. It is
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.