pi-pignon 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1 — 2026-09-23
4
+
5
+ - Published on npm: `pi install npm:pi-pignon`.
6
+
7
+ - `/pignon log`, `config`, `doctor` and `/pignon-stats` (and `compare`) open in
8
+ a scrollable overlay that closes with Esc. They used to render in a widget,
9
+ which Pi cuts at 10 lines (doctor lost its model checks) and which stayed
10
+ above the editor until `… clear`. `doctor` opens at once and fills in when
11
+ its checks finish. The `clear` subcommands are still accepted but no longer
12
+ offered.
13
+ - `/pignon log` shows the last 200 decider lines instead of 30.
14
+
15
+ ## 0.1.0 — 2026-09-23
16
+
17
+ First release as **pignon** (formerly laya-llm-router, a local-only prototype).
18
+
19
+ - **Deciders**: Laya on your machine through the official `laya-serve`
20
+ (`laya-serve`, any platform laya supports) and TypeSafe's hosted Jev (`jev`,
21
+ through `@typesafe-ai/sdk`), behind one interface. `/pignon init` detects a
22
+ running laya-serve. An experimental MLX worker (`laya-local`, Apple Silicon)
23
+ can be installed from the repository.
24
+ - **Strategies**: `sequential` (ask the next decider when one is not ready,
25
+ fails, or is not confident enough) and `parallel` (ask all, route on the most
26
+ confident or on the first; the others are recorded for comparison).
27
+ - **Your routing table**: named models, 2 to 8 difficulty tiers with your own
28
+ criteria, `explorationAllowed` per tier, question wording with a version label.
29
+ - **Presets** (`openrouter`, `anthropic`, `openai`) via `extends`.
30
+ - **Config** checked against a TypeBox schema, published as
31
+ `schema/config.schema.json` for editor autocompletion.
32
+ - **Commands**: `/pignon` (modes, `log`, `config`, `config migrate`, `init`,
33
+ `doctor`) and `/pignon-stats` (`compare`, `export`). `/laya` and `/laya-stats`
34
+ remain as aliases for this release.
35
+ - The experimental worker is not published; its protocol version is checked
36
+ at startup.
37
+ - `confidenceSource: "top-probability"` for checkpoints with uncalibrated
38
+ confidence.
39
+ - laya-router config files and session entries are still read;
40
+ `/pignon config migrate` converts the config.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicolas Chaintron
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,514 @@
1
+ # <img src="docs/assets/pignon.svg" width="40" height="40" alt="" align="top"> pignon
2
+
3
+ [![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
+ [![License: MIT](https://img.shields.io/github/license/siiick/pi-pignon)](LICENSE)
5
+
6
+ Pi agent extension that shifts to the right LLM for each prompt, the way a
7
+ bike changes sprocket (*pignon*): a small **decision model** judges how hard
8
+ the prompt is, and pignon looks the answer up in **your routing table**.
9
+
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)
13
+ - You choose the models and write your own difficulty tiers
14
+ - Strongly typed TypeScript, config checked against a published JSON Schema
15
+
16
+ ## How it works
17
+
18
+ On every prompt the decider is asked two questions:
19
+
20
+ 1. **How hard is it?** It picks one of your tiers. The defaults are:
21
+ - `trivial` → mechanical edits, renames, single lookup
22
+ - `standard` → localized change across a few files
23
+ - `hard` → multi-step investigation, debugging, cross-cutting design
24
+
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.
27
+
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))
32
+
33
+ ## Installation
34
+
35
+ ### 1. Install the extension
36
+
37
+ ```bash
38
+ pi install npm:pi-pignon
39
+ ```
40
+
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`).
47
+
48
+ ### 2. Give it a decision model
49
+
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.
52
+
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:
57
+
58
+ ```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
61
+ ```
62
+
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).
73
+
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).
76
+
77
+ **Remote: Jev.** Get a key from [TypeSafe](https://typesafe.ai) and export it
78
+ where Pi runs:
79
+
80
+ ```bash
81
+ export TYPESAFE_API_KEY="sk-..."
82
+ ```
83
+
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).
87
+
88
+ ### 3. Restart Pi
89
+
90
+ ```bash
91
+ pi
92
+ # or /reload if already running
93
+ ```
94
+
95
+ ### 4. Create a config and check it
96
+
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)
102
+ ```
103
+
104
+ `/pignon init anthropic` (or `openai`, `openrouter`) picks a preset
105
+ explicitly; see [Presets](#presets). `init` never replaces an existing file.
106
+
107
+ ## Commands
108
+
109
+ | Command | Description |
110
+ |---------|-------------|
111
+ | `/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 |
114
+ | `/pignon off` | Disable routing |
115
+ | `/pignon unpin` | Re-enable routing after manual model selection |
116
+ | `/pignon log` | Show recent decider output (load progress, warnings, tracebacks) |
117
+ | `/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.
129
+
130
+ `/laya` and `/laya-stats` still work as aliases of `/pignon` and `/pignon-stats`;
131
+ they will be removed in a later release.
132
+
133
+ ## What you see
134
+
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.
136
+ - **After each routed prompt**: a decision card below your message, e.g.
137
+
138
+ ```
139
+ pignon laya-serve hard/exploration p=0.92 · 75 ms ⚡ switched to openrouter/tencent/hy4-preview · thinking low
140
+ upgrade
141
+ ```
142
+
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.
146
+ - **Footer status**: the latest verdict at a glance.
147
+
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
+ ## Configuration
165
+
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 |
219
+
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:
318
+
319
+ ```json
320
+ {
321
+ "$schema": "https://raw.githubusercontent.com/siiick/pi-pignon/main/schema/config.schema.json",
322
+ "version": 2,
323
+ "models": {
324
+ "reasoner": { "provider": "anthropic", "modelId": "claude-opus-5-5", "thinking": "high" }
325
+ }
326
+ }
327
+ ```
328
+
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
333
+
334
+ A preset fills the four built-in model names from one provider. Use it with
335
+ `extends`, and override any name under `models`:
336
+
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
411
+
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
+ ```
455
+
456
+ ## Development
457
+
458
+ Work from a clone, and point Pi at it instead of a release:
459
+
460
+ ```bash
461
+ git clone https://github.com/siiick/pi-pignon && cd pi-pignon
462
+ npm install
463
+ (cd worker && uv sync) # only for the experimental worker
464
+ pi install ./ # loads the clone in place, no copy
465
+ ```
466
+
467
+ ```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
491
+ ```
492
+
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.
507
+
508
+ ## License
509
+
510
+ [MIT](LICENSE) © 2026 Nicolas Chaintron
511
+
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.
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/siiick/pi-pignon/main/schema/config.schema.json",
3
+ "version": 2,
4
+ "models": {
5
+ "opus": { "provider": "anthropic", "modelId": "claude-opus-5-5", "thinking": "high" },
6
+ "reasoner": { "provider": "openrouter", "modelId": "z-ai/glm-5.3", "thinking": "xhigh" }
7
+ },
8
+ "tiers": [
9
+ {
10
+ "id": "trivial",
11
+ "criterion": "Mechanical edit, rename, formatting, or a single factual lookup",
12
+ "model": "fast",
13
+ "explorationAllowed": false
14
+ },
15
+ {
16
+ "id": "standard",
17
+ "criterion": "Localized change across a few files with clear intent",
18
+ "model": "balanced"
19
+ },
20
+ {
21
+ "id": "hard",
22
+ "criterion": "Multi-step investigation, debugging with unclear cause, or cross-cutting design",
23
+ "direct": "reasoner",
24
+ "exploration": "agent"
25
+ },
26
+ {
27
+ "id": "research",
28
+ "criterion": "Open-ended design or research spanning the whole system, with no clear starting point",
29
+ "model": "opus"
30
+ }
31
+ ],
32
+ "questions": { "version": "q1-research" },
33
+ "confidenceSource": "reported",
34
+ "thresholds": { "minConfidenceDowngrade": 0.9 }
35
+ }