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 +40 -0
- package/LICENSE +21 -0
- package/README.md +514 -0
- package/examples/pignon.json +35 -0
- package/package.json +68 -0
- package/schema/config.schema.json +446 -0
- package/src/compare.ts +109 -0
- package/src/config/defaults.ts +95 -0
- package/src/config/describe.ts +30 -0
- package/src/config/load.ts +445 -0
- package/src/config/migrate.ts +86 -0
- package/src/config/presets.ts +54 -0
- package/src/config/schema.ts +224 -0
- package/src/deciders/create.ts +102 -0
- package/src/deciders/jev.ts +226 -0
- package/src/deciders/laya-local.ts +718 -0
- package/src/deciders/laya-serve.ts +53 -0
- package/src/deciders/parse.ts +48 -0
- package/src/deciders/questions.ts +34 -0
- package/src/deciders/strategy.ts +225 -0
- package/src/deciders/types.ts +70 -0
- package/src/extension.ts +439 -0
- package/src/onboarding.ts +203 -0
- package/src/policy.ts +215 -0
- package/src/report.ts +171 -0
- package/src/router.ts +215 -0
- package/src/stats.ts +55 -0
- package/src/types.ts +341 -0
- package/src/ui.ts +153 -0
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
|
+
[](https://github.com/siiick/pi-pignon/actions/workflows/ci.yml)
|
|
4
|
+
[](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
|
+
}
|